fix(frontend): lock multi-stone parallel trajectory animation

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Dekarske 2026-07-10 23:17:21 -07:00
parent 2e11f005dc
commit 20c592d8ac

View File

@ -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<StoneState> & Pick<StoneState, 'id' | 'team'>): 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)
})
})