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