import { describe, expect, it } from 'vitest' import { GameModel } from './game-model' import type { StoneId, StonePath, StoneState, Team } from './protocol' import { SAMPLE_RATE_HZ } from './protocol' function sid(team: Team, n: number): StoneId { return { team, n } } function stone(partial: Partial & Pick): StoneState { return { x: 0, y: 30, rotation: 0, ...partial, } } /** Build (x,y,theta) samples; time comes from index / SAMPLE_RATE_HZ. */ function pathSamples( points: { x: number; y: number; theta?: number }[], ): [number, number, number][] { return points.map((p) => [p.x, p.y, p.theta ?? 0]) } function stonePath( stone_id: StoneId, team: Team, trajectory: [number, number, number][], ): StonePath { return { stone_id, rotation: trajectory[trajectory.length - 1]?.[2] ?? 0, team, trajectory, } } describe('GameModel multi-path trajectory animation', () => { it('returns two DrawableStones in parallel mid-trajectory', () => { const model = new GameModel() model.state.stones = [ stone({ id: sid('team1', 1), team: 'team1' }), stone({ id: sid('team2', 1), team: 'team2' }), ] model.state.turnTeam = 'team1' // 21 samples → duration 20/40 = 0.5s; mid at 0.25s is sample 10 → y=11 const n = 21 const path1 = pathSamples( Array.from({ length: n }, (_, i) => ({ x: 0, y: 10 + i * 0.1, theta: 0 })), ) const path2 = pathSamples( Array.from({ length: n }, (_, i) => ({ x: 1, y: 10 + i * 0.1, theta: 0 })), ) const paths: StonePath[] = [ stonePath(sid('team1', 1), 'team1', path1), stonePath(sid('team2', 1), 'team2', path2), ] model.startTrajectory(paths) expect(model.state.animating).toBe(true) const mid = performance.now() + 250 const drawn = model.tick(mid) expect(drawn).toHaveLength(2) expect(drawn.map((d) => d.team).sort()).toEqual(['team1', 'team2']) // Mid-sample y ≈ 11 for both paths (y never reaches hog line → no trim) for (const d of drawn) { expect(d.y).toBeCloseTo(11, 0) } }) it('clears animating when elapsed reaches maxTotal on a short path', () => { const model = new GameModel() model.state.stones = [stone({ id: sid('team1', 1), team: 'team1' })] model.state.turnTeam = 'team1' // 3 samples → max t = 2/40 = 0.05s model.startTrajectory([ stonePath( sid('team1', 1), 'team1', pathSamples([ { x: 0, y: 10 }, { x: 0, y: 10.5 }, { x: 0, y: 11 }, ]), ), ]) expect(model.state.animating).toBe(true) const afterEnd = performance.now() + 200 const drawn = model.tick(afterEnd) expect(drawn).toEqual([]) expect(model.state.animating).toBe(false) }) it('trims thrown stone path to hog line when id is not in existing stones', () => { const model = new GameModel() // Only stone team2/1 is already on the sheet; team1/1 is the newly thrown rock. model.state.stones = [stone({ id: sid('team2', 1), team: 'team2', x: 0.5, y: 35 })] model.state.turnTeam = 'team1' const thrownPath = pathSamples([ { x: 0, y: 2, theta: 0.1 }, { x: 0, y: 20, theta: 0.2 }, { x: 0, y: 21.5, theta: 0.3 }, { x: 0, y: 30, theta: 0.4 }, ]) model.startTrajectory([stonePath(sid('team1', 1), 'team1', thrownPath)]) expect(model.state.animating).toBe(true) // Immediately after start: hog-trimmed path begins at y=20 (sample before hog) const atStart = performance.now() const drawn = model.tick(atStart) expect(drawn).toHaveLength(1) expect(drawn[0].team).toBe('team1') expect(drawn[0].y).toBeCloseTo(20, 0) // Must not still be at the hack (y=2) expect(drawn[0].y).toBeGreaterThan(15) // Uses body theta from path (small elapsed may blend toward next sample) expect(drawn[0].rotation).toBeCloseTo(0.2, 2) }) it('stores scoreboard and stones_remaining from game_state', () => { const model = new GameModel() model.updateGameState({ type: 'game_state', end: 2, scores: [1, 0], hammer: 'team2', turn_team: 'team1', scoreboard: [{ end: 1, hammer: 'team1', team1: 1, team2: 0 }], stones_remaining: [7, 8], stones: [], phase: 'playing', }) expect(model.state.scoreboard).toHaveLength(1) expect(model.state.scoreboard[0].team1).toBe(1) expect(model.state.stonesRemaining).toEqual([7, 8]) expect(model.state.hammer).toBe('team2') }) it('uses sample index for time (SAMPLE_RATE_HZ)', () => { expect(SAMPLE_RATE_HZ).toBe(40) }) it('does not snap a takeout target back to its pre-throw rest position', () => { const model = new GameModel() const rest = stone({ id: sid('team1', 1), team: 'team1', x: 0.2, y: 38.5 }) model.state.stones = [rest] model.state.turnTeam = 'team2' // Shared clock: thrown reaches hog ~ sample 2; target then exits past backline. const thrownPath = pathSamples([ { x: 0, y: 2 }, { x: 0, y: 20 }, { x: 0, y: 25 }, { x: 0, y: 38.5 }, { x: 0, y: 40 }, ]) const targetPath = pathSamples([ { x: 0.2, y: 38.5 }, { x: 0.2, y: 38.5 }, { x: 0.2, y: 38.5 }, { x: 0.4, y: 39.5 }, { x: 1.0, y: 42.0 }, // edge past backline → out ]) model.startTrajectory([ stonePath(sid('team2', 1), 'team2', thrownPath), stonePath(sid('team1', 1), 'team1', targetPath), ]) // Final game_state: only shooter remains in play. model.updateGameState({ type: 'game_state', end: 1, scores: [0, 0], hammer: 'team1', turn_team: 'team1', scoreboard: [], stones_remaining: [8, 7], stones: [ stone({ id: sid('team2', 1), team: 'team2', x: 0, y: 39 }), ], phase: 'playing', }) const afterEnd = performance.now() + 5000 model.tick(afterEnd) expect(model.state.animating).toBe(false) expect(model.state.stones).toHaveLength(1) expect(model.state.stones[0]?.id).toEqual(sid('team2', 1)) // Must not resurrect target at rest position. expect(model.state.stones.some((s) => s.id.team === 'team1' && s.id.n === 1)).toBe( false, ) }) it('does not resurrect a removed rock at the start of the next throw', () => { const model = new GameModel() model.state.stones = [stone({ id: sid('team1', 1), team: 'team1', x: 0.2, y: 38.5 })] model.state.turnTeam = 'team2' // First throw: hit team1/1 out. Server post-state has only shooter. model.startTrajectory([ stonePath( sid('team2', 1), 'team2', pathSamples([ { x: 0, y: 2 }, { x: 0, y: 20 }, { x: 0, y: 38 }, { x: 0, y: 39 }, ]), ), stonePath( sid('team1', 1), 'team1', pathSamples([ { x: 0.2, y: 38.5 }, { x: 0.2, y: 38.5 }, { x: 0.5, y: 40 }, { x: 1.2, y: 42 }, ]), ), ]) model.updateGameState({ type: 'game_state', end: 1, scores: [0, 0], hammer: 'team1', turn_team: 'team1', scoreboard: [], stones_remaining: [8, 7], stones: [stone({ id: sid('team2', 1), team: 'team2', x: 0, y: 39 })], phase: 'playing', }) model.tick(performance.now() + 5000) expect(model.state.stones.map((s) => s.id)).toEqual([sid('team2', 1)]) // Second throw: only shooter already on ice + new team1 rock. model.state.turnTeam = 'team1' model.startTrajectory([ stonePath( sid('team2', 1), 'team2', pathSamples([ { x: 0, y: 39 }, { x: 0, y: 39 }, { x: 0, y: 39 }, ]), ), stonePath( sid('team1', 2), 'team1', pathSamples([ { x: 0, y: 2 }, { x: 0, y: 20 }, { x: 0, y: 30 }, ]), ), ]) // First frame of next throw must not draw removed team1/1 at old rest. const drawn = model.tick(performance.now()) expect(drawn.every((d) => !(d.team === 'team1' && Math.abs(d.x - 0.2) < 0.01 && Math.abs(d.y - 38.5) < 0.01))).toBe( true, ) expect(model.state.stones.some((s) => s.id.team === 'team1' && s.id.n === 1)).toBe(false) }) })