Skip to content

Commit aaf6c43

Browse files
committed
Extract client phase telemetry and control flow
1 parent 3067f8d commit aaf6c43

File tree

7 files changed

+448
-85
lines changed

7 files changed

+448
-85
lines changed

src/client/game/integration.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ const createDeps = (
100100
presentCombatResults: track('presentCombatResults'),
101101
showGameOverOutcome: track('showGameOverOutcome'),
102102
storePlayerToken: track('storePlayerToken'),
103+
resetTurnTelemetry: track('resetTurnTelemetry'),
103104
onAnimationComplete: track('onAnimationComplete'),
104105
logScenarioBriefing: track('logScenarioBriefing'),
105106
deserializeState: (raw: GameState) => raw,
@@ -170,6 +171,7 @@ describe('client integration: connection flow', () => {
170171
state,
171172
} as S2C);
172173

174+
expect(deps.calls.resetTurnTelemetry).toHaveLength(1);
173175
expect(deps.calls.applyGameState).toEqual([[state]]);
174176
expect(deps.calls['renderer.clearTrails']).toHaveLength(1);
175177
expect(deps.calls['ui.clearLog']).toHaveLength(1);

src/client/game/message-handler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface MessageHandlerDeps {
3030
) => void;
3131
showGameOverOutcome: (won: boolean, reason: string) => void;
3232
storePlayerToken: (code: string, token: string) => void;
33+
resetTurnTelemetry: () => void;
3334
onAnimationComplete: () => void;
3435
logScenarioBriefing: () => void;
3536
deserializeState: (raw: GameState) => GameState;
@@ -81,6 +82,7 @@ export const handleServerMessage = (
8182
playPhaseChange();
8283
break;
8384
case 'gameStart':
85+
deps.resetTurnTelemetry();
8486
deps.applyGameState(deps.deserializeState(plan.state));
8587
deps.renderer.clearTrails();
8688
deps.ui.clearLog();
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import type { GameState, PlayerState, Ship } from '../../shared/types';
4+
import type { ClientState } from './phase';
5+
import {
6+
type PhaseControllerDeps,
7+
transitionClientPhase,
8+
} from './phase-controller';
9+
10+
const createShip = (overrides: Partial<Ship> = {}): Ship => ({
11+
id: 'ship-0',
12+
type: 'packet',
13+
owner: 0,
14+
position: { q: 0, r: 0 },
15+
velocity: { dq: 0, dr: 0 },
16+
fuel: 10,
17+
cargoUsed: 0,
18+
nukesLaunchedSinceResupply: 0,
19+
resuppliedThisTurn: false,
20+
landed: false,
21+
destroyed: false,
22+
detected: true,
23+
damage: { disabledTurns: 0 },
24+
...overrides,
25+
});
26+
27+
const createPlayers = (): [PlayerState, PlayerState] => [
28+
{
29+
connected: true,
30+
ready: true,
31+
targetBody: 'Mars',
32+
homeBody: 'Terra',
33+
bases: [],
34+
escapeWins: false,
35+
},
36+
{
37+
connected: true,
38+
ready: true,
39+
targetBody: 'Terra',
40+
homeBody: 'Mars',
41+
bases: [],
42+
escapeWins: false,
43+
},
44+
];
45+
46+
const createState = (overrides: Partial<GameState> = {}): GameState => ({
47+
gameId: 'PHASE',
48+
scenario: 'biplanetary',
49+
scenarioRules: {},
50+
escapeMoralVictoryAchieved: false,
51+
turnNumber: 2,
52+
phase: 'astrogation',
53+
activePlayer: 0,
54+
ships: [createShip(), createShip({ id: 'enemy', owner: 1 })],
55+
ordnance: [],
56+
pendingAstrogationOrders: null,
57+
pendingAsteroidHazards: [],
58+
destroyedAsteroids: [],
59+
destroyedBases: [],
60+
players: createPlayers(),
61+
winner: null,
62+
winReason: null,
63+
...overrides,
64+
});
65+
66+
const createDeps = (
67+
overrides: Partial<PhaseControllerDeps> = {},
68+
): {
69+
deps: PhaseControllerDeps;
70+
setState: ReturnType<typeof vi.fn<(state: ClientState) => void>>;
71+
beginCombat: ReturnType<typeof vi.fn<() => void>>;
72+
runLocalAI: ReturnType<typeof vi.fn<() => void>>;
73+
onTurnLogged: ReturnType<
74+
typeof vi.fn<
75+
(
76+
turnNumber: number,
77+
context: { scenario: string; isLocalGame: boolean },
78+
) => void
79+
>
80+
>;
81+
logTurn: ReturnType<
82+
typeof vi.fn<(turnNumber: number, playerLabel: string) => void>
83+
>;
84+
playPhaseSound: ReturnType<typeof vi.fn<() => void>>;
85+
} => {
86+
const setState = vi.fn<(state: ClientState) => void>();
87+
const beginCombat = vi.fn<() => void>();
88+
const runLocalAI = vi.fn<() => void>();
89+
const onTurnLogged =
90+
vi.fn<
91+
(
92+
turnNumber: number,
93+
context: { scenario: string; isLocalGame: boolean },
94+
) => void
95+
>();
96+
const logTurn = vi.fn<(turnNumber: number, playerLabel: string) => void>();
97+
const playPhaseSound = vi.fn<() => void>();
98+
99+
const deps: PhaseControllerDeps = {
100+
gameState: createState(),
101+
playerId: 0,
102+
lastLoggedTurn: -1,
103+
isLocalGame: false,
104+
scenario: 'biplanetary',
105+
onTurnLogged,
106+
logTurn,
107+
beginCombat,
108+
setState,
109+
runLocalAI,
110+
playPhaseSound,
111+
...overrides,
112+
};
113+
114+
return {
115+
deps,
116+
setState,
117+
beginCombat,
118+
runLocalAI,
119+
onTurnLogged,
120+
logTurn,
121+
playPhaseSound,
122+
};
123+
};
124+
125+
describe('transitionClientPhase', () => {
126+
it('logs a new turn, sets the next state, and plays the phase sound', () => {
127+
const controller = createDeps({
128+
gameState: createState({ phase: 'astrogation', activePlayer: 0 }),
129+
});
130+
131+
transitionClientPhase(controller.deps);
132+
133+
expect(controller.onTurnLogged).toHaveBeenCalledWith(2, {
134+
scenario: 'biplanetary',
135+
isLocalGame: false,
136+
});
137+
expect(controller.logTurn).toHaveBeenCalledWith(2, 'You');
138+
expect(controller.setState).toHaveBeenCalledWith('playing_astrogation');
139+
expect(controller.playPhaseSound).toHaveBeenCalledTimes(1);
140+
expect(controller.runLocalAI).not.toHaveBeenCalled();
141+
});
142+
143+
it('begins combat immediately when asteroid hazards are pending', () => {
144+
const controller = createDeps({
145+
gameState: createState({
146+
phase: 'combat',
147+
activePlayer: 0,
148+
pendingAsteroidHazards: [{ shipId: 'ship-0', hex: { q: 1, r: 1 } }],
149+
}),
150+
lastLoggedTurn: 2,
151+
});
152+
153+
transitionClientPhase(controller.deps);
154+
155+
expect(controller.beginCombat).toHaveBeenCalledTimes(1);
156+
expect(controller.setState).not.toHaveBeenCalled();
157+
expect(controller.playPhaseSound).not.toHaveBeenCalled();
158+
});
159+
160+
it('runs the AI when local play transitions to the opponent turn', () => {
161+
const controller = createDeps({
162+
gameState: createState({ phase: 'ordnance', activePlayer: 1 }),
163+
isLocalGame: true,
164+
lastLoggedTurn: 2,
165+
});
166+
167+
transitionClientPhase(controller.deps);
168+
169+
expect(controller.setState).toHaveBeenCalledWith('playing_opponentTurn');
170+
expect(controller.runLocalAI).toHaveBeenCalledTimes(1);
171+
expect(controller.playPhaseSound).not.toHaveBeenCalled();
172+
});
173+
174+
it('does nothing when no playable game state is available', () => {
175+
const controller = createDeps({ gameState: null });
176+
177+
transitionClientPhase(controller.deps);
178+
179+
expect(controller.onTurnLogged).not.toHaveBeenCalled();
180+
expect(controller.setState).not.toHaveBeenCalled();
181+
expect(controller.beginCombat).not.toHaveBeenCalled();
182+
});
183+
184+
it('does nothing once the game is over', () => {
185+
const controller = createDeps({
186+
gameState: createState({ phase: 'gameOver', winner: 0 }),
187+
});
188+
189+
transitionClientPhase(controller.deps);
190+
191+
expect(controller.onTurnLogged).not.toHaveBeenCalled();
192+
expect(controller.setState).not.toHaveBeenCalled();
193+
expect(controller.beginCombat).not.toHaveBeenCalled();
194+
});
195+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { GameState } from '../../shared/types';
2+
import { playPhaseChange } from '../audio';
3+
import { type ClientState, derivePhaseTransition } from './phase';
4+
import type { TurnTelemetryContext } from './turn-telemetry';
5+
6+
export interface PhaseControllerDeps {
7+
gameState: GameState | null;
8+
playerId: number;
9+
lastLoggedTurn: number;
10+
isLocalGame: boolean;
11+
scenario: string;
12+
onTurnLogged: (turnNumber: number, context: TurnTelemetryContext) => void;
13+
logTurn: (turnNumber: number, playerLabel: string) => void;
14+
beginCombat: () => void;
15+
setState: (state: ClientState) => void;
16+
runLocalAI: () => void;
17+
playPhaseSound?: () => void;
18+
}
19+
20+
export const transitionClientPhase = (deps: PhaseControllerDeps): void => {
21+
if (!deps.gameState || deps.gameState.phase === 'gameOver') {
22+
return;
23+
}
24+
25+
const transition = derivePhaseTransition(
26+
deps.gameState,
27+
deps.playerId,
28+
deps.lastLoggedTurn,
29+
deps.isLocalGame,
30+
);
31+
32+
if (transition.turnLogNumber !== null && transition.turnLogPlayerLabel) {
33+
deps.onTurnLogged(transition.turnLogNumber, {
34+
scenario: deps.scenario,
35+
isLocalGame: deps.isLocalGame,
36+
});
37+
deps.logTurn(transition.turnLogNumber, transition.turnLogPlayerLabel);
38+
}
39+
40+
if (transition.beginCombatPhase) {
41+
deps.beginCombat();
42+
return;
43+
}
44+
45+
if (!transition.nextState) {
46+
return;
47+
}
48+
49+
deps.setState(transition.nextState);
50+
51+
if (transition.playPhaseSound) {
52+
(deps.playPhaseSound ?? playPhaseChange)();
53+
}
54+
55+
if (transition.runLocalAI) {
56+
deps.runLocalAI();
57+
}
58+
};
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import { TurnTelemetryTracker } from './turn-telemetry';
4+
5+
describe('TurnTelemetryTracker', () => {
6+
it('tracks player phase durations and emits turn telemetry on rollover', () => {
7+
let now = 100;
8+
const trackEvent =
9+
vi.fn<(event: string, props?: Record<string, unknown>) => void>();
10+
const telemetry = new TurnTelemetryTracker({
11+
now: () => now,
12+
trackEvent,
13+
});
14+
15+
telemetry.onTurnLogged(1, {
16+
scenario: 'biplanetary',
17+
isLocalGame: false,
18+
});
19+
telemetry.onStateChanged('menu', 'playing_astrogation');
20+
21+
now = 140;
22+
telemetry.onStateChanged('playing_astrogation', 'playing_opponentTurn');
23+
24+
now = 165;
25+
telemetry.onStateChanged('playing_opponentTurn', 'playing_ordnance');
26+
27+
now = 200;
28+
telemetry.onStateChanged('playing_ordnance', 'menu');
29+
30+
now = 240;
31+
telemetry.onTurnLogged(2, {
32+
scenario: 'biplanetary',
33+
isLocalGame: false,
34+
});
35+
36+
expect(trackEvent).toHaveBeenCalledTimes(1);
37+
expect(trackEvent).toHaveBeenCalledWith('turn_completed', {
38+
turn: 1,
39+
totalMs: 140,
40+
phases: {
41+
astrogation: 40,
42+
ordnance: 35,
43+
},
44+
scenario: 'biplanetary',
45+
mode: 'multiplayer',
46+
});
47+
expect(telemetry.getLastLoggedTurn()).toBe(2);
48+
});
49+
50+
it('reset clears prior session state before the next turn begins', () => {
51+
let now = 10;
52+
const trackEvent =
53+
vi.fn<(event: string, props?: Record<string, unknown>) => void>();
54+
const telemetry = new TurnTelemetryTracker({
55+
now: () => now,
56+
trackEvent,
57+
});
58+
59+
telemetry.onTurnLogged(4, {
60+
scenario: 'test',
61+
isLocalGame: true,
62+
});
63+
telemetry.onStateChanged('menu', 'playing_combat');
64+
65+
now = 30;
66+
telemetry.onStateChanged('playing_combat', 'menu');
67+
telemetry.reset();
68+
69+
now = 50;
70+
telemetry.onTurnLogged(1, {
71+
scenario: 'test',
72+
isLocalGame: true,
73+
});
74+
75+
expect(trackEvent).not.toHaveBeenCalled();
76+
expect(telemetry.getLastLoggedTurn()).toBe(1);
77+
});
78+
});

0 commit comments

Comments
 (0)