Added sound and credits, bombardment audio, and hid foreign production
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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]])));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user