Files
Battle-for-Tismo/shared/game_state/intelligence.js
T

427 lines
15 KiB
JavaScript

// Espionage: what a spy unit does once it reaches a foreign tile. The spy
// itself is an ordinary unit marked `proto.spy`, so its invisibility lives in
// visibility.js and its freedom to cross foreign land in movement.js; this
// module owns the covert operations ordered against it, the reports they bring
// back, the counter-intelligence that fights them, and the spy satellites that
// watch the world from orbit.
import { key, parseKey } from "../hex.js";
import {
SABOTAGE,
ESPIONAGE,
SATELLITE,
SPY_REPORT_CITY,
SPY_REPORT_COMMS,
} from "../data/intelligence.js";
import {
NEWS_SABOTAGE,
NEWS_SATELLITE,
NEWS_ENRICHMENT,
} from "../data/relations.js";
import { BUILDING_MECHANIC } from "../data/buildings.js";
import { HOURS_PER_DAY } from "./constants.js";
export const intelligenceMethods = {
// ------------------------------------------------- intelligence levels --
// Whether a nation has professional intelligence services. This is the
// technology that unlocks the spy, so any nation with a spy has it.
_hasIntelligenceAgencies(civ) {
return this.hasTechnology(civ, "intelligence_agencies");
},
_hasCounterIntelligence(civ) {
return this.hasTechnology(civ, "counter_intelligence");
},
// The strength of a nation's offensive intelligence, 0 or 1 for now (one
// unlock-only technology). Read by the blame roll.
_intelligenceLevel(civ) {
return this._hasIntelligenceAgencies(civ) ? 1 : 0;
},
// The strength of a nation's defence against spies, 0 or 1. It makes blame
// land on the true culprit more often and makes incoming reports less honest.
_counterIntelligenceLevel(civ) {
return this._hasCounterIntelligence(civ) ? 1 : 0;
},
// A covert operation spends its operatives, so a larger team is the only way
// to out-match a counter-intelligence service: each spy beyond the first
// cancels `ESPIONAGE.agentsPerCounter` of the target's defence.
_effectiveCounterIntelligence(civ, agents = 1) {
const level = this._counterIntelligenceLevel(civ);
const extra = Math.max(0, (Number(agents) || 1) - 1);
return Math.max(0, level - extra * ESPIONAGE.agentsPerCounter);
},
// Removes the operatives an operation spent. A spy is single-use: it vanishes
// quietly, with no news line and no notice to its owner or its victim.
_spendSpies(units) {
for (const unit of units) this._destroyUnit(unit);
this._visibilityDirty = true;
},
// --------------------------------------------------------- sabotage ------
// Whether a stack may sabotage the tile it stands on: every unit is a spy,
// they share one tile, and that tile is foreign land.
canSabotage(unitIds) {
const units = this._unitsForOrder(unitIds);
if (units.length === 0) return false;
if (!units.every((unit) => this._isSpy(unit))) return false;
const coords = units[0].coords;
if (!units.every((unit) => unit.coords.x === coords.x && unit.coords.y === coords.y)) {
return false;
}
const owner = this.civAt(coords);
if (owner < 0 || owner === units[0].civ) return false;
const tile = this.tiles[key(coords.x, coords.y)];
return !!tile && tile.terrainClass === "Land";
},
// Sabotages the tile the spies stand on: its production is zeroed until a week
// has passed. `frame` is an optional third nation the author tries to pin it
// on; the victim cannot tell a correct blame from a successful false flag. The
// acting spies are spent.
requestSabotage(unitIds, { frame = null } = {}) {
const units = this._unitsForOrder(unitIds);
if (!this.canSabotage(unitIds)) return false;
const actor = units[0].civ;
const coords = units[0].coords;
const k = key(coords.x, coords.y);
const owner = this.civAt(coords);
const until = this.totalHours + SABOTAGE.blockDays * HOURS_PER_DAY;
this.tileSabotage.set(k, until);
this._dropTileGdpCache(k);
const region = this.regionCityAt(coords);
this._addNews({
type: NEWS_SABOTAGE,
civ: owner,
region: region ? region.name : "the frontier",
suspect: this._blameCulprit(actor, owner, frame, units.length),
});
this._spendSpies(units);
this._emitChanged();
return true;
},
// Who the victim pins a covert act on: the true author, a chosen scapegoat, or
// no one. A counter-intelligence service raises the chance of a true
// identification; the author's own agencies lower it, and a larger team of
// `agents` blunts the service in proportion.
_blameCulprit(actor, victim, frame, agents = 1) {
const chance = Math.max(
0,
Math.min(
1,
ESPIONAGE.baseIdentifyChance +
ESPIONAGE.counterIntelligenceWeight * this._effectiveCounterIntelligence(victim, agents) -
ESPIONAGE.agencyWeight * this._intelligenceLevel(actor)
)
);
if (this._random() < chance) return actor;
const scapegoat = Number.isInteger(frame) && frame >= 0 && frame < this.civilisations.length
? frame
: null;
if (scapegoat !== null && scapegoat !== actor && scapegoat !== victim) {
if (this._random() < ESPIONAGE.frameChance) return scapegoat;
}
return null;
},
// Whether a tile's production is currently crippled by sabotage.
isTileSabotaged(coords) {
const k = key(coords.x, coords.y);
return (this.tileSabotage.get(k) || 0) > this.totalHours;
},
// Drops the sabotage blocks whose week has run out and forgets the GDP figures
// they cached as zero, so a tile's production returns when the block ends.
_tickSabotage() {
if (!this.tileSabotage || this.tileSabotage.size === 0) return;
for (const [k, until] of this.tileSabotage) {
if (until > this.totalHours) continue;
this.tileSabotage.delete(k);
this._dropTileGdpCache(k);
}
},
// --------------------------------------------------- city/comms spying ---
// A spy may read a foreign city from the tile it stands on or the six around
// it: it can never enter a hostile city, so it watches from the doorstep.
_spyWatches(units, city) {
if (!city || city.civ === units[0].civ) return false;
const from = units[0].coords;
return this.topology.tileDistance(from, city.coords) <= 1;
},
_spyStackAtOneTile(unitIds) {
const units = this._unitsForOrder(unitIds);
if (units.length === 0) return null;
if (!units.every((unit) => this._isSpy(unit) && unit.civ === units[0].civ)) return null;
const coords = units[0].coords;
for (const unit of units) {
if (unit.coords.x !== coords.x || unit.coords.y !== coords.y) return null;
}
return units;
},
canCitySpy(unitIds, goal) {
const units = this._spyStackAtOneTile(unitIds);
if (!units) return false;
return this._spyWatches(units, this.cityAt(goal));
},
// Reads a foreign city: its people, building levels and garrison. A
// counter-intelligence service can quietly falsify the report. The spying
// spies are spent.
requestCitySpy(unitIds, goal) {
const units = this._spyStackAtOneTile(unitIds);
if (!this.canCitySpy(unitIds, goal)) return false;
const actor = units[0].civ;
this._addSpyReport(actor, this._buildCityReport(this.cityAt(goal), units.length));
this._spendSpies(units);
this._emitChanged();
return true;
},
_buildCityReport(city, agents = 1) {
const target = city.civ;
const falsify =
this._random() < ESPIONAGE.falsifyChance * this._effectiveCounterIntelligence(target, agents);
const buildings = [];
for (const [index, level] of Object.entries(city.buildings || {})) {
const n = Number(level) || 0;
if (n <= 0) continue;
const proto = this.protoBuildings[index];
buildings.push({
name: proto ? proto.name : `Building ${index}`,
level: falsify ? Math.max(0, n + (this._random() < 0.5 ? -1 : 1)) : n,
});
}
const counts = new Map();
for (const unit of this.unitsAt(city.coords)) {
if (unit.civ !== target) continue;
const proto = this.unitProto(unit);
const name = proto ? proto.name : "Unit";
counts.set(name, (counts.get(name) || 0) + 1);
}
const garrison = [];
for (const [name, count] of counts) {
garrison.push({
name,
count: falsify ? Math.max(0, count + (this._random() < 0.5 ? -1 : 1)) : count,
});
}
return {
id: this._nextSpyReportId++,
kind: SPY_REPORT_CITY,
hours: this.totalHours,
city: city.name,
civ: target,
coords: [city.coords.x, city.coords.y],
population: falsify ? this._jitterCount(city.population) : city.population,
buildings,
garrison,
};
},
canCommsSpy(unitIds, goal) {
const units = this._spyStackAtOneTile(unitIds);
if (!units) return false;
const city = this.cityAt(goal);
if (!city || !city.isCapital) return false;
return this._spyWatches(units, city);
},
// Intercepts the target capital's recent messages. A counter-intelligence
// service can garble the text and swap the correspondents. The spying spies
// are spent.
requestCommsSpy(unitIds, goal) {
const units = this._spyStackAtOneTile(unitIds);
if (!this.canCommsSpy(unitIds, goal)) return false;
const actor = units[0].civ;
this._addSpyReport(actor, this._buildCommsReport(this.cityAt(goal), units.length));
this._spendSpies(units);
this._emitChanged();
return true;
},
_buildCommsReport(capital, agents = 1) {
const target = capital.civ;
const garble =
this._random() < ESPIONAGE.garbleChance * this._effectiveCounterIntelligence(target, agents);
const involving = this.messages.filter(
(message) => message.from === target || message.to === target
);
const recent = involving.slice(-ESPIONAGE.interceptCount);
const messages = recent.map((message) =>
garble ? this._garbleMessage(message) : { ...message, garbled: false }
);
return {
id: this._nextSpyReportId++,
kind: SPY_REPORT_COMMS,
hours: this.totalHours,
civ: target,
capital: capital.name,
messages,
};
},
_garbleMessage(message) {
const swap = this._random() < 0.5;
return {
from: swap ? message.to : message.from,
to: swap ? message.from : message.to,
text: this._scramble(message.text),
hours: message.hours,
garbled: true,
};
},
_scramble(text) {
let out = "";
for (const character of String(text)) {
out += this._random() < 0.3 ? "▒" : character;
}
return out;
},
_jitterCount(value) {
const base = Number(value) || 0;
if (base <= 0) return 0;
const factor = 0.6 + this._random() * 0.8;
return Math.round(base * factor);
},
_addSpyReport(civ, report) {
if (!this.spyReports.has(civ)) this.spyReports.set(civ, []);
const list = this.spyReports.get(civ);
list.push(report);
while (list.length > ESPIONAGE.reportLimit) list.shift();
},
// ------------------------------------------------------------ comms ------
// Records a line of chat for the message log comms spying reads. `to` is a
// nation index for a private line, or null for the public channel.
recordChat(from, to, text) {
if (!Number.isInteger(from) || from < 0) return;
const message = {
from,
to: Number.isInteger(to) && to >= 0 ? to : null,
text: String(text || "").slice(0, 240),
hours: this.totalHours,
};
this.messages.push(message);
while (this.messages.length > ESPIONAGE.messageLogLimit) this.messages.shift();
},
// --------------------------------------------------------- satellites ----
// How many satellites a nation's space launch centres can sustain: one per
// centre level.
_spaceLaunchCapacity(civ) {
let total = 0;
for (const city of this.cities) {
if (city.civ !== civ) continue;
for (const [index, level] of Object.entries(city.buildings || {})) {
const proto = this.protoBuildings[index];
if (proto && proto.mechanic === BUILDING_MECHANIC.SPACE_LAUNCH) {
total += (Number(level) || 0) * SATELLITE.perLaunchCentreLevel;
}
}
}
return total;
},
_satelliteCount(civ) {
let total = 0;
for (const satellite of this.satellites) {
if (satellite.civ === civ) total += 1;
}
return total;
},
canLaunchSatellite(civ) {
return this._spaceLaunchCapacity(civ) > this._satelliteCount(civ);
},
// Launches a spy satellite from `city`, which must hold a space launch centre.
// Everyone is told; the satellite then uncovers a patch of the world every two
// days for as long as the game runs.
requestSatellite(civ, city) {
if (!city || city.civ !== civ) return false;
if (!this.canLaunchSatellite(civ)) return false;
let holdsCentre = false;
for (const [index, level] of Object.entries(city.buildings || {})) {
const proto = this.protoBuildings[index];
if (proto && proto.mechanic === BUILDING_MECHANIC.SPACE_LAUNCH && (Number(level) || 0) > 0) {
holdsCentre = true;
}
}
if (!holdsCentre) return false;
this.satellites.push({
civ,
launchedHours: this.totalHours,
nextRevealHour: this.totalHours + SATELLITE.revealEveryDays * HOURS_PER_DAY,
});
this._addNews({ type: NEWS_SATELLITE, civ });
this._emitChanged();
return true;
},
// Uncovers each satellite's next patch once its cadence comes round.
_tickSatellites() {
if (!this.satellites || this.satellites.length === 0) return;
for (const satellite of this.satellites) {
let guard = 0;
while (satellite.nextRevealHour <= this.totalHours && guard < 64) {
this._revealSatelliteChunk(satellite.civ);
satellite.nextRevealHour += SATELLITE.revealEveryDays * HOURS_PER_DAY;
guard += 1;
}
}
},
// Adds a random patch of the world to `civ`'s explored set, the same set the
// fog of war is drawn from.
_revealSatelliteChunk(civ) {
const explored = this.explored.get(civ);
if (!explored) return;
const pool = this.landCells.length > 0 ? this.landCells : Object.keys(this.tiles).map(parseKey);
if (pool.length === 0) return;
const centre = pool[Math.floor(this._random() * pool.length)];
const queue = [{ x: centre.x, y: centre.y, d: 0 }];
const seen = new Set([key(centre.x, centre.y)]);
while (queue.length > 0) {
const node = queue.shift();
const k = key(node.x, node.y);
if (this.tiles[k]) explored.add(k);
if (node.d >= SATELLITE.revealRadius) continue;
for (const neighbour of this.topology.neighbours(node.x, node.y)) {
const nk = key(neighbour.x, neighbour.y);
if (seen.has(nk)) continue;
seen.add(nk);
queue.push({ x: neighbour.x, y: neighbour.y, d: node.d + 1 });
}
}
this._visibilityDirty = true;
},
// ---------------------------------------------------------- secrets ------
// A newly raised enrichment centre may be noticed by foreign intelligence.
_warnOfEnrichment(civ) {
if (this._random() >= ESPIONAGE.enrichmentWarningChance) return;
this._addNews({ type: NEWS_ENRICHMENT, civ });
},
_isSpy(unit) {
const proto = this.unitProto(unit);
return !!(proto && proto.spy);
},
};