299 lines
13 KiB
JavaScript
299 lines
13 KiB
JavaScript
import { TestCase } from "./framework/test_case.js";
|
|
import { smallState, grantBuilding } from "./framework/helpers.js";
|
|
import { parseKey } from "../shared/hex.js";
|
|
import {
|
|
SABOTAGE,
|
|
SATELLITE,
|
|
SPY_REPORT_CITY,
|
|
SPY_REPORT_COMMS,
|
|
} from "../shared/data.js";
|
|
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
|
|
|
|
// The first populated foreign land tile in the fixture, for a spy to stand on.
|
|
function foreignLandTile(state, civ = 1) {
|
|
for (const [k, owner] of state.territory) {
|
|
if (owner !== civ) continue;
|
|
const coords = parseKey(k);
|
|
if (state.cityAt(coords)) continue;
|
|
if (state.unitsAt(coords).length > 0) continue;
|
|
const tile = state.tiles[k];
|
|
if (!tile || tile.terrainClass !== "Land") continue;
|
|
if ((state.tilePopulation.get(k) || 0) <= 0) continue;
|
|
return coords;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function spyProto(state) {
|
|
return state.protoUnits.find((unit) => unit.id === "spy");
|
|
}
|
|
|
|
function research(state, civ, id) {
|
|
const index = state.technologies.findIndex((tech) => tech.id === id);
|
|
state.researched.get(civ).add(index);
|
|
return index;
|
|
}
|
|
|
|
// A spy watching the given city. A spy cannot legally stand on a hostile city
|
|
// tile, but the model lets it watch from that tile or a neighbour; the fixture
|
|
// simply drops it on the city so the distance is always in range.
|
|
function spyAtCity(state, city, civ = 0) {
|
|
return state._spawnUnit({ x: city.coords.x, y: city.coords.y }, civ, spyProto(state));
|
|
}
|
|
|
|
function sequence(state, values) {
|
|
let i = 0;
|
|
state._random = () => values[Math.min(i++, values.length - 1)];
|
|
}
|
|
|
|
export class IntelligenceTest extends TestCase {
|
|
// ------------------------------------------------------------ sabotage --
|
|
|
|
test_sabotage_cripples_a_foreign_tile_for_a_week() {
|
|
const state = smallState();
|
|
const coords = foreignLandTile(state, 1);
|
|
this.assertNotNull(coords, "the fixture has a populated British tile");
|
|
const spy = state._spawnUnit(coords, 0, spyProto(state));
|
|
const before = state.getTileGdpPerCapita(coords);
|
|
this.assertGreater(before, 0, "the tile produces something to cripple");
|
|
|
|
this.assertTrue(state.canSabotage([spy.id]), "a spy on foreign land may sabotage");
|
|
this.assertTrue(state.requestSabotage([spy.id]), "the sabotage is accepted");
|
|
this.assertTrue(state.isTileSabotaged(coords), "the tile is crippled");
|
|
this.assertEqual(state.getTileGdpPerCapita(coords), 0, "its production is zeroed");
|
|
|
|
const news = state.news[state.news.length - 1];
|
|
this.assertEqual(news.type, "sabotage", "the sabotage is reported");
|
|
this.assertEqual(news.civ, 1, "to the nation whose tile was hit");
|
|
|
|
// A week later the block lapses and the tile's production returns.
|
|
state.totalHours += SABOTAGE.blockDays * HOURS_PER_DAY + 1;
|
|
state._tickSabotage();
|
|
this.assertFalse(state.isTileSabotaged(coords), "the week has run out");
|
|
this.assertGreater(state.getTileGdpPerCapita(coords), 0, "production is back");
|
|
}
|
|
|
|
test_sabotage_needs_a_spy_on_a_foreign_tile() {
|
|
const state = smallState();
|
|
const own = state.cities.find((city) => city.civ === 0);
|
|
const spy = state._spawnUnit(own.coords, 0, spyProto(state));
|
|
this.assertFalse(state.canSabotage([spy.id]), "a spy may not sabotage its own land");
|
|
|
|
const target = foreignLandTile(state, 1);
|
|
this.assertNotNull(target, "the fixture has a foreign land tile");
|
|
const soldier = state._spawnUnit(
|
|
target,
|
|
0,
|
|
state.protoUnits.find((unit) => unit.id === "modern_infantry")
|
|
);
|
|
this.assertFalse(state.canSabotage([soldier.id]), "only a spy sabotages");
|
|
this.assertFalse(state.requestSabotage([soldier.id]), "and the order is refused");
|
|
}
|
|
|
|
// ---------------------------------------------------------- blame -------
|
|
|
|
test_blame_names_the_author_when_detected() {
|
|
const state = smallState();
|
|
const coords = foreignLandTile(state, 1);
|
|
const spy = state._spawnUnit(coords, 0, spyProto(state));
|
|
sequence(state, [0]); // the identify roll succeeds
|
|
state.requestSabotage([spy.id]);
|
|
const news = state.news[state.news.length - 1];
|
|
this.assertEqual(news.suspect, 0, "the saboteur is blamed");
|
|
}
|
|
|
|
test_blame_can_land_on_a_framed_nation_or_no_one() {
|
|
const state = smallState(["france", "britain", "germany"]);
|
|
const coords = foreignLandTile(state, 1);
|
|
|
|
// Fail to identify the author, then let the false flag on Germany stick.
|
|
// An operation spends its spy, so each attempt sends a fresh one.
|
|
const first = state._spawnUnit(coords, 0, spyProto(state));
|
|
sequence(state, [0.99, 0]);
|
|
state.requestSabotage([first.id], { frame: 2 });
|
|
let news = state.news[state.news.length - 1];
|
|
this.assertEqual(news.suspect, 2, "the framed nation takes the blame");
|
|
|
|
// Fail to identify, and fail to blame the scapegoat.
|
|
const second = state._spawnUnit(coords, 0, spyProto(state));
|
|
sequence(state, [0.99, 0.99]);
|
|
state.requestSabotage([second.id], { frame: 2 });
|
|
news = state.news[state.news.length - 1];
|
|
this.assertNull(news.suspect, "no one is blamed");
|
|
}
|
|
|
|
test_counter_intelligence_makes_identification_more_likely() {
|
|
const state = smallState();
|
|
research(state, 1, "counter_intelligence");
|
|
const coords = foreignLandTile(state, 1);
|
|
const spy = state._spawnUnit(coords, 0, spyProto(state));
|
|
// 0.7 fails the base 0.5 roll but clears the counter-intelligence threshold.
|
|
sequence(state, [0.7]);
|
|
state.requestSabotage([spy.id]);
|
|
const news = state.news[state.news.length - 1];
|
|
this.assertEqual(news.suspect, 0, "the counter-intelligence service fingers the author");
|
|
}
|
|
|
|
// -------------------------------------------------------- single-use ----
|
|
|
|
test_an_operation_spends_the_acting_spies() {
|
|
const state = smallState();
|
|
const coords = foreignLandTile(state, 1);
|
|
const spy = state._spawnUnit(coords, 0, spyProto(state));
|
|
this.assertTrue(state.requestSabotage([spy.id]), "the sabotage is accepted");
|
|
this.assertNull(state.findUnit(spy.id), "the saboteur is gone");
|
|
this.assertSize(state.unitsAt(coords), 0, "and leaves nothing behind");
|
|
}
|
|
|
|
test_only_the_acting_stack_is_spent() {
|
|
const state = smallState();
|
|
const coords = foreignLandTile(state, 1);
|
|
const actor = state._spawnUnit(coords, 0, spyProto(state));
|
|
const bystander = state._spawnUnit(coords, 0, spyProto(state));
|
|
this.assertTrue(state.requestSabotage([actor.id]), "the sabotage is accepted");
|
|
this.assertNull(state.findUnit(actor.id), "the saboteur is gone");
|
|
this.assertNotNull(state.findUnit(bystander.id), "the unselected spy stays");
|
|
}
|
|
|
|
test_a_bigger_team_escapes_the_blame() {
|
|
const state = smallState();
|
|
research(state, 1, "counter_intelligence");
|
|
const coords = foreignLandTile(state, 1);
|
|
const spies = [0, 1, 2].map(() => state._spawnUnit(coords, 0, spyProto(state)));
|
|
// A lone spy has a 0.75 chance of being fingered, so 0.7 would expose the
|
|
// author; three spies cut it to 0.3, so the same roll gets away.
|
|
sequence(state, [0.7]);
|
|
state.requestSabotage(spies.map((spy) => spy.id));
|
|
const news = state.news[state.news.length - 1];
|
|
this.assertNull(news.suspect, "the team gets away with it");
|
|
}
|
|
|
|
test_extra_spies_blunt_counter_intelligence() {
|
|
const state = smallState();
|
|
research(state, 1, "counter_intelligence");
|
|
this.assertEqual(state._effectiveCounterIntelligence(1, 1), 1, "one spy faces the full service");
|
|
this.assertEqual(state._effectiveCounterIntelligence(1, 2), 0.5, "a second spy halves it");
|
|
this.assertEqual(state._effectiveCounterIntelligence(1, 3), 0, "a third cancels it");
|
|
|
|
const city = state.cities.find((entry) => entry.civ === 1);
|
|
const spies = [0, 1, 2].map(() => spyAtCity(state, city));
|
|
// A roll of 0.5 falsifies a lone spy's report (0.5 < 0.7) but not a
|
|
// three-strong team's, whose falsify chance is zero.
|
|
sequence(state, [0.5]);
|
|
state.requestCitySpy(spies.map((spy) => spy.id), city.coords);
|
|
const report = state.spyReports.get(0)[0];
|
|
this.assertEqual(report.population, city.population, "the report is honest");
|
|
for (const spy of spies) this.assertNull(state.findUnit(spy.id), "every spy is spent");
|
|
}
|
|
|
|
// ------------------------------------------------------ city spying -----
|
|
|
|
test_city_spying_returns_an_accurate_report() {
|
|
const state = smallState();
|
|
const city = state.cities.find((entry) => entry.civ === 1);
|
|
const spy = spyAtCity(state, city);
|
|
this.assertNotNull(spy, "the fixture has a tile beside the British city");
|
|
this.assertTrue(state.canCitySpy([spy.id], city.coords), "the spy may watch the city");
|
|
|
|
sequence(state, [0]); // no counter-intelligence: the report is honest
|
|
this.assertTrue(state.requestCitySpy([spy.id], city.coords));
|
|
const reports = state.spyReports.get(0);
|
|
this.assertSize(reports, 1);
|
|
this.assertEqual(reports[0].kind, SPY_REPORT_CITY);
|
|
this.assertEqual(reports[0].city, city.name);
|
|
this.assertEqual(reports[0].population, city.population, "the population is exact");
|
|
this.assertSize(state.snapshot(0).spyReports, 1, "the report reaches its owner");
|
|
this.assertSize(state.snapshot(1).spyReports, 0, "and no one else");
|
|
}
|
|
|
|
test_counter_intelligence_falsifies_a_city_report() {
|
|
const state = smallState();
|
|
research(state, 1, "counter_intelligence");
|
|
const city = state.cities.find((entry) => entry.civ === 1);
|
|
const spy = spyAtCity(state, city);
|
|
sequence(state, [0]); // the falsify roll succeeds, then values shrink
|
|
state.requestCitySpy([spy.id], city.coords);
|
|
const report = state.spyReports.get(0)[0];
|
|
this.assertLess(report.population, city.population, "the population is distorted");
|
|
}
|
|
|
|
// ----------------------------------------------------- comms spying -----
|
|
|
|
test_comms_spying_intercepts_messages() {
|
|
const state = smallState();
|
|
const capital = state.cities.find((entry) => entry.civ === 1 && entry.isCapital);
|
|
const spy = spyAtCity(state, capital);
|
|
this.assertTrue(state.canCommsSpy([spy.id], capital.coords), "the spy may tap the capital");
|
|
|
|
state.recordChat(1, 0, "meet me at dawn");
|
|
state.recordChat(0, 1, "agreed");
|
|
sequence(state, [0.99]); // no counter-intelligence: the messages are clean
|
|
this.assertTrue(state.requestCommsSpy([spy.id], capital.coords));
|
|
const report = state.spyReports.get(0)[0];
|
|
this.assertEqual(report.kind, SPY_REPORT_COMMS);
|
|
this.assertSize(report.messages, 2, "both recent messages are intercepted");
|
|
this.assertEqual(report.messages[1].text, "agreed");
|
|
}
|
|
|
|
test_counter_intelligence_garbles_intercepted_comms() {
|
|
const state = smallState();
|
|
research(state, 1, "counter_intelligence");
|
|
const capital = state.cities.find((entry) => entry.civ === 1 && entry.isCapital);
|
|
const spy = spyAtCity(state, capital);
|
|
state.recordChat(1, 0, "attack at dawn");
|
|
sequence(state, [0]); // garble fires, then every character is scrambled
|
|
state.requestCommsSpy([spy.id], capital.coords);
|
|
const report = state.spyReports.get(0)[0];
|
|
this.assertTrue(report.messages[0].garbled, "the message is marked garbled");
|
|
this.assertTrue(report.messages[0].text.includes("▒"), "and its text is ruined");
|
|
}
|
|
|
|
// ------------------------------------------------------- satellites -----
|
|
|
|
test_a_satellite_reveals_a_chunk_every_two_days() {
|
|
const state = smallState();
|
|
const city = state.cities.find((entry) => entry.civ === 0);
|
|
grantBuilding(state, city, "space_launch_center", 1);
|
|
this.assertTrue(state.canLaunchSatellite(0), "a centre gives a launch pad");
|
|
this.assertTrue(state.requestSatellite(0, city), "the launch is accepted");
|
|
this.assertEqual(state.satellites.length, 1);
|
|
this.assertFalse(state.canLaunchSatellite(0), "one centre sustains one satellite");
|
|
|
|
const satellite = state.satellites[0];
|
|
this.assertEqual(satellite.civ, 0);
|
|
this.assertEqual(
|
|
satellite.nextRevealHour,
|
|
SATELLITE.revealEveryDays * HOURS_PER_DAY,
|
|
"the first sweep is two days out"
|
|
);
|
|
const news = state.news[state.news.length - 1];
|
|
this.assertEqual(news.type, "satellite", "everyone is told of the launch");
|
|
|
|
// Wipe the viewer's explored set so the reveal is measurable, then run the
|
|
// clock to the sweep.
|
|
const explored = state.explored.get(0);
|
|
explored.clear();
|
|
state._random = () => 0;
|
|
state.totalHours = satellite.nextRevealHour;
|
|
state._tickSatellites();
|
|
this.assertGreater(explored.size, 0, "the sweep uncovers tiles");
|
|
this.assertGreater(state.satellites[0].nextRevealHour, state.totalHours, "and schedules the next");
|
|
}
|
|
|
|
// --------------------------------------------------------- secrets ------
|
|
|
|
test_enrichment_can_leak_to_foreign_intelligence() {
|
|
const state = smallState();
|
|
state.news.length = 0;
|
|
sequence(state, [0]);
|
|
state._warnOfEnrichment(1);
|
|
this.assertEqual(state.news.length, 1, "the warning is published");
|
|
this.assertEqual(state.news[0].type, "enrichment");
|
|
this.assertEqual(state.news[0].civ, 1, "naming the enriching nation");
|
|
|
|
sequence(state, [0.99]);
|
|
state._warnOfEnrichment(1);
|
|
this.assertEqual(state.news.length, 1, "and sometimes it stays secret");
|
|
}
|
|
}
|