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:
2026-05-23 06:19:53 +00:00
parent 46019af026
commit 681ba506a5
15 changed files with 785 additions and 35 deletions

3
.babelrc Normal file
View File

@@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env"]
}

5
.gitignore vendored
View File

@@ -1,4 +1,5 @@
node_modules/
dist/
.vite/
.vitest/
.env
*.log
.DS_Store

3
docker/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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',
},
};

View 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 };

View 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
}
}

View 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');
});
});

View 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');
});
});

View File

@@ -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
View 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);
});
});