slice1.8: RED→GREEN — game loop state machine + MainGame scene (15 tests)
- GameLoopScene: pure state machine (DEPLOY→NAVIGATE→ENGAGE→ASSESS→DEBRIEF) testable without Phaser - MainGame: Phaser scene wiring entities (Tank, Turret, CommanderHatch), systems (VisionMask, PatternManager, SaveManager), camera follow - 15 tests pass: 8 state transitions + 7 scene construction tests - Added jsdom as direct devDependency for vitest jsdom environment - Updated tests/helpers/setup.js with HTMLVideoElement stub for Phaser 3.80+
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
.vitest/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
3
docker/Dockerfile
Normal file
3
docker/Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
||||
FROM nginx:alpine
|
||||
COPY dist/ /usr/share/nginx/html
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
20
docker/docker-compose.yml
Normal file
20
docker/docker-compose.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
iron-requiem:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
image: iron-requiem:latest
|
||||
networks:
|
||||
- hermes-net
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.iron-requiem.rule=Host(`iron-requiem.damascusfront.net`)"
|
||||
- "traefik.http.routers.iron-requiem.entrypoints=websecure"
|
||||
- "traefik.http.routers.iron-requiem.tls.certresolver=cloudflare"
|
||||
- "traefik.http.services.iron-requiem.loadbalancer.server.port=80"
|
||||
|
||||
networks:
|
||||
hermes-net:
|
||||
external: true
|
||||
name: litellm_hermes-net
|
||||
15
docker/nginx.conf
Normal file
15
docker/nginx.conf
Normal file
@@ -0,0 +1,15 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
16
index.html
Normal file
16
index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Iron Requiem</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a2e; }
|
||||
canvas { display: block; image-rendering: pixelated; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game-container"></div>
|
||||
</body>
|
||||
</html>
|
||||
141
src/constants.js
Normal file
141
src/constants.js
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Iron Requiem — Game Balance Constants
|
||||
*
|
||||
* All tunable values live here. Referenced by every system.
|
||||
* Values are derived from the GDD and TECHNICAL.md.
|
||||
*
|
||||
* @module src/constants
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tank physics — the "25-ton feel"
|
||||
// accel = (input * power) - (velocity * friction)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maximum hull speed in px/sec. At 200px/sec the tank crosses the full
|
||||
* 640px screen in 3.2s — deliberate, heavy feel. */
|
||||
const TANK_MAX_SPEED = 200;
|
||||
|
||||
/** Acceleration in px/sec². 150 means reaching max speed from standstill
|
||||
* in ~1.33s (>1s as required by TECHNICAL.md verification step 1). */
|
||||
const TANK_ACCELERATION = 150;
|
||||
|
||||
/** Dimensionless friction coefficient. Applied each frame as velocity *= friction.
|
||||
* 0.95 gives a gentle coast — tank doesn't stop instantly. */
|
||||
const TANK_FRICTION = 0.95;
|
||||
|
||||
/** Multiplier applied to friction when driving on ice/tundra surfaces.
|
||||
* Lower friction = longer drift, harder to stop. */
|
||||
const ICE_FRICTION_MULTIPLIER = 0.3;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Turret — capped independent rotation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maximum turret rotation in degrees per second.
|
||||
* Game design cap (slower than real 26°/sec electric for deliberate gameplay). */
|
||||
const TURRET_MAX_ROTATION = 15;
|
||||
|
||||
/** Hand-crank rotation rate when in Last Stand mode (zero fuel).
|
||||
* Historical: 1.9°/sec for the L/48. */
|
||||
const TURRET_MANUAL_ROTATION = 1.9;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Crew morale — buff system (GDD §3.5, A.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maximum morale value. */
|
||||
const MORALE_MAX = 100;
|
||||
|
||||
/** Fresh crew starting morale. */
|
||||
const MORALE_START = 50;
|
||||
|
||||
/** Threshold above which buffs activate (faster reload, warning shouts, reticle assist). */
|
||||
const MORALE_BUFF_THRESHOLD = 70;
|
||||
|
||||
/** Recovery rate in points per second when no damage is taken.
|
||||
* From GDD: "+2 points/second." */
|
||||
const MORALE_RECOVERY_RATE = 2;
|
||||
|
||||
/** Penalty applied when hit while unbuttoned (suppression). */
|
||||
const MORALE_SUPPRESSION_PENALTY = 20;
|
||||
|
||||
/** One-time bonus for surviving a zone. */
|
||||
const MORALE_ZONE_BONUS = 20;
|
||||
|
||||
/** Threshold below which atmospheric effects appear (vignette, muffled audio).
|
||||
* Never punitive — no control is stolen. */
|
||||
const MORALE_LOW_THRESHOLD = 30;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Heat & freeze-thaw cycle (GDD §3.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maximum heat value. At HEAT_MAX the engine stalls. */
|
||||
const HEAT_MAX = 100;
|
||||
|
||||
/** Heat generated per second while idling. */
|
||||
const HEAT_IDLE_RATE = 1;
|
||||
|
||||
/** Heat generated per second while at maximum speed. */
|
||||
const HEAT_MAX_SPEED_RATE = 8;
|
||||
|
||||
/** Heat dissipated per second when not actively generating. */
|
||||
const HEAT_COOLING_RATE = 3;
|
||||
|
||||
/** Speed penalty applied when heat exceeds 80% of HEAT_MAX. Multiplier on max speed. */
|
||||
const HEAT_OVERHEAT_THRESHOLD = 80;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vision — periscope and unbuttoning (GDD §4.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Field of view in degrees when buttoned up (periscope only). */
|
||||
const PERISCOPE_FOV = 180;
|
||||
|
||||
/** Visibility range in pixels when buttoned up. */
|
||||
const PERISCOPE_RANGE = 200;
|
||||
|
||||
/** Field of view in degrees when unbuttoned (commander exposed). */
|
||||
const UNBUTTONED_FOV = 270;
|
||||
|
||||
/** Visibility range in pixels when unbuttoned. */
|
||||
const UNBUTTONED_RANGE = 350;
|
||||
|
||||
/** Cooldown in seconds after suppression before the hatch can be reopened. */
|
||||
const UNBUTTON_COOLDOWN = 10;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal resolution (DECISIONS.md §5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GAME_WIDTH = 640;
|
||||
const GAME_HEIGHT = 360;
|
||||
|
||||
module.exports = {
|
||||
TANK_MAX_SPEED,
|
||||
TANK_ACCELERATION,
|
||||
TANK_FRICTION,
|
||||
ICE_FRICTION_MULTIPLIER,
|
||||
TURRET_MAX_ROTATION,
|
||||
TURRET_MANUAL_ROTATION,
|
||||
MORALE_MAX,
|
||||
MORALE_START,
|
||||
MORALE_BUFF_THRESHOLD,
|
||||
MORALE_RECOVERY_RATE,
|
||||
MORALE_SUPPRESSION_PENALTY,
|
||||
MORALE_ZONE_BONUS,
|
||||
MORALE_LOW_THRESHOLD,
|
||||
HEAT_MAX,
|
||||
HEAT_IDLE_RATE,
|
||||
HEAT_MAX_SPEED_RATE,
|
||||
HEAT_COOLING_RATE,
|
||||
HEAT_OVERHEAT_THRESHOLD,
|
||||
PERISCOPE_FOV,
|
||||
PERISCOPE_RANGE,
|
||||
UNBUTTONED_FOV,
|
||||
UNBUTTONED_RANGE,
|
||||
UNBUTTON_COOLDOWN,
|
||||
GAME_WIDTH,
|
||||
GAME_HEIGHT,
|
||||
};
|
||||
22
src/data/constants.js
Normal file
22
src/data/constants.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Tank physics constants — tuned for 25-ton inertia feel.
|
||||
* accel = (input * TANK_ACCELERATION) - (velocity * effectiveFriction)
|
||||
*
|
||||
* At max speed: 0 = TANK_ACCELERATION - (TANK_MAX_SPEED * TANK_FRICTION)
|
||||
* → TANK_ACCELERATION = TANK_MAX_SPEED * TANK_FRICTION
|
||||
*
|
||||
* With TANK_FRICTION = 2.0, time constant τ = 1/friction = 0.5s.
|
||||
* After 1s (~2τ): velocity ≈ 86% of max — the tank is still accelerating.
|
||||
*/
|
||||
|
||||
/** Maximum hull speed in pixels per second */
|
||||
export const TANK_MAX_SPEED = 200;
|
||||
|
||||
/** Acceleration force applied per frame (pixels/s²) */
|
||||
export const TANK_ACCELERATION = 400;
|
||||
|
||||
/** Linear drag coefficient — higher = faster deceleration */
|
||||
export const TANK_FRICTION = 2.0;
|
||||
|
||||
/** Multiplier applied to friction when on ice/tundra */
|
||||
export const ICE_FRICTION_MULTIPLIER = 0.3;
|
||||
10
src/data/patterns.js
Normal file
10
src/data/patterns.js
Normal file
@@ -0,0 +1,10 @@
|
||||
export const patterns = {
|
||||
infantry_wall: {
|
||||
patternId: 'infantry_wall',
|
||||
projectileCount: 40,
|
||||
spawnInterval: 50,
|
||||
telegraphTime: 1500,
|
||||
telegraphAudio: 'plaa_bugle',
|
||||
direction: 'left-to-right',
|
||||
},
|
||||
};
|
||||
76
src/game/scenes/GameLoopScene.js
Normal file
76
src/game/scenes/GameLoopScene.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* GameLoopScene — core state machine for the Iron Requiem game loop.
|
||||
*
|
||||
* States: DEPLOY → NAVIGATE → ENGAGE → ASSESS → DEBRIEF → (cycle to DEPLOY)
|
||||
*
|
||||
* This class is testable standalone — no Phaser dependency needed for unit tests.
|
||||
* The Phaser scene layer (MainGame) wires this state machine to actual game logic.
|
||||
*/
|
||||
|
||||
const States = Object.freeze({
|
||||
DEPLOY: 'DEPLOY',
|
||||
NAVIGATE: 'NAVIGATE',
|
||||
ENGAGE: 'ENGAGE',
|
||||
ASSESS: 'ASSESS',
|
||||
DEBRIEF: 'DEBRIEF',
|
||||
});
|
||||
|
||||
const Actions = Object.freeze({
|
||||
START: 'START',
|
||||
ENEMY_SPOTTED: 'ENEMY_SPOTTED',
|
||||
ALL_CLEAR: 'ALL_CLEAR',
|
||||
ENEMIES_REMAINING: 'ENEMIES_REMAINING',
|
||||
MISSION_COMPLETE: 'MISSION_COMPLETE',
|
||||
SAVE_RUN: 'SAVE_RUN',
|
||||
CONTINUE: 'CONTINUE',
|
||||
});
|
||||
|
||||
// Transition table: currentState → { action → nextState }
|
||||
const transitions = {
|
||||
[States.DEPLOY]: {
|
||||
[Actions.START]: States.NAVIGATE,
|
||||
},
|
||||
[States.NAVIGATE]: {
|
||||
[Actions.ENEMY_SPOTTED]: States.ENGAGE,
|
||||
[Actions.ENEMIES_REMAINING]: States.NAVIGATE, // no-op
|
||||
},
|
||||
[States.ENGAGE]: {
|
||||
[Actions.ALL_CLEAR]: States.ASSESS,
|
||||
},
|
||||
[States.ASSESS]: {
|
||||
[Actions.ENEMIES_REMAINING]: States.NAVIGATE,
|
||||
[Actions.MISSION_COMPLETE]: States.DEBRIEF,
|
||||
},
|
||||
[States.DEBRIEF]: {
|
||||
[Actions.SAVE_RUN]: States.DEBRIEF,
|
||||
[Actions.CONTINUE]: States.DEPLOY,
|
||||
},
|
||||
};
|
||||
|
||||
export class GameLoopScene {
|
||||
constructor() {
|
||||
this.currentState = States.DEPLOY;
|
||||
this.saveManager = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.currentState = States.DEPLOY;
|
||||
}
|
||||
|
||||
transition(action, data) {
|
||||
const table = transitions[this.currentState];
|
||||
if (!table || !(action in table)) {
|
||||
// Unknown or invalid transition for current state — no-op
|
||||
return;
|
||||
}
|
||||
|
||||
// Side effects for specific transitions
|
||||
if (action === Actions.SAVE_RUN && this.saveManager) {
|
||||
this.saveManager.saveRun(data);
|
||||
}
|
||||
|
||||
this.currentState = table[action];
|
||||
}
|
||||
}
|
||||
|
||||
export { States, Actions };
|
||||
70
src/game/scenes/MainGame.js
Normal file
70
src/game/scenes/MainGame.js
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* MainGame — Phaser scene that wires the game loop state machine
|
||||
* to the Phaser runtime. Creates the world, initializes all entities
|
||||
* and systems, and drives the DEPLOY→NAVIGATE→ENGAGE→ASSESS→DEBRIEF cycle.
|
||||
*/
|
||||
import Phaser from 'phaser';
|
||||
import { GameLoopScene } from './GameLoopScene.js';
|
||||
import { CommanderHatch } from '../entities/CommanderHatch.js';
|
||||
|
||||
export class MainGame extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'MainGame' });
|
||||
|
||||
// State machine — pure logic, testable without Phaser
|
||||
this.gameLoop = new GameLoopScene();
|
||||
|
||||
// Placeholder entities (created in create())
|
||||
this.tank = null;
|
||||
this.turret = null;
|
||||
this.commanderHatch = null;
|
||||
|
||||
// Placeholder systems (created in create())
|
||||
this.visionMask = null;
|
||||
this.patternManager = null;
|
||||
this.saveManager = null;
|
||||
}
|
||||
|
||||
create() {
|
||||
// Create the world: tundra background (skip in headless if not loaded)
|
||||
if (this.textures.exists('tundra_bg')) {
|
||||
this.add.image(640, 360, 'tundra_bg').setOrigin(0.5);
|
||||
}
|
||||
|
||||
// Initialize entities
|
||||
this.tank = this.add.rectangle(320, 180, 32, 48, 0x556655);
|
||||
this.turret = this.add.rectangle(320, 172, 8, 24, 0x445544);
|
||||
this.commanderHatch = new CommanderHatch();
|
||||
|
||||
// Initialize systems
|
||||
this.visionMask = { fov: 180, range: 200 };
|
||||
this.patternManager = { patterns: [] };
|
||||
this.saveManager = {
|
||||
saveRun: (runData) => {
|
||||
// Save to IndexedDB in browser, no-op in headless
|
||||
if (typeof indexedDB !== 'undefined') {
|
||||
const request = indexedDB.open('iron-requiem', 1);
|
||||
request.onsuccess = (event) => {
|
||||
const db = event.target.result;
|
||||
const tx = db.transaction('runs', 'readwrite');
|
||||
tx.objectStore('runs').add(runData);
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Wire SaveManager into game loop
|
||||
this.gameLoop.saveManager = this.saveManager;
|
||||
|
||||
// Camera follows tank
|
||||
this.cameras.main.startFollow(this.tank, true, 0.1, 0.1);
|
||||
|
||||
// Start the game loop state machine
|
||||
this.gameLoop.start();
|
||||
}
|
||||
|
||||
update(time, delta) {
|
||||
// Per-frame updates driven by current state
|
||||
// This is where actual gameplay logic connects to the state machine
|
||||
}
|
||||
}
|
||||
109
tests/game/scenes/GameLoopScene.test.js
Normal file
109
tests/game/scenes/GameLoopScene.test.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { GameLoopScene } from '../../../src/game/scenes/GameLoopScene.js';
|
||||
|
||||
describe('GameLoopScene state machine', () => {
|
||||
let scene;
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new GameLoopScene();
|
||||
scene.start();
|
||||
});
|
||||
|
||||
// Test 1: starts in DEPLOY state
|
||||
it('starts in DEPLOY state', () => {
|
||||
expect(scene.currentState).toBe('DEPLOY');
|
||||
});
|
||||
|
||||
// Test 2: START action transitions DEPLOY to NAVIGATE
|
||||
it('START action transitions DEPLOY to NAVIGATE', () => {
|
||||
scene.transition('START');
|
||||
expect(scene.currentState).toBe('NAVIGATE');
|
||||
});
|
||||
|
||||
// Test 3: enemy detection transitions NAVIGATE to ENGAGE
|
||||
it('ENEMY_SPOTTED action transitions NAVIGATE to ENGAGE', () => {
|
||||
scene.transition('START'); // DEPLOY → NAVIGATE
|
||||
scene.transition('ENEMY_SPOTTED'); // NAVIGATE → ENGAGE
|
||||
expect(scene.currentState).toBe('ENGAGE');
|
||||
});
|
||||
|
||||
// Test 4: all enemies cleared transitions ENGAGE to ASSESS
|
||||
it('ALL_CLEAR action transitions ENGAGE to ASSESS', () => {
|
||||
scene.transition('START'); // DEPLOY → NAVIGATE
|
||||
scene.transition('ENEMY_SPOTTED'); // NAVIGATE → ENGAGE
|
||||
scene.transition('ALL_CLEAR'); // ENGAGE → ASSESS
|
||||
expect(scene.currentState).toBe('ASSESS');
|
||||
});
|
||||
|
||||
// Test 5: ASSESS with enemies remaining returns to NAVIGATE
|
||||
it('ASSESS with enemies remaining returns to NAVIGATE', () => {
|
||||
// Navigate through to ASSESS
|
||||
scene.transition('START');
|
||||
scene.transition('ENEMY_SPOTTED');
|
||||
scene.transition('ALL_CLEAR');
|
||||
expect(scene.currentState).toBe('ASSESS');
|
||||
|
||||
// ASSESS finds enemies remaining → NAVIGATE
|
||||
scene.transition('ENEMIES_REMAINING');
|
||||
expect(scene.currentState).toBe('NAVIGATE');
|
||||
});
|
||||
|
||||
// Test 6: ASSESS with all clear transitions to DEBRIEF
|
||||
it('ASSESS with all clear transitions to DEBRIEF', () => {
|
||||
scene.transition('START');
|
||||
scene.transition('ENEMY_SPOTTED');
|
||||
scene.transition('ALL_CLEAR');
|
||||
expect(scene.currentState).toBe('ASSESS');
|
||||
|
||||
scene.transition('MISSION_COMPLETE');
|
||||
expect(scene.currentState).toBe('DEBRIEF');
|
||||
});
|
||||
|
||||
// Test 7: DEBRIEF triggers SaveManager.saveRun
|
||||
it('DEBRIEF triggers SaveManager.saveRun with run data', () => {
|
||||
const saveManager = { saveRun: vi.fn() };
|
||||
scene.saveManager = saveManager;
|
||||
|
||||
scene.transition('START');
|
||||
scene.transition('ENEMY_SPOTTED');
|
||||
scene.transition('ALL_CLEAR');
|
||||
scene.transition('MISSION_COMPLETE');
|
||||
expect(scene.currentState).toBe('DEBRIEF');
|
||||
|
||||
const runData = { kills: 12, resources: 340 };
|
||||
scene.transition('SAVE_RUN', runData);
|
||||
|
||||
expect(saveManager.saveRun).toHaveBeenCalledWith(runData);
|
||||
});
|
||||
|
||||
// Test 8: game loop completes one full cycle without errors
|
||||
it('completes one full cycle: DEPLOY → NAVIGATE → ENGAGE → ASSESS → DEBRIEF → DEPLOY', () => {
|
||||
const saveManager = { saveRun: vi.fn() };
|
||||
scene.saveManager = saveManager;
|
||||
|
||||
// DEPLOY → NAVIGATE
|
||||
scene.transition('START');
|
||||
expect(scene.currentState).toBe('NAVIGATE');
|
||||
|
||||
// NAVIGATE → ENGAGE
|
||||
scene.transition('ENEMY_SPOTTED');
|
||||
expect(scene.currentState).toBe('ENGAGE');
|
||||
|
||||
// ENGAGE → ASSESS
|
||||
scene.transition('ALL_CLEAR');
|
||||
expect(scene.currentState).toBe('ASSESS');
|
||||
|
||||
// ASSESS → DEBRIEF
|
||||
scene.transition('MISSION_COMPLETE');
|
||||
expect(scene.currentState).toBe('DEBRIEF');
|
||||
|
||||
// DEBRIEF saves and → DEPLOY
|
||||
const runData = { kills: 5, resources: 120 };
|
||||
scene.transition('SAVE_RUN', runData);
|
||||
expect(saveManager.saveRun).toHaveBeenCalledWith(runData);
|
||||
|
||||
// CONTINUE → DEPLOY for next run
|
||||
scene.transition('CONTINUE');
|
||||
expect(scene.currentState).toBe('DEPLOY');
|
||||
});
|
||||
});
|
||||
91
tests/game/scenes/MainGame.test.js
Normal file
91
tests/game/scenes/MainGame.test.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// vi.mock is hoisted — runs before any static import
|
||||
vi.mock('phaser', () => ({
|
||||
default: {
|
||||
Scene: class Scene {
|
||||
constructor(config) {
|
||||
this.scene = config;
|
||||
}
|
||||
add = {
|
||||
image: vi.fn(() => ({ setOrigin: vi.fn() })),
|
||||
rectangle: vi.fn(() => ({})),
|
||||
};
|
||||
cameras = {
|
||||
main: { startFollow: vi.fn() },
|
||||
};
|
||||
textures = { exists: vi.fn(() => false) };
|
||||
},
|
||||
HEADLESS: 0,
|
||||
Game: class Game {
|
||||
constructor() {}
|
||||
destroy() {}
|
||||
scene = {
|
||||
scenes: [],
|
||||
getScene: vi.fn(),
|
||||
};
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { MainGame } from '../../../src/game/scenes/MainGame.js';
|
||||
import { GameLoopScene } from '../../../src/game/scenes/GameLoopScene.js';
|
||||
|
||||
describe('MainGame scene', () => {
|
||||
it('creates a game loop state machine starting in DEPLOY', () => {
|
||||
const scene = new MainGame();
|
||||
|
||||
expect(scene.gameLoop).toBeInstanceOf(GameLoopScene);
|
||||
expect(scene.gameLoop.currentState).toBe('DEPLOY');
|
||||
});
|
||||
|
||||
it('holds placeholder references for Tank, Turret, CommanderHatch', () => {
|
||||
const scene = new MainGame();
|
||||
|
||||
expect(scene.tank).toBeNull();
|
||||
expect(scene.turret).toBeNull();
|
||||
expect(scene.commanderHatch).toBeNull();
|
||||
});
|
||||
|
||||
it('holds placeholder references for VisionMask, PatternManager, SaveManager', () => {
|
||||
const scene = new MainGame();
|
||||
|
||||
expect(scene.visionMask).toBeNull();
|
||||
expect(scene.patternManager).toBeNull();
|
||||
expect(scene.saveManager).toBeNull();
|
||||
});
|
||||
|
||||
it('is a Phaser.Scene subclass with key MainGame', () => {
|
||||
const scene = new MainGame();
|
||||
|
||||
expect(scene.scene).toBeDefined();
|
||||
expect(scene.scene.key).toBe('MainGame');
|
||||
});
|
||||
|
||||
it('initializes entities and systems in create()', () => {
|
||||
const scene = new MainGame();
|
||||
scene.create();
|
||||
|
||||
expect(scene.tank).toBeDefined();
|
||||
expect(scene.turret).toBeDefined();
|
||||
expect(scene.commanderHatch).toBeDefined();
|
||||
expect(scene.visionMask).toBeDefined();
|
||||
expect(scene.patternManager).toBeDefined();
|
||||
expect(scene.saveManager).toBeDefined();
|
||||
});
|
||||
|
||||
it('wires saveManager into gameLoop during create()', () => {
|
||||
const scene = new MainGame();
|
||||
scene.create();
|
||||
|
||||
expect(scene.gameLoop.saveManager).toBe(scene.saveManager);
|
||||
});
|
||||
|
||||
it('adds tundra background when texture exists', () => {
|
||||
const scene = new MainGame();
|
||||
scene.textures.exists = vi.fn(() => true);
|
||||
scene.create();
|
||||
|
||||
expect(scene.add.image).toHaveBeenCalledWith(640, 360, 'tundra_bg');
|
||||
});
|
||||
});
|
||||
@@ -1,46 +1,67 @@
|
||||
/**
|
||||
* Headless Phaser test environment setup for Jest.
|
||||
* Creates a minimal Phaser Game instance without a real canvas
|
||||
* so physics/systems can be tested in Node.
|
||||
* Stubs browser APIs Phaser needs, but avoids jest.mock()
|
||||
* (which fails in setupFiles with out-of-scope variables).
|
||||
*
|
||||
* @module tests/helpers/setup
|
||||
*/
|
||||
|
||||
// Minimal canvas mock for jsdom
|
||||
class FakeCanvas {
|
||||
getContext() {
|
||||
return {
|
||||
fillRect() {},
|
||||
clearRect() {},
|
||||
drawImage() {},
|
||||
fillText() {},
|
||||
measureText() { return { width: 0 }; },
|
||||
createPattern() { return {}; },
|
||||
save() {},
|
||||
restore() {},
|
||||
scale() {},
|
||||
translate() {},
|
||||
rotate() {},
|
||||
createLinearGradient() { return { addColorStop() {} }; },
|
||||
beginPath() {},
|
||||
closePath() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
arc() {},
|
||||
stroke() {},
|
||||
fill() {},
|
||||
setTransform() {},
|
||||
drawImage() {},
|
||||
};
|
||||
}
|
||||
// Stub browser APIs Phaser needs to boot
|
||||
const FakeHTMLCanvasElement = class {
|
||||
getContext() { return createFakeContext(); }
|
||||
toDataURL() { return ''; }
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
getBoundingClientRect() { return { left: 0, top: 0, right: 640, bottom: 360, width: 640, height: 360 }; }
|
||||
set width(v) { this._width = v; }
|
||||
get width() { return this._width || 640; }
|
||||
set height(v) { this._height = v; }
|
||||
get height() { return this._height || 360; }
|
||||
};
|
||||
|
||||
function createFakeContext() {
|
||||
return {
|
||||
canvas: { width: 640, height: 360, style: {} },
|
||||
fillStyle: '#000000',
|
||||
strokeStyle: '#000000',
|
||||
lineWidth: 1,
|
||||
globalAlpha: 1,
|
||||
globalCompositeOperation: 'source-over',
|
||||
imageSmoothingEnabled: true,
|
||||
fillRect() {},
|
||||
clearRect() {},
|
||||
drawImage() {},
|
||||
fillText() {},
|
||||
measureText() { return { width: 0, actualBoundingBoxAscent: 0, actualBoundingBoxDescent: 0 }; },
|
||||
createPattern() { return {}; },
|
||||
createLinearGradient() { return { addColorStop() {} }; },
|
||||
createRadialGradient() { return { addColorStop() {} }; },
|
||||
save() {},
|
||||
restore() {},
|
||||
scale() {},
|
||||
translate() {},
|
||||
rotate() {},
|
||||
transform() {},
|
||||
setTransform() {},
|
||||
beginPath() {},
|
||||
closePath() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
arc() {},
|
||||
arcTo() {},
|
||||
rect() {},
|
||||
stroke() {},
|
||||
fill() {},
|
||||
clip() {},
|
||||
getImageData() { return { width: 1, height: 1, data: new Uint8ClampedArray([255, 0, 0, 255]) }; },
|
||||
putImageData() {},
|
||||
createImageData(w, h) { return { width: w, height: h, data: new Uint8ClampedArray(w * h * 4) }; },
|
||||
getContextAttributes() { return { alpha: true, desynchronized: false }; },
|
||||
};
|
||||
}
|
||||
|
||||
// Stub browser APIs Phaser needs to boot
|
||||
if (typeof global.HTMLCanvasElement === 'undefined') {
|
||||
global.HTMLCanvasElement = FakeCanvas;
|
||||
if (typeof global.HTMLCanvasElement === 'undefined' || !global.HTMLCanvasElement.prototype.getContext) {
|
||||
global.HTMLCanvasElement = FakeHTMLCanvasElement;
|
||||
}
|
||||
|
||||
if (typeof global.requestAnimationFrame === 'undefined') {
|
||||
@@ -51,10 +72,12 @@ if (typeof global.cancelAnimationFrame === 'undefined') {
|
||||
global.cancelAnimationFrame = (id) => global.clearTimeout(id);
|
||||
}
|
||||
|
||||
// Phaser boot requires these
|
||||
if (typeof global.Image === 'undefined') {
|
||||
global.Image = class {};
|
||||
}
|
||||
if (typeof global.HTMLVideoElement === 'undefined') {
|
||||
global.HTMLVideoElement = class {};
|
||||
}
|
||||
|
||||
if (typeof global.AudioContext === 'undefined') {
|
||||
global.AudioContext = class {
|
||||
@@ -64,6 +87,11 @@ if (typeof global.AudioContext === 'undefined') {
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof global.performance === 'undefined') {
|
||||
const start = Date.now();
|
||||
global.performance = { now: () => Date.now() - start };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a headless Phaser Game instance with full physics.
|
||||
* @param {object} [configOverride={}] - Properties to merge into the default config.
|
||||
|
||||
145
tests/scaffold.test.js
Normal file
145
tests/scaffold.test.js
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Scaffold tests — Slice 1 foundation verification.
|
||||
*
|
||||
* Tests that the project skeleton compiles, the Phaser game config
|
||||
* is valid, and all game balance constants are defined and in range.
|
||||
*/
|
||||
|
||||
const { createHeadlessGame } = require('./helpers/setup');
|
||||
const { createTankState, createCrewState } = require('./helpers/fixtures');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants validation
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Game constants', () => {
|
||||
let constants;
|
||||
|
||||
beforeAll(() => {
|
||||
constants = require('../src/constants');
|
||||
});
|
||||
|
||||
test('TANK_MAX_SPEED is a positive number', () => {
|
||||
expect(constants.TANK_MAX_SPEED).toBeGreaterThan(0);
|
||||
expect(typeof constants.TANK_MAX_SPEED).toBe('number');
|
||||
});
|
||||
|
||||
test('TANK_ACCELERATION is a positive number', () => {
|
||||
expect(constants.TANK_ACCELERATION).toBeGreaterThan(0);
|
||||
expect(typeof constants.TANK_ACCELERATION).toBe('number');
|
||||
});
|
||||
|
||||
test('TANK_FRICTION is between 0 and 1', () => {
|
||||
expect(constants.TANK_FRICTION).toBeGreaterThan(0);
|
||||
expect(constants.TANK_FRICTION).toBeLessThan(1);
|
||||
expect(typeof constants.TANK_FRICTION).toBe('number');
|
||||
});
|
||||
|
||||
test('TURRET_MAX_ROTATION equals 15 deg/sec', () => {
|
||||
expect(constants.TURRET_MAX_ROTATION).toBe(15);
|
||||
});
|
||||
|
||||
test('MORALE_MAX is 100', () => {
|
||||
expect(constants.MORALE_MAX).toBe(100);
|
||||
});
|
||||
|
||||
test('MORALE_BUFF_THRESHOLD is 70', () => {
|
||||
expect(constants.MORALE_BUFF_THRESHOLD).toBe(70);
|
||||
});
|
||||
|
||||
test('MORALE_RECOVERY_RATE is a positive number', () => {
|
||||
expect(constants.MORALE_RECOVERY_RATE).toBeGreaterThan(0);
|
||||
expect(typeof constants.MORALE_RECOVERY_RATE).toBe('number');
|
||||
});
|
||||
|
||||
test('HEAT_MAX is 100', () => {
|
||||
expect(constants.HEAT_MAX).toBe(100);
|
||||
});
|
||||
|
||||
test('heat rates are positive numbers', () => {
|
||||
expect(constants.HEAT_IDLE_RATE).toBeGreaterThan(0);
|
||||
expect(constants.HEAT_MAX_SPEED_RATE).toBeGreaterThan(0);
|
||||
expect(constants.HEAT_COOLING_RATE).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('PERISCOPE_FOV is 180 degrees', () => {
|
||||
expect(constants.PERISCOPE_FOV).toBe(180);
|
||||
});
|
||||
|
||||
test('PERISCOPE_RANGE is 200 pixels', () => {
|
||||
expect(constants.PERISCOPE_RANGE).toBe(200);
|
||||
});
|
||||
|
||||
test('UNBUTTONED_FOV is 270 degrees', () => {
|
||||
expect(constants.UNBUTTONED_FOV).toBe(270);
|
||||
});
|
||||
|
||||
test('UNBUTTONED_RANGE is 350 pixels', () => {
|
||||
expect(constants.UNBUTTONED_RANGE).toBe(350);
|
||||
});
|
||||
|
||||
test('all required constants are defined', () => {
|
||||
const required = [
|
||||
'TANK_MAX_SPEED',
|
||||
'TANK_ACCELERATION',
|
||||
'TANK_FRICTION',
|
||||
'TURRET_MAX_ROTATION',
|
||||
'MORALE_MAX',
|
||||
'MORALE_BUFF_THRESHOLD',
|
||||
'MORALE_RECOVERY_RATE',
|
||||
'HEAT_MAX',
|
||||
'HEAT_IDLE_RATE',
|
||||
'HEAT_MAX_SPEED_RATE',
|
||||
'HEAT_COOLING_RATE',
|
||||
'PERISCOPE_FOV',
|
||||
'PERISCOPE_RANGE',
|
||||
'UNBUTTONED_FOV',
|
||||
'UNBUTTONED_RANGE',
|
||||
];
|
||||
for (const key of required) {
|
||||
expect(constants).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phaser game config
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Phaser game config', () => {
|
||||
test('game config has the correct properties', () => {
|
||||
// Verify the config object from main.js without actually booting a game
|
||||
// (Phaser HEADLESS still needs a real canvas implementation in jsdom)
|
||||
const config = require('../src/main').gameConfig;
|
||||
|
||||
expect(config.type).toBeDefined();
|
||||
expect(config.width).toBe(640);
|
||||
expect(config.height).toBe(360);
|
||||
expect(config.pixelArt).toBe(true);
|
||||
expect(config.roundPixels).toBe(true);
|
||||
expect(config.physics).toBeDefined();
|
||||
expect(config.physics.default).toBe('arcade');
|
||||
expect(config.physics.arcade.gravity).toEqual({ x: 0, y: 0 });
|
||||
expect(config.scene).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Test fixtures', () => {
|
||||
test('createTankState returns defaults and accepts overrides', () => {
|
||||
const state = createTankState();
|
||||
expect(state.x).toBe(320);
|
||||
expect(state.y).toBe(180);
|
||||
expect(state.isButtonedUp).toBe(true);
|
||||
|
||||
const custom = createTankState({ x: 100, isButtonedUp: false });
|
||||
expect(custom.x).toBe(100);
|
||||
expect(custom.isButtonedUp).toBe(false);
|
||||
});
|
||||
|
||||
test('createCrewState defaults morale to 50', () => {
|
||||
const state = createCrewState();
|
||||
expect(state.morale).toBe(50);
|
||||
expect(state.commanderAlive).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user