import { describe, expect, it } from 'vitest' import { GameModel } from './game-model' import type { ServerStoneTrajectory, StoneState } from './protocol' import { SAMPLE_RATE_HZ } from './protocol' function stone(partial: Partial & Pick): StoneState { return { x: 0, y: 30, rotation: 0, active: true, ...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]) } describe('GameModel multi-path trajectory animation', () => { it('returns two DrawableStones in parallel mid-trajectory', () => { const model = new GameModel() model.state.stones = [stone({ id: 1, team: 'red' }), stone({ id: 2, team: 'yellow' })] model.state.turnTeam = 'red' // 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: ServerStoneTrajectory[] = [ { stone_id: 1, path: path1 }, { stone_id: 2, path: 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(['red', 'yellow']) // 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: 1, team: 'red' })] model.state.turnTeam = 'red' // 3 samples → max t = 2/40 = 0.05s model.startTrajectory([ { stone_id: 1, path: 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 1 is already on the sheet; stone 2 is the newly thrown rock. model.state.stones = [stone({ id: 1, team: 'yellow', x: 0.5, y: 35 })] model.state.turnTeam = 'red' 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([{ stone_id: 2, path: 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('red') // turnTeam fallback for unknown id 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('uses sample index for time (SAMPLE_RATE_HZ)', () => { expect(SAMPLE_RATE_HZ).toBe(40) }) })