Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 39 additions & 5 deletions .claude/CLAUDE.md

Large diffs are not rendered by default.

82 changes: 46 additions & 36 deletions README.md

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,14 @@ Deferred to ship SDK 58 the same day:
- **Check it on a device.** Verify, typecheck and an iOS prebuild pass, but nothing ran on hardware: launch (SDK 58's scene life cycle), swiping a toast away (`@rific/toaster` 0.8.1 only had its gesture-handler 3 peer range raised, and its tests mock gesture-handler), the board's aim arrows and chevrons (moved to Skia 2.14's `PathBuilder`), and the edge-gesture guard.
- **React Native 0.88 strict types.** `@infinitetoken/tsconfig` 0.6 resolves React Native 0.88's legacy types, and React Native drops them after 0.88. This app's own code is already clean under the strict types (checked 2026-10-16 with `customConditions: ["react-native"]` only); the errors it sees are all in package source it type-checks (`@tastic/hud` 15, `@tastic/split-screen` 6, `@rific/auto-paper` 3, `@tastic/profile` 3, `@tastic/core` 1, `@rific/updater` 1). Fix those packages first (their TODO.md files), then drop the deferral.
- **Web tab title in the static HTML.** Since SDK 58, `expo-router/head`'s `<Head>` (in `src/components/Fonts.tsx`) only sets the title after hydration; the static export's HTML has none, so the tab shows the URL until the JS loads and crawlers see no title. Export `generateMetadata()` (returns `{ title }`) from each route, or from the root layout if Expo Router supports it there, then drop `<Head>`.

## Later: drop the BoxHockey 1.0 import (not before May 2027)

PuckTastic 2.0 copies a BoxHockey 1.0 save into its own store on its first launch (`src/importBoxHockey1.ts`; CLAUDE.md, "Pack structure" > "Saved data"). It only matters for a device that hasn't opened a 2.x build yet, so it can go once almost nobody is still on 1.0: **no earlier than six months after 2.0 is on the App Store, and not before May 2027.** Before deleting, check App Store Connect (App Analytics or Sales and Trends, by app version) that 1.x is down to nothing. Anyone still on 1.0 afterwards just starts fresh.

To remove it:
1. Delete `src/importBoxHockey1.ts`.
2. In `src/pack.ts`, drop the `migrate:` line and the `AsyncStorage` and `createBoxHockey1Import` imports.
3. In `src/__tests__/store.test.ts`, delete the "a BoxHockey 1.0 save" tests.
4. In CLAUDE.md's "Saved data", replace the import paragraph with a line saying 1.0 saves are no longer read (and when it was removed).
5. Run `npm run verify`.
4 changes: 2 additions & 2 deletions app.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"expo": {
"name": "BoxHockey",
"name": "PuckTastic",
"slug": "BoxHockey",
"version": "1.1.3",
"scheme": "boxhockey",
"description": "Physics-driven tabletop box hockey. Flick the puck through the gaps and into the other player's goal, against the CPU or a friend across the table.",
"description": "Four tabletop puck games in one app: Box Hockey, Air Hockey, Paddles and Breaker, against the CPU or a friend across the table.",
"orientation": "portrait",
"platforms": [
"ios",
Expand Down
2 changes: 1 addition & 1 deletion eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"submit": {
"production": {
"ios": {
"appName": "BoxHockeyTastic"
"appName": "PuckTastic"
}
},
"preview": {}
Expand Down
28 changes: 27 additions & 1 deletion eslint.config.cjs
Original file line number Diff line number Diff line change
@@ -1 +1,27 @@
module.exports = require('@infinitetoken/eslint-config/expo')
const fs = require('node:fs')
const path = require('node:path')

const expo = require('@infinitetoken/eslint-config/expo')

// The pack's layering (CLAUDE.md, "Pack structure"): the shell and shared code never import a game,
// and a game never imports another game, so any game can leave or join the pack on its own.
const games = fs
.readdirSync(path.join(path.dirname(require.resolve('./package.json')), 'src/games'), { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)

const noGameImports = (allowed) => ({
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@/games/*', ...(allowed ? [`!@/games/${allowed}`, `!@/games/${allowed}/**`] : [])],
message: allowed ? 'A game never imports another game. Move code two games share into src/shared.' : 'The shell and shared code never import a game. A game hands the shell what it needs through its GameDefinition (src/pack.ts).'
}
]
}
]
})

module.exports = [...expo, { files: ['src/shell/**', 'src/shared/**'], rules: noGameImports(null) }, ...games.map((id) => ({ files: [`src/games/${id}/**`], rules: noGameImports(id) }))]
28 changes: 14 additions & 14 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 8 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "box-hockey",
"version": "1.1.3",
"private": true,
"description": "Physics-driven tabletop box hockey. Flick the puck through the gaps and into the other player's goal, against the CPU or a friend across the table.",
"description": "Four tabletop puck games in one app: Box Hockey, Air Hockey, Paddles and Breaker, against the CPU or a friend across the table.",
"type": "commonjs",
"main": "expo-router/entry",
"scripts": {
Expand Down Expand Up @@ -45,25 +45,25 @@
"@tastic/achievements": "^0.6.0",
"@tastic/core": "^0.14.0",
"@tastic/edge-guard": "^0.1.5",
"@tastic/hud": "^0.24.1",
"@tastic/hud": "^0.25.1",
"@tastic/input": "^0.4.1",
"@tastic/physics": "^0.3.1",
"@tastic/profile": "^0.16.2",
"@tastic/split-screen": "^0.5.4",
"@tastic/profile": "^0.17.0",
"@tastic/split-screen": "^0.5.5",
"@tastic/sprites": "^0.2.1",
"expo": "~58.0.16",
"expo-asset": "~58.0.16",
"expo-audio": "~58.0.9",
"expo-blur": "~58.0.5",
"expo-constants": "~58.0.12",
"expo-dev-client": "~58.0.13",
"expo-dev-client": "~58.0.14",
"expo-font": "~58.0.8",
"expo-haptics": "~58.0.6",
"expo-haptics": "~58.0.7",
"expo-keep-awake": "~58.0.3",
"expo-linking": "~58.0.13",
"expo-router": "~58.0.24",
"expo-sensors": "~58.0.4",
"expo-splash-screen": "~58.0.9",
"expo-splash-screen": "~58.0.10",
"expo-system-ui": "~58.0.6",
"expo-updates": "~58.0.17",
"react": "19.3.0",
Expand All @@ -81,7 +81,7 @@
},
"devDependencies": {
"@babel/core": "^7.29.7",
"@infinitetoken/eslint-config": "^0.2.0",
"@infinitetoken/eslint-config": "^0.2.1",
"@infinitetoken/jest-config": "^0.5.2",
"@infinitetoken/tsconfig": "^0.6.0",
"@react-native/jest-preset": "^0.88.0",
Expand Down
15 changes: 15 additions & 0 deletions src/__mocks__/@shopify/react-native-skia.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Skia's native module can't initialize under Jest, and its own jestSetup needs CanvasKit's wasm in
// a custom test environment. Nothing in this app's suites draws a board: they only load the modules
// a board lives in (the pack's game registry imports every game's play screen). So every export is
// an inert stand-in: any property, call or `new` gives back another stand-in.
type Inert = ((...args: unknown[]) => Inert) & Record<string | symbol, unknown>

const inert: Inert = new Proxy(function () {} as unknown as Inert, {
get: (_target, prop) => (prop === 'then' ? undefined : prop === Symbol.toPrimitive ? () => 0 : inert),
apply: () => inert,
construct: () => inert
})

module.exports = new Proxy({ __esModule: true } as Record<string | symbol, unknown>, {
get: (target, prop) => (prop in target ? target[prop] : inert)
})
2 changes: 1 addition & 1 deletion src/__tests__/SeatResultCards.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { fireEvent, render, screen, within } from '@testing-library/react-native'

import { SeatResultCards } from '@/components/SeatResultCards'
import { SeatResultCards } from '@/shared/SeatResultCards'

const COLORS = { 1: '#E53935', 2: '#1E88E5' }
const ON_COLORS = { 1: '#FFFFFF', 2: '#FFFFFF' }
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/SeatToasters.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { render, screen, within } from '@testing-library/react-native'
import { Dimensions, Text } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'

import { SeatToasters } from '@/components/SeatToasters'
import { SeatToasters } from '@/shared/SeatToasters'

// Each Toaster is drawn as its stack's name in the frame it hands renderFrame, followed by the safe-area insets the frame provides: which
// toasts a stack shows, and how it lays them out against those insets, are @rific/toaster's to test.
Expand Down
8 changes: 4 additions & 4 deletions src/__tests__/achievements.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ACHIEVEMENT_CATALOG } from '@/constants/achievements'
import { StatsState } from '@/types'
import { evaluateUnlockedIds, evaluateUnlockedIdsForProfile, unlockedKey } from '@/utils/achievementEngine'
import { DEFAULT_PROFILE_STATS, DEFAULT_STATS } from '@/utils/statsValidation'
import { ACHIEVEMENT_CATALOG } from '@/games/boxhockey/constants/achievements'
import { StatsState } from '@/games/boxhockey/types'
import { evaluateUnlockedIds, evaluateUnlockedIdsForProfile, unlockedKey } from '@/games/boxhockey/utils/achievementEngine'
import { DEFAULT_PROFILE_STATS, DEFAULT_STATS } from '@/games/boxhockey/utils/statsValidation'

function stats(overrides: Partial<StatsState>): StatsState {
return { ...DEFAULT_STATS, ...overrides }
Expand Down
6 changes: 3 additions & 3 deletions src/__tests__/aiOpponent.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { BoardLayout } from '@/types'
import { AIDifficulty, planAIShot, randomAIThinkDelayMs, randomInterceptReactionMs } from '@/utils/aiOpponent'
import { stepPhysics } from '@/utils/physics'
import { BoardLayout } from '@/games/boxhockey/types'
import { AIDifficulty, planAIShot, randomAIThinkDelayMs, randomInterceptReactionMs } from '@/games/boxhockey/utils/aiOpponent'
import { stepPhysics } from '@/games/boxhockey/utils/physics'

// A standard open board with no obstacles/wells - mirrors the fixture in physics.test.ts.
const openBoard: BoardLayout = {
Expand Down
12 changes: 6 additions & 6 deletions src/__tests__/backgroundPause.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import { act, renderHook } from '@testing-library/react-native'
import { useEffect } from 'react'
import { AppState } from 'react-native'

import { FACEOFF_COUNTDOWN_STEP_MS, FRICTION_LEVELS, INTERCEPT_SPEED_THRESHOLDS } from '@/constants/game'
import { useAIOpponent } from '@/hooks/useAIOpponent'
import { useFaceoff } from '@/hooks/useFaceoff'
import { useGameState } from '@/hooks/useGameState'
import { DEFAULT_SETTINGS } from '@/redux/settingsSlice'
import { BoardLayout, Player, TipoffMode } from '@/types'
import { FACEOFF_COUNTDOWN_STEP_MS, FRICTION_LEVELS, INTERCEPT_SPEED_THRESHOLDS } from '@/games/boxhockey/constants/game'
import { useAIOpponent } from '@/games/boxhockey/hooks/useAIOpponent'
import { useFaceoff } from '@/games/boxhockey/hooks/useFaceoff'
import { useGameState } from '@/games/boxhockey/hooks/useGameState'
import { DEFAULT_SETTINGS } from '@/games/boxhockey/redux/settingsSlice'
import { BoardLayout, Player, TipoffMode } from '@/games/boxhockey/types'

import { emitAppState } from '../testUtils/appState'

Expand Down
8 changes: 4 additions & 4 deletions src/__tests__/boardGeometry.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { FRICTION_LEVELS, GOAL_LINE_STROKE_WIDTH, MAX_SPEED, WALL_POST_RADIUS_RATIO } from '@/constants/game'
import { BoardVariant, PuckSizeLevel } from '@/types'
import { computeBoardLayout, faceoffZoneCenters, faceoffZoneRadius } from '@/utils/board'
import { circleVsRect, computeTrajectory, stepPhysics } from '@/utils/physics'
import { FRICTION_LEVELS, GOAL_LINE_STROKE_WIDTH, MAX_SPEED, WALL_POST_RADIUS_RATIO } from '@/games/boxhockey/constants/game'
import { BoardVariant, PuckSizeLevel } from '@/games/boxhockey/types'
import { computeBoardLayout, faceoffZoneCenters, faceoffZoneRadius } from '@/games/boxhockey/utils/board'
import { circleVsRect, computeTrajectory, stepPhysics } from '@/games/boxhockey/utils/physics'

// Integration coverage for computeBoardLayout + stepPhysics together, using the REAL layout
// geometry - unlike physics.test.ts/aiOpponent.test.ts, which construct a synthetic BoardLayout
Expand Down
8 changes: 4 additions & 4 deletions src/__tests__/faceoff.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { FACEOFF_KNOCK_MAX_POWER, FACEOFF_KNOCK_MIN_POWER, FACEOFF_MARGIN_MAX_MS, FACEOFF_REACTION_MAX_MS, FRICTION_LEVELS, MAX_SPEED } from '@/constants/game'
import { makeReducer } from '@/hooks/useGameState'
import { BoardLayout, ControlScheme, FaceoffState, GameState, Player } from '@/types'
import { computeFaceoffKnock } from '@/utils/faceoff'
import { FACEOFF_KNOCK_MAX_POWER, FACEOFF_KNOCK_MIN_POWER, FACEOFF_MARGIN_MAX_MS, FACEOFF_REACTION_MAX_MS, FRICTION_LEVELS, MAX_SPEED } from '@/games/boxhockey/constants/game'
import { makeReducer } from '@/games/boxhockey/hooks/useGameState'
import { BoardLayout, ControlScheme, FaceoffState, GameState, Player } from '@/games/boxhockey/types'
import { computeFaceoffKnock } from '@/games/boxhockey/utils/faceoff'

// Same synthetic board physics.test.ts uses.
const board: BoardLayout = {
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/goalWedges.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { computeGoalWedges } from '@/utils/goalWedges'
import { computeGoalWedges } from '@tastic/physics'

describe('computeGoalWedges', () => {
it('returns exactly `count` wedges', () => {
Expand Down
8 changes: 4 additions & 4 deletions src/__tests__/gravityRink.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { FRICTION_LEVELS, MAX_SPEED } from '@/constants/game'
import { BoardLayout, BoardVariant, PuckState } from '@/types'
import { computeBoardLayout } from '@/utils/board'
import { stepPhysics } from '@/utils/physics'
import { FRICTION_LEVELS, MAX_SPEED } from '@/games/boxhockey/constants/game'
import { BoardLayout, BoardVariant, PuckState } from '@/games/boxhockey/types'
import { computeBoardLayout } from '@/games/boxhockey/utils/board'
import { stepPhysics } from '@/games/boxhockey/utils/physics'

// The bug seen while capturing App Store screenshots (2026-10-18): on Gravity with Rink, against
// the CPU on Hard, the puck came to rest behind the bottom goal line beside the goal mouth, outside
Expand Down
Loading
Loading