Added sound and credits, bombardment audio, and hid foreign production

This commit is contained in:
2026-09-21 13:36:28 +02:00
parent a33dfe8132
commit f2b52fc2b4
28 changed files with 1395 additions and 6 deletions
+134
View File
@@ -0,0 +1,134 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom, FakeAudio } from "./framework/dom.js";
import { AudioManager, EFFECT_PRESETS } from "../client/js/audio.js";
// A minimal Web Audio stand-in that records every node it hands out and the
// connections made between them.
function fakeAudioContext() {
const nodes = [];
const node = (kind) => {
const created = {
kind,
connected: [],
connect(target) {
this.connected.push(target);
return target;
},
disconnect() {
this.disconnected = true;
},
};
nodes.push(created);
return created;
};
return {
nodes,
state: "running",
destination: node("destination"),
resume() {
return Promise.resolve();
},
createMediaElementSource: () => node("source"),
createBiquadFilter: () =>
Object.assign(node("biquad"), { type: "", frequency: { value: 0 }, Q: { value: 0 } }),
createGain: () => Object.assign(node("gain"), { gain: { value: 0 } }),
createDelay: () => Object.assign(node("delay"), { delayTime: { value: 0 } }),
};
}
export class AudioTest extends TestCase {
test_effect_sources_pick_the_chosen_library_files() {
const manager = new AudioManager();
this.assertEqual(manager.effects.click, "assets/sounds/button_click.mp3");
this.assertEqual(manager.effects.complete, "assets/sounds/build_complete.mp3");
this.assertEqual(manager.effects.notification, "assets/sounds/notification.mp3");
this.assertEqual(manager.effects.bomb, "assets/sounds/death_flash.flac");
this.assertEqual(manager.musicSource, "assets/sounds/crashed_ship.ogg");
}
async test_plays_effects_and_mutes() {
const env = await setupDom();
try {
FakeAudio.reset();
const manager = new AudioManager();
manager.play("click");
this.assertEqual(FakeAudio.playedSources, ["assets/sounds/button_click.mp3"]);
// The bombardment passes an explicit, zoom-scaled volume.
manager.play("bomb", { volume: 0.25 });
const bomb = FakeAudio.instances[FakeAudio.instances.length - 1];
this.assertEqual(bomb.src, "assets/sounds/death_flash.flac");
this.assertApprox(bomb.volume, 0.25, 1e-9, "the volume option overrides the default");
manager.setEnabled(false);
const muted = FakeAudio.playedSources.length;
manager.play("click");
manager.play("bomb", { volume: 0.9 });
this.assertEqual(FakeAudio.playedSources.length, muted, "muted effects are silent");
let changed = null;
manager.onChange = (value) => {
changed = value;
};
manager.setEnabled(true);
this.assertEqual(changed, true, "the toggle reports its new state");
} finally {
teardownDom(env);
}
}
async test_bombardment_uses_a_lowpass_and_echo() {
const env = await setupDom();
try {
FakeAudio.reset();
const context = fakeAudioContext();
const manager = new AudioManager({ contextFactory: () => context });
// Keep the graph-cleanup timer short so the test does not linger.
manager.presets.bomb.echoTail = 0.01;
manager.play("bomb", { volume: 0.4, duration: 0.01 });
const filter = context.nodes.find((node) => node.kind === "biquad");
this.assertNotNull(filter, "the clip runs through a biquad filter");
this.assertEqual(filter.type, "lowpass", "the blasts are muffled");
this.assertApprox(filter.frequency.value, EFFECT_PRESETS.bomb.lowpassHz);
const delay = context.nodes.find((node) => node.kind === "delay");
this.assertNotNull(delay, "an echo delay is built");
this.assertApprox(delay.delayTime.value, EFFECT_PRESETS.bomb.echoDelay);
const feedback = delay.connected.find(
(node) => node.kind === "gain" && node.connected.includes(delay)
);
this.assertNotNull(feedback, "the delay feeds back on itself");
this.assertApprox(feedback.gain.value, EFFECT_PRESETS.bomb.echoFeedback);
const played = FakeAudio.instances.find((audio) => audio.src === "assets/sounds/death_flash.flac");
this.assertNotNull(played, "the clip is played through the graph");
} finally {
teardownDom(env);
}
}
async test_music_loops_and_stops() {
const env = await setupDom();
try {
FakeAudio.reset();
const manager = new AudioManager();
manager.requestMusic();
const music = FakeAudio.instances.find((audio) => audio.src === manager.musicSource);
this.assertNotNull(music, "the music element was created");
this.assertTrue(music.loop, "the music loops");
this.assertEqual(music.plays, 1, "the music was started once");
manager.stopMusic();
this.assertTrue(music.paused, "stopping pauses the music");
// Muting while music is wanted stops it; unmuting starts it again.
manager.requestMusic();
manager.setEnabled(false);
manager.setEnabled(true);
this.assertGreater(FakeAudio.instances.length, 1, "music restarts when unmuted");
} finally {
teardownDom(env);
}
}
}
+108
View File
@@ -0,0 +1,108 @@
import { TestCase } from "./framework/test_case.js";
import {
BombardmentSfx,
AirStrikeSfx,
zoomVolume,
BOMB_VOLUME,
BOMB_CLIP_S,
BOMB_GAP_MS,
BOMB_IDLE_MS,
AIR_BOMB_BURST,
} from "../client/js/game_screen/bombing.js";
export class BombingTest extends TestCase {
test_volume_grows_with_zoom() {
this.assertLess(zoomVolume(0.2), zoomVolume(1.0), "closer is louder");
this.assertLess(zoomVolume(1.0), zoomVolume(2.0), "and louder still up close");
this.assertApprox(zoomVolume(0.2), BOMB_VOLUME.min);
this.assertApprox(zoomVolume(2.0), BOMB_VOLUME.max);
// Out-of-range zooms clamp rather than going silent or overdriven.
this.assertApprox(zoomVolume(-5), BOMB_VOLUME.min);
this.assertApprox(zoomVolume(50), BOMB_VOLUME.max);
}
test_silent_while_nothing_is_bombing() {
const plays = [];
const sfx = new BombardmentSfx((volume, duration) => plays.push({ volume, duration }));
for (let i = 0; i < 50; i++) sfx.update(100, false, 0.5);
this.assertEqual(plays.length, 0);
}
test_bursts_play_short_clips_at_the_zoom_volume() {
const plays = [];
let now = 0;
const sfx = new BombardmentSfx(
(volume, duration) => plays.push({ volume, duration, at: now }),
() => 0.5
);
// Ten seconds of frames, 100 ms apart, with a bombardment in view.
for (let i = 0; i < 100; i++) {
now += 100;
sfx.update(100, true, 0.4);
}
this.assertGreater(plays.length, 0, "impacts play");
this.assertLess(plays.length, 100, "the impacts arrive in bursts, not every frame");
for (const play of plays) {
this.assertApprox(play.volume, 0.4, 1e-9, "the impact uses the zoom volume");
this.assertGreaterOrEqual(play.duration, BOMB_CLIP_S.min);
this.assertLess(play.duration, BOMB_CLIP_S.max + 1e-9, "each clip is cut short");
}
}
test_impacts_keep_their_spacing() {
const times = [];
let now = 0;
const sfx = new BombardmentSfx(() => times.push(now), () => 0.99);
// Forty seconds of frames, 100 ms apart.
for (let i = 0; i < 400; i++) {
now += 100;
sfx.update(100, true, 0.5);
}
this.assertGreater(times.length, 3, "impacts play");
const gaps = [];
for (let i = 1; i < times.length; i++) gaps.push(times[i] - times[i - 1]);
for (const gap of gaps) {
this.assertGreaterOrEqual(gap, BOMB_GAP_MS.min, "impacts never bunch up");
}
this.assertTrue(
gaps.some((gap) => gap >= BOMB_IDLE_MS.min),
"and there are quiet gaps between bursts"
);
}
test_air_strike_is_one_large_one_shot_burst() {
const plays = [];
let now = 0;
const sfx = new AirStrikeSfx(
(volume, duration) => plays.push({ volume, duration, at: now }),
() => 0.99
);
sfx.trigger(0.5);
// Two seconds of frames, 50 ms apart, cover the whole burst.
for (let i = 0; i < 40; i++) {
now += 50;
sfx.update(50);
}
this.assertGreaterOrEqual(plays.length, AIR_BOMB_BURST.min, "at least seven explosions");
for (const play of plays) this.assertApprox(play.volume, 0.5, 1e-9);
// It is a one-shot: no further impacts once the burst has run out.
const after = plays.length;
for (let i = 0; i < 20; i++) {
now += 50;
sfx.update(50);
}
this.assertEqual(plays.length, after, "the burst does not keep repeating");
}
test_stopping_the_bombing_stops_the_sound() {
const plays = [];
const sfx = new BombardmentSfx((volume, duration) => plays.push(duration), () => 0);
for (let i = 0; i < 10; i++) sfx.update(100, true, 0.5);
const before = plays.length;
this.assertGreater(before, 0, "something played while active");
for (let i = 0; i < 10; i++) sfx.update(100, false, 0.5);
this.assertEqual(plays.length, before, "no impacts once the bombing stops");
for (let i = 0; i < 5; i++) sfx.update(100, true, 0.5);
this.assertGreater(plays.length, before, "the ambience resumes with the next barrage");
}
}
+97
View File
@@ -0,0 +1,97 @@
import { TestCase } from "./framework/test_case.js";
import {
CompletionWatcher,
buildingLevelGained,
cargoUnloaded,
} from "../client/js/game_screen/completions.js";
const city = (id, civ, buildings = {}) => ({ id, civ, buildings });
const unit = (id, civ, cargo = []) => ({ id, civ, cargo });
const snapshot = ({ cities = [], units = [] } = {}) => ({ cities, units });
export class CompletionsTest extends TestCase {
test_first_snapshot_only_primes_the_baseline() {
const watcher = new CompletionWatcher();
const done = watcher.update(
snapshot({ cities: [city(1, 0, { 0: 2 })], units: [unit(7, 0)] }),
0
);
this.assertTrue(done.first);
this.assertFalse(done.building);
this.assertFalse(done.unit);
}
test_detects_a_building_gaining_a_level() {
const watcher = new CompletionWatcher();
watcher.update(snapshot({ cities: [city(1, 0, { 0: 1 })] }), 0);
const done = watcher.update(snapshot({ cities: [city(1, 0, { 0: 2 })] }), 0);
this.assertTrue(done.building);
this.assertFalse(done.unit);
}
test_detects_a_freshly_trained_unit() {
const watcher = new CompletionWatcher();
watcher.update(snapshot({ units: [unit(7, 0)] }), 0);
const done = watcher.update(snapshot({ units: [unit(7, 0), unit(8, 0)] }), 0);
this.assertTrue(done.unit);
this.assertFalse(done.building);
}
test_a_second_snapshot_with_no_change_is_silent() {
const watcher = new CompletionWatcher();
const state = snapshot({ cities: [city(1, 0, { 0: 1 })], units: [unit(7, 0)] });
watcher.update(state, 0);
const done = watcher.update(
snapshot({ cities: [city(1, 0, { 0: 1 })], units: [unit(7, 0)] }),
0
);
this.assertFalse(done.building);
this.assertFalse(done.unit);
}
test_a_captured_city_does_not_count_as_a_completion() {
const watcher = new CompletionWatcher();
watcher.update(snapshot({ cities: [city(1, 0, {})] }), 0);
const done = watcher.update(
snapshot({ cities: [city(1, 0, {}), city(2, 0, { 0: 3 })] }),
0
);
this.assertFalse(done.building);
}
test_only_the_local_player_is_watched() {
const watcher = new CompletionWatcher();
watcher.update(snapshot({ cities: [city(1, 1, {}), city(2, 0, {})] }), 0);
const done = watcher.update(
snapshot({ cities: [city(1, 1, { 0: 4 }), city(2, 0, {})] }),
0
);
this.assertFalse(done.building);
}
test_landing_troops_is_not_a_trained_unit() {
const watcher = new CompletionWatcher();
watcher.update(snapshot({ units: [unit(5, 0, [{}]), unit(6, 1)] }), 0);
// The transport empties and a fresh infantry id appears on the shore.
const done = watcher.update(snapshot({ units: [unit(5, 0, []), unit(9, 0)] }), 0);
this.assertFalse(done.unit, "disembarking does not chime");
}
test_reset_primes_the_baseline_again() {
const watcher = new CompletionWatcher();
watcher.update(snapshot({ cities: [city(1, 0, {})] }), 0);
watcher.update(snapshot({ cities: [city(1, 0, { 0: 1 })] }), 0);
watcher.reset();
const done = watcher.update(snapshot({ cities: [city(1, 0, { 0: 1 })] }), 0);
this.assertTrue(done.first);
}
test_helpers_compare_levels_and_cargo() {
this.assertTrue(buildingLevelGained(new Map([[1, { 0: 1 }]]), new Map([[1, { 0: 2 }]])));
this.assertFalse(
buildingLevelGained(new Map([[1, { 0: 1 }]]), new Map([[1, { 0: 1 }], [2, { 0: 5 }]]))
);
this.assertTrue(cargoUnloaded(new Map([[5, 2]]), new Map([[5, 0]])));
this.assertFalse(cargoUnloaded(new Map([[5, 2]]), new Map([[5, 2]])));
}
}
+64
View File
@@ -0,0 +1,64 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom, FakeAudio } from "./framework/dom.js";
import { CREDIT_SECTIONS } from "../client/js/credits.js";
import { audio } from "../client/js/audio.js";
export class CreditsTest extends TestCase {
test_credits_name_the_required_sources() {
const text = JSON.stringify(CREDIT_SECTIONS);
for (const needle of [
"game-icons.net",
"opengameart.org",
"Ted Kerr",
"Delapouite",
"Lorc",
"Mapbox Maki",
]) {
this.assertTrue(text.includes(needle), `credits mention ${needle}`);
}
}
async test_menu_button_opens_the_credits_and_clicks_sound() {
const env = await setupDom();
try {
FakeAudio.reset();
await import(`../client/js/app.js?credits=${Date.now()}`);
audio.setEnabled(true);
this.assertTrue(env.$("#modal-credits").hasClass("hidden"));
env.$("#menu-credits").click();
this.assertFalse(env.$("#modal-credits").hasClass("hidden"));
this.assertGreaterOrEqual(env.$("#credits-list .credits-title").length, 3);
this.assertTrue(
FakeAudio.playedSources.includes("assets/sounds/button_click.mp3"),
"a button click plays the click effect"
);
env.$("#modal-credits .modal-close").click();
this.assertTrue(env.$("#modal-credits").hasClass("hidden"));
} finally {
teardownDom(env);
}
}
async test_audio_toggle_relabels_both_buttons() {
const env = await setupDom();
try {
await import(`../client/js/app.js?audio=${Date.now()}`);
audio.setEnabled(true);
this.assertEqual(env.$("#menu-audio").text(), "Sound: On");
this.assertEqual(env.$("#audio-button").text(), "Sound on");
env.$("#menu-audio").click();
this.assertFalse(audio.enabled);
this.assertEqual(env.$("#menu-audio").text(), "Sound: Off");
env.$("#audio-button").click();
this.assertTrue(audio.enabled);
this.assertEqual(env.$("#menu-audio").text(), "Sound: On");
} finally {
audio.setEnabled(true);
teardownDom(env);
}
}
}
+37 -1
View File
@@ -13,7 +13,7 @@ const JQUERY_PATH = fileURLToPath(new URL("../../client/vendor/jquery.min.js", i
// Only the globals the client actually reads. Some Node globals (navigator,
// location) are getter-only, so they are left alone.
const MANAGED_GLOBALS = [
"window", "document", "$", "jQuery", "WebSocket",
"window", "document", "$", "jQuery", "WebSocket", "Audio",
"requestAnimationFrame", "cancelAnimationFrame",
];
@@ -73,6 +73,41 @@ export class FakeWebSocket {
}
}
// Stand-in for the browser Audio element. jsdom's play() is unimplemented and
// noisy, and the tests only care that the right source was asked to play.
export class FakeAudio {
static instances = [];
static reset() {
FakeAudio.instances = [];
}
// Every source that was actually played, in order.
static get playedSources() {
return FakeAudio.instances.filter((audio) => audio.plays > 0).map((audio) => audio.src);
}
constructor(src) {
this.src = src;
this.volume = 1;
this.loop = false;
this.currentTime = 0;
this.paused = true;
this.plays = 0;
FakeAudio.instances.push(this);
}
play() {
this.plays += 1;
this.paused = false;
return Promise.resolve();
}
pause() {
this.paused = true;
}
}
export async function setupDom(options = {}) {
const html = await readFile(INDEX_PATH, "utf8");
const jquerySource = await readFile(JQUERY_PATH, "utf8");
@@ -96,6 +131,7 @@ export async function setupDom(options = {}) {
install("$", $);
install("jQuery", $);
install("WebSocket", options.webSocket || FakeWebSocket);
install("Audio", options.audio || FakeAudio);
// Keep the render loop out of tests: enter() schedules a frame but the stub
// never runs it, so no timer keeps the process alive.
install("requestAnimationFrame", () => 0);
+47 -1
View File
@@ -1,7 +1,7 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
import { GameScreen } from "../client/js/game_screen.js";
import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, ECONOMY } from "../shared/data.js";
import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, ECONOMY } from "../shared/data.js";
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
import { hoursInNextMonth } from "../shared/game_clock.js";
import { compact } from "../shared/text_format.js";
@@ -1706,6 +1706,52 @@ export class GameScreenTest extends TestCase {
teardownDom(env);
}
}
async test_air_strike_burst_fires_on_the_bomb_run_not_the_flight() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
screen.protoUnits = PROTO_UNITS;
screen.map.tileInView = () => true;
const airIndex = PROTO_UNITS.findIndex((proto) => proto.air);
const groundIndex = PROTO_UNITS.findIndex((proto) => proto.military && !proto.air);
const triggers = [];
screen.airBombing.trigger = (volume) => triggers.push(volume);
// The first snapshot only primes the baseline: no burst while the plane
// is still flying towards its target.
screen._detectAirStrikes({ units: [{ id: 1, proto: airIndex, strikeTarget: [5, 5] }] });
this.assertEmpty(triggers, "nothing while the plane is en route");
screen._detectAirStrikes({ units: [{ id: 1, proto: airIndex, strikeTarget: [5, 5] }] });
this.assertEmpty(triggers, "still nothing while it flies");
// Reaching the target clears the run: the bombs fall.
screen._detectAirStrikes({ units: [{ id: 1, proto: airIndex, strikeTarget: null }] });
this.assertSize(triggers, 1, "the resolved bomb run triggers the burst");
// A plane shot down before bombing is gone, not detonated.
screen._detectAirStrikes({ units: [{ id: 2, proto: airIndex, strikeTarget: [6, 6] }] });
screen._detectAirStrikes({ units: [] });
this.assertSize(triggers, 1, "a downed plane does not bomb");
// An off-screen bomb run is silent.
screen.map.tileInView = () => false;
screen._detectAirStrikes({ units: [{ id: 3, proto: airIndex, strikeTarget: [7, 7] }] });
screen._detectAirStrikes({ units: [{ id: 3, proto: airIndex, strikeTarget: null }] });
this.assertSize(triggers, 1, "the burst only plays in view");
// Aircraft do not drive the ambient barrage, ground batteries still do.
screen.map.tileInView = () => true;
screen.snapshot = { units: [{ id: 4, proto: airIndex, strikeTarget: [1, 1] }] };
this.assertFalse(screen._bombingInView(), "aircraft stay out of the ambience");
screen.snapshot = { units: [{ id: 5, proto: groundIndex, strikeTarget: [1, 1] }] };
this.assertTrue(screen._bombingInView(), "a ground battery still rumbles");
screen.leave();
} finally {
teardownDom(env);
}
}
}
function findEnemyTile(state) {
+27 -1
View File
@@ -330,13 +330,39 @@ export class GameStateTest extends TestCase {
for (const key of [
"seed", "totalHours", "civs", "protos", "buildings", "governments", "technologies",
"cities", "units", "territory", "population", "budgets", "culture",
"government", "researched", "training",
"government", "researched",
]) {
this.assertEqual(first[key], second[key], `key ${key} is shared`);
}
this.assertNotEqual(first.visible, second.visible, "visibility stays per viewer");
}
test_snapshot_only_shows_the_viewers_own_production() {
const state = smallState();
state.budgets.set(0, 1.0e15);
state.budgets.set(1, 1.0e15);
const mine = state.cities.find((c) => c.civ === 0);
const theirs = state.cities.find((c) => c.civ === 1);
grantBuilding(state, mine, "barracks");
grantBuilding(state, theirs, "barracks");
this.assertTrue(state.requestTrain(mine.id, 0));
this.assertTrue(state.requestTrain(theirs.id, 0));
const asMine = state.snapshot(0).training;
this.assertHas(asMine, mine.id);
this.assertFalse(
Object.prototype.hasOwnProperty.call(asMine, theirs.id),
"no enemy queue is shipped"
);
const asTheirs = state.snapshot(1).training;
this.assertHas(asTheirs, theirs.id);
this.assertFalse(
Object.prototype.hasOwnProperty.call(asTheirs, mine.id),
"the enemy sees nothing either"
);
this.assertEmpty(state.snapshot(-1).training, "a spectator sees no production");
}
test_explored_serialisation_cache_invalidates_on_discovery() {
const state = smallState();
state.explored.get(0).clear();
+35
View File
@@ -154,6 +154,24 @@ export class MapViewTest extends TestCase {
}
}
async test_tile_in_view_follows_the_camera() {
const state = smallState();
const { env, map } = await this.build(state);
try {
const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
const coords = { x: capital.coords.x, y: capital.coords.y };
map.centerOn(coords);
this.assertTrue(map.tileInView(coords), "the centred tile is in view");
this.assertTrue(map.tileInView(coords, 1), "still in view with a margin");
// Pan away along the (unwrapped) north-south axis: the tile drops out.
map.camera.y += 100000;
this.assertFalse(map.tileInView(coords), "a far tile is out of view");
} finally {
teardownDom(env);
}
}
async test_tile_at_screen_round_trips() {
const state = smallState();
const { env, map } = await this.build(state);
@@ -834,6 +852,23 @@ export class MapViewTest extends TestCase {
}
}
async test_foreign_city_hides_its_production() {
const state = smallState();
state.budgets.set(1, 1.0e15);
const enemy = state.cities.find((c) => c.civ === 1);
grantBuilding(state, enemy, "barracks");
this.assertTrue(state.requestTrain(enemy.id, 0));
state.training.get(enemy.id)[0].elapsedHours = 1;
const { env, map } = await this.build(state);
try {
const bar = map._cityViews.get(enemy.id).querySelector(".city-progress");
this.assertNotNull(bar, "the enemy city has a progress element");
this.assertEqual(bar.style.display, "none", "a foreign city never shows production");
} finally {
teardownDom(env);
}
}
async test_each_building_level_adds_a_square_around_the_city() {
const state = smallState();
const city = state.cities.find((c) => c.civ === 0);