Skip to content

Commit 05786f0

Browse files
committed
Extract client message planning helpers
1 parent 6e0cc59 commit 05786f0

File tree

3 files changed

+366
-39
lines changed

3 files changed

+366
-39
lines changed
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import type { CombatResult, GameState, MovementEvent, OrdnanceMovement, S2C, Ship, ShipMovement } from '../shared/types';
4+
import type { ClientState } from './game-client-phase';
5+
import { deriveClientMessagePlan } from './game-client-messages';
6+
7+
function createShip(overrides: Partial<Ship> = {}): Ship {
8+
return {
9+
id: 'ship-0',
10+
type: 'packet',
11+
owner: 0,
12+
position: { q: 0, r: 0 },
13+
velocity: { dq: 0, dr: 0 },
14+
fuel: 10,
15+
cargoUsed: 0,
16+
nukesLaunchedSinceResupply: 0,
17+
resuppliedThisTurn: false,
18+
landed: false,
19+
destroyed: false,
20+
detected: true,
21+
damage: { disabledTurns: 0 },
22+
...overrides,
23+
};
24+
}
25+
26+
function createState(overrides: Partial<GameState> = {}): GameState {
27+
return {
28+
gameId: 'MSG',
29+
scenario: 'test',
30+
scenarioRules: {},
31+
escapeMoralVictoryAchieved: false,
32+
turnNumber: 4,
33+
phase: 'astrogation',
34+
activePlayer: 0,
35+
ships: [createShip(), createShip({ id: 'enemy', owner: 1 })],
36+
ordnance: [],
37+
pendingAstrogationOrders: null,
38+
pendingAsteroidHazards: [],
39+
destroyedAsteroids: [],
40+
destroyedBases: [],
41+
players: [
42+
{ connected: true, ready: true, targetBody: 'Mars', homeBody: 'Terra', bases: [], escapeWins: false },
43+
{ connected: true, ready: true, targetBody: 'Terra', homeBody: 'Mars', bases: [], escapeWins: false },
44+
],
45+
winner: null,
46+
winReason: null,
47+
...overrides,
48+
};
49+
}
50+
51+
function derive(msg: S2C, currentState: ClientState = 'connecting') {
52+
return deriveClientMessagePlan(currentState, 2, 0, 5_000, msg);
53+
}
54+
55+
describe('game-client-messages', () => {
56+
it('derives welcome handling from reconnect and current state', () => {
57+
expect(derive({
58+
type: 'welcome',
59+
playerId: 1,
60+
code: 'ABCDE',
61+
playerToken: 'player-token',
62+
})).toEqual({
63+
kind: 'welcome',
64+
playerId: 1,
65+
code: 'ABCDE',
66+
playerToken: 'player-token',
67+
clearInviteLink: true,
68+
showReconnectToast: true,
69+
nextState: 'waitingForOpponent',
70+
});
71+
});
72+
73+
it('derives game start, movement, combat, and state update plans', () => {
74+
const movementState = createState();
75+
const movements: ShipMovement[] = [{
76+
shipId: 'ship-0',
77+
from: { q: 0, r: 0 },
78+
to: { q: 1, r: 0 },
79+
path: [],
80+
newVelocity: { dq: 1, dr: 0 },
81+
fuelSpent: 1,
82+
gravityEffects: [],
83+
crashed: false,
84+
landedAt: null,
85+
}];
86+
const ordnanceMovements: OrdnanceMovement[] = [];
87+
const events: MovementEvent[] = [];
88+
const results: CombatResult[] = [{
89+
attackerIds: ['ship-0'],
90+
targetId: 'enemy',
91+
targetType: 'ship',
92+
attackType: 'gun',
93+
odds: '1-1',
94+
attackStrength: 1,
95+
defendStrength: 1,
96+
rangeMod: 0,
97+
velocityMod: 0,
98+
dieRoll: 3,
99+
modifiedRoll: 3,
100+
damageType: 'disabled',
101+
disabledTurns: 1,
102+
counterattack: null,
103+
}];
104+
105+
expect(derive({
106+
type: 'gameStart',
107+
state: createState({ phase: 'fleetBuilding' }),
108+
}, 'waitingForOpponent')).toEqual({
109+
kind: 'gameStart',
110+
state: createState({ phase: 'fleetBuilding' }),
111+
nextState: 'playing_fleetBuilding',
112+
});
113+
114+
expect(derive({
115+
type: 'movementResult',
116+
state: movementState,
117+
movements,
118+
ordnanceMovements,
119+
events,
120+
}, 'playing_astrogation')).toEqual({
121+
kind: 'movementResult',
122+
state: movementState,
123+
movements,
124+
ordnanceMovements,
125+
events,
126+
});
127+
128+
expect(derive({
129+
type: 'combatResult',
130+
state: movementState,
131+
results,
132+
}, 'playing_combat')).toEqual({
133+
kind: 'combatResult',
134+
state: movementState,
135+
results,
136+
shouldTransition: true,
137+
});
138+
139+
expect(derive({
140+
type: 'stateUpdate',
141+
state: movementState,
142+
}, 'playing_movementAnim')).toEqual({
143+
kind: 'stateUpdate',
144+
state: movementState,
145+
shouldTransition: false,
146+
});
147+
});
148+
149+
it('derives endgame, rematch, disconnect, error, and pong plans', () => {
150+
expect(derive({
151+
type: 'gameOver',
152+
winner: 1,
153+
reason: 'Lost all ships',
154+
}, 'playing_combat')).toEqual({
155+
kind: 'gameOver',
156+
won: false,
157+
reason: 'Lost all ships',
158+
});
159+
160+
expect(derive({ type: 'rematchPending' }, 'gameOver')).toEqual({
161+
kind: 'rematchPending',
162+
});
163+
164+
expect(derive({ type: 'opponentDisconnected' }, 'playing_ordnance')).toEqual({
165+
kind: 'opponentDisconnected',
166+
nextState: 'gameOver',
167+
won: true,
168+
reason: 'Opponent disconnected',
169+
});
170+
171+
expect(derive({ type: 'error', message: 'Bad request' }, 'playing_astrogation')).toEqual({
172+
kind: 'error',
173+
message: 'Bad request',
174+
});
175+
176+
expect(derive({ type: 'pong', t: 4_000 }, 'playing_astrogation')).toEqual({
177+
kind: 'pong',
178+
latencyMs: 1_000,
179+
});
180+
181+
expect(derive({ type: 'pong', t: 0 }, 'playing_astrogation')).toEqual({
182+
kind: 'pong',
183+
latencyMs: null,
184+
});
185+
});
186+
187+
it('marks match found as a phase-change notification', () => {
188+
expect(derive({ type: 'matchFound' }, 'waitingForOpponent')).toEqual({
189+
kind: 'matchFound',
190+
});
191+
});
192+
});

src/client/game-client-messages.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import type { CombatResult, GameState, MovementEvent, OrdnanceMovement, S2C, ShipMovement } from '../shared/types';
2+
import type { ClientState } from './game-client-phase';
3+
import {
4+
deriveGameStartClientState,
5+
deriveWelcomeHandling,
6+
shouldTransitionAfterStateUpdate,
7+
} from './game-client-network';
8+
9+
export type ClientMessagePlan =
10+
| {
11+
kind: 'welcome';
12+
playerId: number;
13+
code: string;
14+
playerToken: string;
15+
clearInviteLink: boolean;
16+
showReconnectToast: boolean;
17+
nextState: ClientState | null;
18+
}
19+
| { kind: 'matchFound' }
20+
| {
21+
kind: 'gameStart';
22+
state: GameState;
23+
nextState: ClientState;
24+
}
25+
| {
26+
kind: 'movementResult';
27+
state: GameState;
28+
movements: ShipMovement[];
29+
ordnanceMovements: OrdnanceMovement[];
30+
events: MovementEvent[];
31+
}
32+
| {
33+
kind: 'combatResult';
34+
state: GameState;
35+
results: CombatResult[];
36+
shouldTransition: true;
37+
}
38+
| {
39+
kind: 'stateUpdate';
40+
state: GameState;
41+
shouldTransition: boolean;
42+
}
43+
| {
44+
kind: 'gameOver';
45+
won: boolean;
46+
reason: string;
47+
}
48+
| { kind: 'rematchPending' }
49+
| {
50+
kind: 'opponentDisconnected';
51+
nextState: 'gameOver';
52+
won: true;
53+
reason: 'Opponent disconnected';
54+
}
55+
| {
56+
kind: 'error';
57+
message: string;
58+
}
59+
| {
60+
kind: 'pong';
61+
latencyMs: number | null;
62+
};
63+
64+
export function deriveClientMessagePlan(
65+
currentState: ClientState,
66+
reconnectAttempts: number,
67+
playerId: number,
68+
nowMs: number,
69+
msg: S2C,
70+
): ClientMessagePlan {
71+
switch (msg.type) {
72+
case 'welcome': {
73+
const welcome = deriveWelcomeHandling(currentState, reconnectAttempts, msg.playerId);
74+
return {
75+
kind: 'welcome',
76+
playerId: msg.playerId,
77+
code: msg.code,
78+
playerToken: msg.playerToken,
79+
clearInviteLink: welcome.clearInviteLink,
80+
showReconnectToast: welcome.showReconnectToast,
81+
nextState: welcome.nextState,
82+
};
83+
}
84+
case 'matchFound':
85+
return { kind: 'matchFound' };
86+
case 'gameStart':
87+
return {
88+
kind: 'gameStart',
89+
state: msg.state,
90+
nextState: deriveGameStartClientState(msg.state, playerId),
91+
};
92+
case 'movementResult':
93+
return {
94+
kind: 'movementResult',
95+
state: msg.state,
96+
movements: msg.movements,
97+
ordnanceMovements: msg.ordnanceMovements,
98+
events: msg.events,
99+
};
100+
case 'combatResult':
101+
return {
102+
kind: 'combatResult',
103+
state: msg.state,
104+
results: msg.results,
105+
shouldTransition: true,
106+
};
107+
case 'stateUpdate':
108+
return {
109+
kind: 'stateUpdate',
110+
state: msg.state,
111+
shouldTransition: shouldTransitionAfterStateUpdate(currentState),
112+
};
113+
case 'gameOver':
114+
return {
115+
kind: 'gameOver',
116+
won: msg.winner === playerId,
117+
reason: msg.reason,
118+
};
119+
case 'rematchPending':
120+
return { kind: 'rematchPending' };
121+
case 'opponentDisconnected':
122+
return {
123+
kind: 'opponentDisconnected',
124+
nextState: 'gameOver',
125+
won: true,
126+
reason: 'Opponent disconnected',
127+
};
128+
case 'error':
129+
return {
130+
kind: 'error',
131+
message: msg.message,
132+
};
133+
case 'pong':
134+
return {
135+
kind: 'pong',
136+
latencyMs: msg.t > 0 ? nowMs - msg.t : null,
137+
};
138+
}
139+
}

0 commit comments

Comments
 (0)