Skip to content

Commit 2f0f1c9

Browse files
committed
Restructure for quality: extract game engine, add tests, expand types
- Extract pure GameEngine from game-do.ts (createGame, processAstrogation) for testability — DO class is now thin WebSocket/storage plumbing - Add vitest with 69 tests covering hex math, movement engine, and game engine (gravity, crashes, landing, takeoff, overload, resupply) - Expand types: Ship gains damage field, Phase gains combat/ordnance/ resupply variants, AstrogationOrder supports overload and weak gravity choices, GravityEffect tracks strength and ignored state, Scenario gains homeBody for base ownership - Implement resupply: landing at any base refuels and repairs - Implement weak gravity: player can ignore single weak gravity hex, consecutive weak gravity from same body is mandatory - Implement overload maneuver: warships can spend 2 fuel for 2-hex burn
1 parent e550bdf commit 2f0f1c9

File tree

10 files changed

+2751
-443
lines changed

10 files changed

+2751
-443
lines changed

package-lock.json

Lines changed: 1600 additions & 194 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@
77
"build": "npm run build:client",
88
"dev": "wrangler dev",
99
"deploy": "wrangler deploy",
10-
"typecheck": "tsc --noEmit"
10+
"typecheck": "tsc --noEmit",
11+
"test": "vitest run",
12+
"test:watch": "vitest"
1113
},
1214
"devDependencies": {
1315
"@cloudflare/workers-types": "^4.20250214.0",
1416
"esbuild": "^0.24.0",
1517
"typescript": "^5.7.0",
18+
"vitest": "^4.0.18",
1619
"wrangler": "^4.0.0"
1720
}
1821
}

src/server/game-do.ts

Lines changed: 23 additions & 160 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import { DurableObject } from 'cloudflare:workers';
2-
import type {
3-
GameState, Ship, C2S, S2C, AstrogationOrder, ShipMovement,
4-
} from '../shared/types';
5-
import { computeCourse } from '../shared/movement';
2+
import type { GameState, C2S, S2C, AstrogationOrder } from '../shared/types';
63
import { getSolarSystemMap, SCENARIOS, findBaseHex } from '../shared/map-data';
7-
import { SHIP_STATS, INACTIVITY_TIMEOUT_MS } from '../shared/constants';
4+
import { INACTIVITY_TIMEOUT_MS } from '../shared/constants';
5+
import { createGame, processAstrogation } from '../shared/game-engine';
86

97
export interface Env {
108
ASSETS: Fetcher;
@@ -16,19 +14,7 @@ export class GameDO extends DurableObject {
1614
super(ctx, env);
1715
}
1816

19-
// --- Helpers for WebSocket tag-based player tracking ---
20-
21-
private getPlayerSockets(): Map<number, WebSocket> {
22-
const result = new Map<number, WebSocket>();
23-
for (const ws of this.ctx.getWebSockets()) {
24-
const tag = this.ctx.getTags(ws).find(t => t.startsWith('player:'));
25-
if (tag) {
26-
const id = parseInt(tag.split(':')[1]);
27-
result.set(id, ws);
28-
}
29-
}
30-
return result;
31-
}
17+
// --- WebSocket tag-based player tracking ---
3218

3319
private getPlayerId(ws: WebSocket): number | null {
3420
const tag = this.ctx.getTags(ws).find(t => t.startsWith('player:'));
@@ -40,7 +26,7 @@ export class GameDO extends DurableObject {
4026
+ this.ctx.getWebSockets('player:1').length;
4127
}
4228

43-
// --- State management via DO storage ---
29+
// --- State management ---
4430

4531
private async getGameState(): Promise<GameState | null> {
4632
return await this.ctx.storage.get<GameState>('gameState') ?? null;
@@ -66,42 +52,34 @@ export class GameDO extends DurableObject {
6652
return new Response('Expected WebSocket', { status: 426 });
6753
}
6854

69-
// Extract code from URL
7055
const url = new URL(request.url);
7156
const codeMatch = url.pathname.match(/\/ws\/([A-Z0-9]{5})/);
7257
if (codeMatch) {
7358
await this.setGameCode(codeMatch[1]);
7459
}
7560

76-
// Count current players
7761
const playerCount = this.getPlayerCount();
7862
if (playerCount >= 2) {
7963
return new Response('Game is full', { status: 409 });
8064
}
8165

82-
// Assign next available player ID
8366
const playerId = this.ctx.getWebSockets('player:0').length === 0 ? 0 : 1;
8467

8568
const pair = new WebSocketPair();
8669
const [client, server] = Object.values(pair);
8770

88-
// Accept with a tag so we can identify this player after hibernation
8971
this.ctx.acceptWebSocket(server, [`player:${playerId}`]);
9072

91-
// Send welcome
9273
const code = await this.getGameCode();
9374
this.send(server, { type: 'welcome', playerId, code });
9475

95-
// Check if both players are now connected
96-
// +1 because the just-accepted socket may not yet appear in getWebSockets
76+
// Both players connected — start the game
9777
if (playerCount + 1 >= 2) {
9878
this.broadcast({ type: 'matchFound' });
9979
await this.initGame();
10080
}
10181

102-
// Set inactivity alarm
10382
await this.ctx.storage.setAlarm(Date.now() + INACTIVITY_TIMEOUT_MS);
104-
10583
return new Response(null, { status: 101, webSocket: client });
10684
}
10785

@@ -119,7 +97,6 @@ export class GameDO extends DurableObject {
11997
const playerId = this.getPlayerId(ws);
12098
if (playerId === null) return;
12199

122-
// Reset inactivity alarm
123100
await this.ctx.storage.setAlarm(Date.now() + INACTIVITY_TIMEOUT_MS);
124101

125102
switch (msg.type) {
@@ -135,7 +112,7 @@ export class GameDO extends DurableObject {
135112
}
136113
}
137114

138-
async webSocketClose(ws: WebSocket): Promise<void> {
115+
async webSocketClose(): Promise<void> {
139116
this.broadcast({ type: 'opponentDisconnected' });
140117
}
141118

@@ -146,46 +123,14 @@ export class GameDO extends DurableObject {
146123
await this.ctx.storage.deleteAll();
147124
}
148125

149-
// --- Game logic ---
126+
// --- Game logic (delegates to engine) ---
150127

151128
private async initGame() {
152129
const scenario = SCENARIOS.biplanetary;
153130
const map = getSolarSystemMap();
154131
const code = await this.getGameCode();
155132

156-
const ships: Ship[] = [];
157-
for (let p = 0; p < scenario.players.length; p++) {
158-
for (let s = 0; s < scenario.players[p].ships.length; s++) {
159-
const def = scenario.players[p].ships[s];
160-
const stats = SHIP_STATS[def.type];
161-
const baseHex = findBaseHex(map, p === 0 ? 'Mars' : 'Venus');
162-
ships.push({
163-
id: `p${p}s${s}`,
164-
type: def.type,
165-
owner: p,
166-
position: baseHex ?? def.position,
167-
velocity: { ...def.velocity },
168-
fuel: stats.fuel,
169-
landed: true,
170-
destroyed: false,
171-
});
172-
}
173-
}
174-
175-
const gameState: GameState = {
176-
gameId: code,
177-
scenario: scenario.name,
178-
turnNumber: 1,
179-
phase: 'astrogation',
180-
activePlayer: 0,
181-
ships,
182-
players: [
183-
{ connected: true, ready: true, targetBody: scenario.players[0].targetBody },
184-
{ connected: true, ready: true, targetBody: scenario.players[1].targetBody },
185-
],
186-
winner: null,
187-
winReason: null,
188-
};
133+
const gameState = createGame(scenario, map, code, findBaseHex);
189134

190135
await this.saveGameState(gameState);
191136
this.broadcast({ type: 'gameStart', state: gameState });
@@ -195,118 +140,36 @@ export class GameDO extends DurableObject {
195140
const gameState = await this.getGameState();
196141
if (!gameState) return;
197142

198-
if (gameState.phase !== 'astrogation') {
199-
this.send(ws, { type: 'error', message: 'Not in astrogation phase' });
200-
return;
201-
}
202-
if (playerId !== gameState.activePlayer) {
203-
this.send(ws, { type: 'error', message: 'Not your turn' });
204-
return;
205-
}
206-
207143
const map = getSolarSystemMap();
208-
const movements: ShipMovement[] = [];
209-
210-
for (const ship of gameState.ships) {
211-
if (ship.owner !== playerId) continue;
212-
if (ship.destroyed) continue;
213-
214-
const order = orders.find(o => o.shipId === ship.id);
215-
const burn = order?.burn ?? null;
216-
217-
// Validate burn
218-
if (burn !== null) {
219-
if (burn < 0 || burn > 5) {
220-
this.send(ws, { type: 'error', message: 'Invalid burn direction' });
221-
return;
222-
}
223-
if (ship.fuel <= 0) {
224-
this.send(ws, { type: 'error', message: 'No fuel remaining' });
225-
return;
226-
}
227-
}
228-
229-
const course = computeCourse(ship, burn, map);
230-
movements.push({
231-
shipId: ship.id,
232-
from: { ...ship.position },
233-
to: course.destination,
234-
path: course.path,
235-
newVelocity: course.newVelocity,
236-
fuelSpent: course.fuelSpent,
237-
gravityEffects: course.gravityEffects,
238-
crashed: course.crashed,
239-
landedAt: course.landedAt,
240-
});
241-
242-
// Update ship
243-
ship.position = course.destination;
244-
ship.velocity = course.newVelocity;
245-
ship.fuel -= course.fuelSpent;
246-
ship.landed = course.landedAt !== null;
247-
248-
if (course.landedAt) {
249-
// Landing: velocity resets to zero (ship docks / sets down)
250-
ship.velocity = { dq: 0, dr: 0 };
251-
}
252-
253-
if (course.crashed) {
254-
ship.destroyed = true;
255-
ship.velocity = { dq: 0, dr: 0 };
256-
}
257-
}
144+
const result = processAstrogation(gameState, playerId, orders, map);
258145

259-
// Check victory: landing on target body
260-
for (const ship of gameState.ships) {
261-
if (ship.destroyed || !ship.landed) continue;
262-
const targetBody = gameState.players[ship.owner].targetBody;
263-
const hex = map.hexes.get(`${ship.position.q},${ship.position.r}`);
264-
if (hex?.base?.bodyName === targetBody || hex?.body?.name === targetBody) {
265-
gameState.winner = ship.owner;
266-
gameState.winReason = `Landed on ${targetBody}!`;
267-
gameState.phase = 'gameOver';
268-
}
269-
}
270-
271-
// Check loss: all ships destroyed
272-
for (let p = 0; p < 2; p++) {
273-
const alive = gameState.ships.filter(s => s.owner === p && !s.destroyed);
274-
if (alive.length === 0) {
275-
gameState.winner = 1 - p;
276-
gameState.winReason = `Opponent's ship was destroyed!`;
277-
gameState.phase = 'gameOver';
278-
}
146+
if ('error' in result) {
147+
this.send(ws, { type: 'error', message: result.error });
148+
return;
279149
}
280150

281151
// Broadcast movement results
282-
this.broadcast({ type: 'movementResult', movements, state: gameState });
152+
this.broadcast({ type: 'movementResult', movements: result.movements, state: result.state });
283153

284-
if (gameState.phase === 'gameOver') {
154+
if (result.state.phase === 'gameOver') {
285155
this.broadcast({
286156
type: 'gameOver',
287-
winner: gameState.winner!,
288-
reason: gameState.winReason!,
157+
winner: result.state.winner!,
158+
reason: result.state.winReason!,
289159
});
290-
await this.saveGameState(gameState);
291-
return;
292160
}
293161

294-
// Switch active player
295-
gameState.activePlayer = 1 - gameState.activePlayer;
296-
if (gameState.activePlayer === 0) {
297-
gameState.turnNumber++;
298-
}
162+
await this.saveGameState(result.state);
299163

300-
await this.saveGameState(gameState);
301-
this.broadcast({ type: 'stateUpdate', state: gameState });
164+
if (result.state.phase !== 'gameOver') {
165+
this.broadcast({ type: 'stateUpdate', state: result.state });
166+
}
302167
}
303168

304-
// --- Messaging helpers ---
169+
// --- Messaging ---
305170

306171
private send(ws: WebSocket, msg: S2C) {
307-
try {
308-
ws.send(JSON.stringify(msg));
309-
} catch {}
172+
try { ws.send(JSON.stringify(msg)); } catch {}
310173
}
311174

312175
private broadcast(msg: S2C) {

0 commit comments

Comments
 (0)