curltastic/frontend/src/game-model.test.ts
Jason Dekarske 99a4f59a39 actual fix
2026-07-11 13:17:41 -07:00

279 lines
8.2 KiB
TypeScript

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