diff --git a/frontend/src/game-model.test.ts b/frontend/src/game-model.test.ts new file mode 100644 index 0000000..14ad192 --- /dev/null +++ b/frontend/src/game-model.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import { GameModel } from './game-model' +import type { ServerStoneTrajectory, StoneState } from './protocol' + +function stone(partial: Partial & Pick): StoneState { + return { + x: 0, + y: 30, + rotation: 0, + active: true, + ...partial, + } +} + +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' + + const paths: ServerStoneTrajectory[] = [ + { + stone_id: 1, + path: [ + [0, 10, 0], + [0, 11, 0.5], + [0, 12, 1.0], + ], + }, + { + stone_id: 2, + path: [ + [1, 10, 0], + [1, 11, 0.5], + [1, 12, 1.0], + ], + }, + ] + + model.startTrajectory(paths) + expect(model.state.animating).toBe(true) + + const mid = performance.now() + 500 + 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 shift) + 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' + + model.startTrajectory([ + { + stone_id: 1, + path: [ + [0, 10, 0], + [0, 10.5, 0.2], + [0, 11, 0.4], + ], + }, + ]) + expect(model.state.animating).toBe(true) + + const afterEnd = performance.now() + 500 + 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: [number, number, number][] = [ + [0, 2, 0], + [0, 20, 1], + [0, 21.5, 2], + [0, 30, 3], + ] + + 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), t=0 + 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) + }) +})