Files
Battle-for-Tismo/tests/intelligence_test.js

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");
}
}