427 lines
15 KiB
JavaScript
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);
|
|
},
|
|
};
|