Files
iron-requiem/tests/systems/RadioSystem.test.js
Kay Kayyali eea9c9c973
Some checks failed
Iron Requiem CI/CD / test (push) Failing after 2m53s
Iron Requiem CI/CD / deploy (push) Has been skipped
Build & Deploy / build-and-deploy (push) Has been cancelled
feat: S3/S4 wiring — HeatSystem tick, RadioSystem, IronLedger, PauseScene, Ghost Crew, ScavengeSystem, data files, CampaignMap, CutsceneScene
S3 (Crew + Morale + Radio + Heat):
- HeatSystem wired: update() called in MainGame tick, thermal multipliers applied to speed/accuracy, fuel depletion
- RadioSystem wired: message queue, ghost transmissions, enemy comms interception, debug overlay display
- IronLedger wired: kill/zone/morale logging, SaveManager integration, HUD ledger display
- PauseScene: ESC menu with Resume/Settings/Quit, system pausing (audio/spawns)

S4 (Campaign + Zones + Cutscenes + Polish):
- Data files: 3-zone definitions, 14 upgrades (4 categories), 25 dialogue nodes with 4 branching endings
- ScavengeSystem: kill yields, one-shot bonuses, wreck scavenging, post-combat tally
- Ghost Crew: top-50 runs with score formula, German naming pools, GhostCrewScene display
- CampaignMapScene, CutsceneScene, upgrades wired into main.js

Tests: 496 total, 0 regressions. All workers passed green.
2026-05-30 05:04:35 +00:00

622 lines
23 KiB
JavaScript

/**
* RadioSystem — comms, intel, ghost crew messages.
*
* Tests:
* 1. Construction — empty queue, configurable message pools
* 2. Queue push/pop — messages enqueue and dequeue in order
* 3. onKill — kill event triggers chatter message
* 4. onZoneTransition — zone transition triggers chatter
* 5. onMoraleWarning — low morale triggers chatter
* 6. Ghost transmissions — lore delivery from dead crews
* 7. Enemy comms interception — range-based
* 8. Queue timing — messages have duration, playback respects timing
* 9. Callback — onMessage fires when message is active
*
* RED phase: RadioSystem.js does not exist yet — all tests fail.
*
* Tests path: tests/systems/RadioSystem.test.js
* Source path: src/game/systems/RadioSystem.js
*/
let RadioSystem;
describe('RadioSystem', () => {
beforeAll(async () => {
const mod = await import('../../src/game/systems/RadioSystem.js');
RadioSystem = mod.RadioSystem;
});
// ═══════════════════════════════════════════════════════════════
// Construction
// ═══════════════════════════════════════════════════════════════
describe('construction', () => {
it('creates with default message pools when none provided', () => {
const radio = new RadioSystem();
expect(radio.getQueueLength()).toBe(0);
expect(typeof radio._messagePools).toBe('object');
expect(radio._messagePools.chatter).toBeDefined();
expect(Array.isArray(radio._messagePools.chatter.kill)).toBe(true);
expect(radio._messagePools.chatter.kill.length).toBeGreaterThan(0);
});
it('accepts custom message pools', () => {
const pools = {
chatter: {
kill: ['Target destroyed, commander.'],
zoneTransition: ['Entering new sector.'],
},
ghost: [],
enemy: {},
};
const radio = new RadioSystem(pools);
expect(radio._messagePools.chatter.kill).toEqual(['Target destroyed, commander.']);
expect(radio._messagePools.chatter.zoneTransition).toEqual(['Entering new sector.']);
});
it('starts with empty queue', () => {
const radio = new RadioSystem();
expect(radio.getQueueLength()).toBe(0);
});
it('has a settable onMessage callback', () => {
const radio = new RadioSystem();
expect(radio.onMessage).toBeNull();
const cb = () => {};
radio.onMessage = cb;
expect(radio.onMessage).toBe(cb);
});
});
// ═══════════════════════════════════════════════════════════════
// Queue system
// ═══════════════════════════════════════════════════════════════
describe('queue', () => {
it('enqueue adds a message to the queue', () => {
const radio = new RadioSystem();
radio.enqueue({ text: 'Testing comms.', speaker: 'loader', duration: 3000 });
expect(radio.getQueueLength()).toBe(1);
});
it('dequeue returns messages in FIFO order', () => {
const radio = new RadioSystem();
radio.enqueue({ text: 'First message.', speaker: 'commander', duration: 2000 });
radio.enqueue({ text: 'Second message.', speaker: 'driver', duration: 2000 });
const msg1 = radio.dequeue();
const msg2 = radio.dequeue();
expect(msg1.text).toBe('First message.');
expect(msg2.text).toBe('Second message.');
expect(radio.getQueueLength()).toBe(0);
});
it('dequeue returns null when queue is empty', () => {
const radio = new RadioSystem();
expect(radio.dequeue()).toBeNull();
});
it('enqueue assigns auto-incrementing IDs', () => {
const radio = new RadioSystem();
radio.enqueue({ text: 'A', speaker: 'gunner', duration: 1000 });
radio.enqueue({ text: 'B', speaker: 'loader', duration: 1000 });
const a = radio.dequeue();
const b = radio.dequeue();
expect(a.id).toBe(1);
expect(b.id).toBe(2);
});
it('peek returns next message without removing it', () => {
const radio = new RadioSystem();
radio.enqueue({ text: 'Next up.', speaker: 'commander', duration: 3000 });
const peeked = radio.peek();
expect(peeked.text).toBe('Next up.');
expect(radio.getQueueLength()).toBe(1); // still there
});
it('peek returns null when queue is empty', () => {
const radio = new RadioSystem();
expect(radio.peek()).toBeNull();
});
});
// ═══════════════════════════════════════════════════════════════
// Message playback with timing
// ═══════════════════════════════════════════════════════════════
describe('playback timing', () => {
it('update with delta time advances playback timer', () => {
const radio = new RadioSystem();
radio.enqueue({ text: 'Incoming transmission.', speaker: 'radio', duration: 5000 });
// Start playing
radio.update(2000);
expect(radio.isPlaying()).toBe(true);
// Message still playing at 4000ms
radio.update(2000); // total 4000ms elapsed
expect(radio.isPlaying()).toBe(true);
// Message completes at 5000ms
radio.update(1000); // total 5000ms elapsed
expect(radio.isPlaying()).toBe(false);
});
it('auto-dequeues next message when current finishes', () => {
const radio = new RadioSystem();
radio.enqueue({ text: 'First.', speaker: 'loader', duration: 1000 });
radio.enqueue({ text: 'Second.', speaker: 'gunner', duration: 1000 });
// Play first
radio.update(1200); // finish first
expect(radio.isPlaying()).toBe(false);
// Next update should start second
radio.update(100);
expect(radio.isPlaying()).toBe(true);
expect(radio.getCurrentMessage().text).toBe('Second.');
});
it('update is no-op when queue is empty', () => {
const radio = new RadioSystem();
expect(() => radio.update(500)).not.toThrow();
expect(radio.isPlaying()).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════
// onMessage callback
// ═══════════════════════════════════════════════════════════════
describe('onMessage callback', () => {
it('fires onMessage when a new message starts playing', () => {
const radio = new RadioSystem();
const received = [];
radio.onMessage = (msg) => received.push(msg);
radio.enqueue({ text: 'Calling in.', speaker: 'commander', duration: 5000 });
radio.update(100);
expect(received.length).toBe(1);
expect(received[0].text).toBe('Calling in.');
});
it('fires onMessage when transitioning to next queued message', () => {
const radio = new RadioSystem();
const received = [];
radio.onMessage = (msg) => received.push(msg);
radio.enqueue({ text: 'Msg 1', speaker: 'driver', duration: 500 });
radio.enqueue({ text: 'Msg 2', speaker: 'gunner', duration: 500 });
radio.update(600); // finish first, start second
expect(received.length).toBe(2);
expect(received[0].text).toBe('Msg 1');
expect(received[1].text).toBe('Msg 2');
});
it('does not fire onMessage when queue is empty', () => {
const radio = new RadioSystem();
let fired = false;
radio.onMessage = () => { fired = true; };
radio.update(500);
expect(fired).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════
// Event-triggered chatter
// ═══════════════════════════════════════════════════════════════
describe('event chatter', () => {
it('onKill enqueues a kill chatter message', () => {
const radio = new RadioSystem();
radio.onKill('type62');
expect(radio.getQueueLength()).toBe(1);
const msg = radio.peek();
expect(msg.speaker).toBeDefined();
expect(msg.text.length).toBeGreaterThan(0);
});
it('onKill uses zone-specific messages when zone is provided', () => {
const pools = {
chatter: {
kill: {
tundra: ['Tundra kill confirmed.'],
city: ['Urban target neutralized.'],
},
zoneTransition: ['Moving out.'],
},
ghost: [],
enemy: {},
};
const radio = new RadioSystem(pools);
radio.onKill('type59', 'tundra');
const msg = radio.dequeue();
expect(msg.text).toBe('Tundra kill confirmed.');
});
it('onKill falls back to generic kill messages when zone not specified', () => {
const pools = {
chatter: {
kill: {
_default: ['Target down!'],
tundra: ['Tundra kill confirmed.'],
},
zoneTransition: ['Moving out.'],
},
ghost: [],
enemy: {},
};
const radio = new RadioSystem(pools);
radio.onKill('helicopter_gunship'); // no zone
const msg = radio.dequeue();
expect(msg.text).toBe('Target down!');
});
it('onZoneTransition enqueues a transition message', () => {
const radio = new RadioSystem();
radio.onZoneTransition(1, 2, 'Taiga');
expect(radio.getQueueLength()).toBe(1);
const msg = radio.peek();
expect(msg.speaker).toBeDefined();
});
it('onZoneTransition message contains zone label in text', () => {
const pools = {
chatter: {
kill: ['Boom.'],
zoneTransition: ['Entering {zone}. Stay sharp.'],
},
ghost: [],
enemy: {},
};
const radio = new RadioSystem(pools);
radio.onZoneTransition(1, 2, 'Taiga');
const msg = radio.dequeue();
expect(msg.text).toContain('Taiga');
});
it('onMoraleWarning enqueues a chatter message when morale drops below threshold', () => {
const radio = new RadioSystem();
radio.onMoraleWarning(25, 30); // morale 25, threshold 30
expect(radio.getQueueLength()).toBe(1);
const msg = radio.peek();
expect(msg.speaker).toBeDefined();
});
it('onMoraleWarning does NOT enqueue when morale is above threshold', () => {
const radio = new RadioSystem();
radio.onMoraleWarning(45, 30); // morale 45 > threshold 30
expect(radio.getQueueLength()).toBe(0);
});
});
// ═══════════════════════════════════════════════════════════════
// Ghost transmissions
// ═══════════════════════════════════════════════════════════════
describe('ghost transmissions', () => {
it('triggerGhostTransmission enqueues a lore message', () => {
const pools = {
chatter: { kill: [], zoneTransition: [] },
ghost: [
{ text: 'We held the line at Karkov...', speaker: 'ghost', crew: '3rd Armored' },
{ text: 'Tell my family I fought well.', speaker: 'ghost', crew: '7th Rangers' },
],
enemy: {},
};
const radio = new RadioSystem(pools);
radio.triggerGhostTransmission();
expect(radio.getQueueLength()).toBe(1);
const msg = radio.peek();
expect(msg.speaker).toBe('ghost');
expect(msg.text.length).toBeGreaterThan(0);
});
it('triggerGhostTransmission cycles through messages without repeating until pool exhausted', () => {
const pools = {
chatter: { kill: [], zoneTransition: [] },
ghost: [
{ text: 'Ghost A', speaker: 'ghost', crew: 'A' },
{ text: 'Ghost B', speaker: 'ghost', crew: 'B' },
],
enemy: {},
};
const radio = new RadioSystem(pools);
const seen = new Set();
for (let i = 0; i < 2; i++) {
radio.triggerGhostTransmission();
const msg = radio.dequeue();
seen.add(msg.text);
}
// Should have seen both unique messages
expect(seen.size).toBe(2);
expect(seen.has('Ghost A')).toBe(true);
expect(seen.has('Ghost B')).toBe(true);
});
it('triggerGhostTransmission does nothing when ghost pool is empty', () => {
const radio = new RadioSystem({
chatter: { kill: [], zoneTransition: [] },
ghost: [],
enemy: {},
});
radio.triggerGhostTransmission();
expect(radio.getQueueLength()).toBe(0);
});
});
// ═══════════════════════════════════════════════════════════════
// Enemy comms interception
// ═══════════════════════════════════════════════════════════════
describe('enemy comms interception', () => {
it('interceptEnemyComms adds message when enemy is in range', () => {
const pools = {
chatter: { kill: [], zoneTransition: [] },
ghost: [],
enemy: {
default: ['Enemy patrol spotted near grid {x}.'],
},
};
const radio = new RadioSystem(pools);
radio.interceptEnemyComms('patrol', 100, 200, 150); // tank at (100,200), enemy at range 150
expect(radio.getQueueLength()).toBe(1);
const msg = radio.peek();
expect(msg.speaker).toBe('radio');
});
it('interceptEnemyComms does nothing when enemy is out of range', () => {
const pools = {
chatter: { kill: [], zoneTransition: [] },
ghost: [],
enemy: {
default: ['Enemy spotted.'],
},
};
const radio = new RadioSystem(pools);
radio.interceptEnemyComms('patrol', 100, 200, 500); // range 500 > default max
expect(radio.getQueueLength()).toBe(0);
});
it('interceptEnemyComms has configurable intercept range', () => {
const pools = {
chatter: { kill: [], zoneTransition: [] },
ghost: [],
enemy: {
default: ['Enemy nearby.'],
},
};
const radio = new RadioSystem(pools, 400); // interceptRange = 400
// At range 350 — should intercept (under 400)
radio.interceptEnemyComms('helicopter_gunship', 100, 200, 350);
expect(radio.getQueueLength()).toBe(1);
// At range 450 — should NOT intercept (over 400)
radio.interceptEnemyComms('type59', 100, 200, 450);
expect(radio.getQueueLength()).toBe(1); // still only first message
});
it('interceptEnemyComms uses enemy-type-specific messages when available', () => {
const pools = {
chatter: { kill: [], zoneTransition: [] },
ghost: [],
enemy: {
default: ['Unknown contact.'],
helicopter_gunship: ['Chopper on approach!'],
},
};
const radio = new RadioSystem(pools);
radio.interceptEnemyComms('helicopter_gunship', 100, 200, 50);
const msg = radio.dequeue();
expect(msg.text).toBe('Chopper on approach!');
});
});
// ═══════════════════════════════════════════════════════════════
// Clear / reset
// ═══════════════════════════════════════════════════════════════
describe('clear / reset', () => {
it('clear empties the queue and stops current playback', () => {
const radio = new RadioSystem();
radio.enqueue({ text: 'A', speaker: 'loader', duration: 5000 });
radio.enqueue({ text: 'B', speaker: 'gunner', duration: 5000 });
radio.update(100);
radio.clear();
expect(radio.getQueueLength()).toBe(0);
expect(radio.isPlaying()).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════
// Queue overflow protection
// ═══════════════════════════════════════════════════════════════
describe('queue overflow', () => {
it('multiple kills do not overflow the queue beyond max', () => {
const radio = new RadioSystem({}, 300, 5); // maxQueueSize = 5
for (let i = 0; i < 20; i++) {
radio.onKill('type62');
}
expect(radio.getQueueLength()).toBeLessThanOrEqual(5);
});
it('oldest messages are dropped when queue overflows', () => {
const pools = {
chatter: {
kill: { _default: ['Kill.', 'Boom.', 'Down.', 'Scratch.', 'Eliminated.', 'Destroyed.'] },
killByZone: {},
zoneTransition: [],
moraleLow: [],
},
ghost: [],
enemy: {},
};
// Override Math.random to cycle deterministically
let callCount = 0;
const origRandom = Math.random;
Math.random = () => (callCount++ % 6) / 6; // cycles through 6 kill messages
try {
const radio = new RadioSystem(pools, 300, 3); // maxQueueSize = 3
radio.onKill('type62'); // msg 0: 'Kill.'
radio.onKill('type62'); // msg 1: 'Boom.'
radio.onKill('type62'); // msg 2: 'Down.' — queue full
radio.onKill('type62'); // msg 3: 'Scratch.' — oldest ('Kill.') drops
radio.onKill('type62'); // msg 4: 'Eliminated.' — oldest ('Boom.') drops
expect(radio.getQueueLength()).toBe(3);
const a = radio.dequeue();
const b = radio.dequeue();
const c = radio.dequeue();
// The queue should contain the three most recent: 'Down.', 'Scratch.', 'Eliminated.'
expect(a.text).toBe('Down.');
expect(b.text).toBe('Scratch.');
expect(c.text).toBe('Eliminated.');
expect(radio.getQueueLength()).toBe(0);
} finally {
Math.random = origRandom;
}
});
});
// ═══════════════════════════════════════════════════════════════
// State save / load
// ═══════════════════════════════════════════════════════════════
describe('state save/load', () => {
it('getState returns a serializable snapshot of the queue', () => {
const radio = new RadioSystem();
radio.onKill('type62');
radio.onZoneTransition(1, 2, 'City');
radio.triggerGhostTransmission();
const state = radio.getState();
expect(state).toBeDefined();
expect(Array.isArray(state.queue)).toBe(true);
expect(state.queue.length).toBe(3);
expect(state.nextId).toBeGreaterThan(0);
expect(state.ghostIdx).toBeGreaterThan(0);
});
it('loadState restores queue exactly', () => {
const radio = new RadioSystem();
radio.onKill('type62');
radio.onKill('type59');
radio.triggerGhostTransmission();
radio.onZoneTransition(1, 2, 'Taiga');
const state = radio.getState();
expect(state.queue.length).toBe(4);
// Create a fresh RadioSystem and restore
const restored = new RadioSystem();
restored.loadState(state);
expect(restored.getQueueLength()).toBe(4);
const msg1 = restored.dequeue();
const msg2 = restored.dequeue();
const msg3 = restored.dequeue();
const msg4 = restored.dequeue();
expect(msg1.speaker).toBe('gunner'); // kill chatter
expect(msg2.speaker).toBe('gunner'); // kill chatter
expect(msg3.speaker).toBe('ghost'); // ghost transmission
expect(msg4.speaker).toBe('commander'); // zone transition
expect(restored.getQueueLength()).toBe(0);
});
it('loadState preserves nextId so IDs do not collide', () => {
const radio = new RadioSystem();
radio.onKill('type62');
radio.onKill('type59');
const state = radio.getState();
const savedNextId = state.nextId;
const restored = new RadioSystem();
restored.loadState(state);
// New enqueue should continue from saved nextId
restored.enqueue({ text: 'New msg', speaker: 'loader', duration: 1000 });
const msg = restored.dequeue(); // skip old ones
const msg2 = restored.dequeue(); // skip old ones
const msg3 = restored.dequeue(); // the new one
expect(msg3.id).toBeGreaterThanOrEqual(savedNextId);
});
it('loadState restores ghost transmission index', () => {
const pools = {
chatter: { kill: [], killByZone: {}, zoneTransition: [], moraleLow: [] },
ghost: [
{ text: 'Ghost A', speaker: 'ghost', crew: 'Alpha' },
{ text: 'Ghost B', speaker: 'ghost', crew: 'Bravo' },
{ text: 'Ghost C', speaker: 'ghost', crew: 'Charlie' },
],
enemy: {},
};
const radio = new RadioSystem(pools);
radio.triggerGhostTransmission(); // idx 0 → 1
radio.triggerGhostTransmission(); // idx 1 → 2
const state = radio.getState();
expect(state.ghostIdx).toBe(2);
expect(state.queue.length).toBe(2); // Ghost A + Ghost B are queued
const restored = new RadioSystem(pools);
restored.loadState(state);
// Drain the pre-restored messages (Ghost A, Ghost B)
restored.dequeue();
restored.dequeue();
// Next ghost should pick idx 2 (Ghost C)
restored.triggerGhostTransmission();
const msg = restored.dequeue();
expect(msg.text).toBe('Ghost C');
});
it('getState works on empty RadioSystem', () => {
const radio = new RadioSystem();
const state = radio.getState();
expect(state.queue).toEqual([]);
expect(state.nextId).toBe(1);
expect(state.ghostIdx).toBe(0);
});
it('round-trip preserves current message playback state', () => {
const radio = new RadioSystem();
radio.enqueue({ text: 'Playing now', speaker: 'commander', duration: 5000 });
radio.update(2000); // 2s into playback
const state = radio.getState();
expect(radio.isPlaying()).toBe(true);
const restored = new RadioSystem();
restored.loadState(state);
expect(restored.isPlaying()).toBe(true);
expect(restored.getCurrentMessage().text).toBe('Playing now');
expect(restored.getCurrentMessage().duration).toBe(5000);
});
});
});