Skip to content

Commit 83bd710

Browse files
committed
Add server-side turn timeout and ordnance lifetime display
- 2-minute turn timeout: if active player doesn't act, their turn auto-skips - Auto-submit empty orders for astrogation, auto-skip ordnance and combat - Turn timer resets after each phase action - Ordnance shows countdown label when 2 or fewer turns remain (red at 1) - Added TURN_TIMEOUT_MS constant (120s)
1 parent 4c60d4c commit 83bd710

File tree

3 files changed

+74
-1
lines changed

3 files changed

+74
-1
lines changed

src/client/renderer.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,6 +1253,14 @@ export class Renderer {
12531253
ctx.setLineDash([]);
12541254
ctx.globalAlpha = 1;
12551255
}
1256+
1257+
// Lifetime indicator (turns remaining until self-destruct)
1258+
if (!this.animState && ord.turnsRemaining <= 2) {
1259+
ctx.fillStyle = ord.turnsRemaining <= 1 ? 'rgba(255, 80, 80, 0.9)' : 'rgba(255, 200, 50, 0.7)';
1260+
ctx.font = 'bold 6px monospace';
1261+
ctx.textAlign = 'center';
1262+
ctx.fillText(`${ord.turnsRemaining}`, p.x, p.y + 10);
1263+
}
12561264
}
12571265

12581266
// During animation, also render ordnance that detonated (show until detonation point)

src/server/game-do.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { DurableObject } from 'cloudflare:workers';
22
import type { GameState, C2S, S2C, AstrogationOrder, OrdnanceLaunch, CombatAttack } from '../shared/types';
33
import { getSolarSystemMap, SCENARIOS, findBaseHex } from '../shared/map-data';
4-
import { INACTIVITY_TIMEOUT_MS } from '../shared/constants';
4+
import { INACTIVITY_TIMEOUT_MS, TURN_TIMEOUT_MS } from '../shared/constants';
55
import { createGame, processAstrogation, processOrdnance, skipOrdnance, processCombat, skipCombat } from '../shared/game-engine';
66

77
export interface Env {
@@ -188,13 +188,71 @@ export class GameDO extends DurableObject {
188188
return;
189189
}
190190

191+
// Check if turn timeout is pending
192+
const turnTimeoutAt = await this.ctx.storage.get<number>('turnTimeoutAt');
193+
if (turnTimeoutAt !== undefined && Date.now() >= turnTimeoutAt - 500) {
194+
await this.handleTurnTimeout();
195+
return;
196+
}
197+
191198
// Inactivity timeout — close everything
192199
for (const ws of this.ctx.getWebSockets()) {
193200
try { ws.close(1000, 'Inactivity timeout'); } catch {}
194201
}
195202
await this.ctx.storage.deleteAll();
196203
}
197204

205+
private async handleTurnTimeout(): Promise<void> {
206+
await this.ctx.storage.delete('turnTimeoutAt');
207+
const gameState = await this.getGameState();
208+
if (!gameState || gameState.phase === 'gameOver') return;
209+
210+
const map = getSolarSystemMap();
211+
const playerId = gameState.activePlayer;
212+
213+
if (gameState.phase === 'astrogation') {
214+
// Auto-submit empty orders (no burns)
215+
const orders: AstrogationOrder[] = gameState.ships
216+
.filter(s => s.owner === playerId)
217+
.map(s => ({ shipId: s.id, burn: null }));
218+
const result = processAstrogation(gameState, playerId, orders, map);
219+
if (!('error' in result)) {
220+
this.broadcast({ type: 'movementResult', movements: result.movements, ordnanceMovements: result.ordnanceMovements, events: result.events, state: result.state });
221+
this.broadcastEndOrUpdate(result.state);
222+
await this.saveGameState(result.state);
223+
await this.startTurnTimer(result.state);
224+
}
225+
} else if (gameState.phase === 'ordnance') {
226+
const result = skipOrdnance(gameState, playerId);
227+
if (!('error' in result)) {
228+
this.broadcast({ type: 'stateUpdate', state: result.state });
229+
this.broadcastEndOrUpdate(result.state);
230+
await this.saveGameState(result.state);
231+
await this.startTurnTimer(result.state);
232+
}
233+
} else if (gameState.phase === 'combat') {
234+
const result = skipCombat(gameState, playerId, map);
235+
if (!('error' in result)) {
236+
if (result.baseDefenseResults && result.baseDefenseResults.length > 0) {
237+
this.broadcast({ type: 'combatResult', results: result.baseDefenseResults, state: result.state });
238+
}
239+
this.broadcastEndOrUpdate(result.state);
240+
await this.saveGameState(result.state);
241+
await this.startTurnTimer(result.state);
242+
}
243+
}
244+
}
245+
246+
private async startTurnTimer(state: GameState): Promise<void> {
247+
if (state.phase === 'gameOver') {
248+
await this.ctx.storage.delete('turnTimeoutAt');
249+
return;
250+
}
251+
const timeoutAt = Date.now() + TURN_TIMEOUT_MS;
252+
await this.ctx.storage.put('turnTimeoutAt', timeoutAt);
253+
await this.ctx.storage.setAlarm(timeoutAt);
254+
}
255+
198256
// --- Game logic (delegates to engine) ---
199257

200258
private async initGame() {
@@ -207,6 +265,7 @@ export class GameDO extends DurableObject {
207265

208266
await this.saveGameState(gameState);
209267
this.broadcast({ type: 'gameStart', state: gameState });
268+
await this.startTurnTimer(gameState);
210269
}
211270

212271
private async handleAstrogation(playerId: number, ws: WebSocket, orders: AstrogationOrder[]) {
@@ -224,6 +283,7 @@ export class GameDO extends DurableObject {
224283
this.broadcast({ type: 'movementResult', movements: result.movements, ordnanceMovements: result.ordnanceMovements, events: result.events, state: result.state });
225284
this.broadcastEndOrUpdate(result.state);
226285
await this.saveGameState(result.state);
286+
await this.startTurnTimer(result.state);
227287
}
228288

229289
private async handleOrdnance(playerId: number, ws: WebSocket, launches: OrdnanceLaunch[]) {
@@ -241,6 +301,7 @@ export class GameDO extends DurableObject {
241301
this.broadcast({ type: 'stateUpdate', state: result.state });
242302
this.broadcastEndOrUpdate(result.state);
243303
await this.saveGameState(result.state);
304+
await this.startTurnTimer(result.state);
244305
}
245306

246307
private async handleSkipOrdnance(playerId: number, ws: WebSocket) {
@@ -256,6 +317,7 @@ export class GameDO extends DurableObject {
256317

257318
this.broadcast({ type: 'stateUpdate', state: result.state });
258319
await this.saveGameState(result.state);
320+
await this.startTurnTimer(result.state);
259321
}
260322

261323
private async handleCombat(playerId: number, ws: WebSocket, attacks: CombatAttack[]) {
@@ -273,6 +335,7 @@ export class GameDO extends DurableObject {
273335
this.broadcast({ type: 'combatResult', results: result.results, state: result.state });
274336
this.broadcastEndOrUpdate(result.state);
275337
await this.saveGameState(result.state);
338+
await this.startTurnTimer(result.state);
276339
}
277340

278341
private async handleSkipCombat(playerId: number, ws: WebSocket) {
@@ -294,6 +357,7 @@ export class GameDO extends DurableObject {
294357

295358
this.broadcastEndOrUpdate(result.state);
296359
await this.saveGameState(result.state);
360+
await this.startTurnTimer(result.state);
297361
}
298362

299363
private async handleRematch(playerId: number, ws: WebSocket) {

src/shared/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,5 @@ export const CAMERA_LERP_SPEED = 5;
4040

4141
// Game constants
4242
export const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
43+
export const TURN_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes per turn
4344
export const CODE_LENGTH = 5;

0 commit comments

Comments
 (0)