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.
622 lines
23 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|