Split shared data, game state and UI into modules

This commit is contained in:
2026-09-17 22:05:53 +02:00
parent 4e2eae3306
commit 6a4ce1ca5f
56 changed files with 7935 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+1
View File
@@ -0,0 +1 @@
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><linearGradient x1="0" x2="0" y1="0" y2="1" id="lorc-crossed-sabres-gradient-1"><stop offset="0%" stop-color="#f5a623" stop-opacity="1"></stop><stop offset="100%" stop-color="#d0021b" stop-opacity="1"></stop></linearGradient></defs><g class="" style="" transform="translate(0,0)"><path d="M27.084 18.248C-17.903 146.478 143.15 277.92 314.496 381.074c-4.645 13.767-5.585 27.628-3.394 40.635 4.44 26.355 20.974 48.997 42.86 62.425 21.884 13.428 49.776 17.57 75.645 5.765 25.87-11.804 48.69-38.923 62.737-84.654l-17.865-5.488c-13 42.318-32.806 64.094-52.63 73.14-19.825 9.047-40.69 5.998-58.116-4.693-17.425-10.69-30.75-29.095-34.205-49.6-3.455-20.507 2.232-43.318 24.677-65.218 20.743-20.24 32.068-41.615 30.434-61.24l-18.622 1.552c.74 8.89-4.35 22.76-16.684 37.486C222.057 230.8 73.838 128.622 27.084 18.248zm458.05 0C451.34 98.03 364.527 173.53 270.93 247.166c19.492 15.878 39.56 31.622 59.195 45.012 110.756-84.836 187.878-180.243 155.01-273.93zM127.58 292.146c-1.634 19.626 9.69 41 30.434 61.24 22.445 21.9 28.132 44.712 24.677 65.218-3.455 20.506-16.78 38.91-34.206 49.6-17.425 10.692-38.29 13.74-58.115 4.694-19.825-9.046-39.632-30.822-52.63-73.14l-17.865 5.488c14.046 45.73 36.867 72.85 62.736 84.654 25.87 11.805 53.763 7.663 75.648-5.765 21.885-13.428 38.42-36.07 42.86-62.426 2.19-13.005 1.25-26.863-3.393-40.628 13.986-8.42 27.905-17.022 41.648-25.803l-56.967-39.387c-6.55 5.103-13.063 10.2-19.52 15.293C150.55 316.46 145.46 302.59 146.2 293.7l-18.622-1.554zm18.1 73.614c-26.1 8.6-62.087 36.255-77.104 60.324 4.948 8.63 10.393 15.223 16.05 20.14 25.846-8.953 59.85-37.406 74.733-60.257-3.007-6.6-7.454-13.386-13.68-20.207zm220.863 0c-6.225 6.822-10.67 13.61-13.68 20.21 14.886 22.85 48.89 51.3 74.736 60.255 5.656-4.918 11.1-11.51 16.05-20.14-15.018-24.07-51.004-51.724-77.105-60.325z" fill="url(#lorc-crossed-sabres-gradient-1)" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="M18.78 25.97c2.6 25.278 9.316 48.72 19.532 69.75l-21.218-6.564L73.53 146.72l-.843-1.782c16.737 17.29 36.797 31.38 59.438 41.593-13.546-18.845-21.597-41.677-22.03-69.28 27.78.35 50.396 8.56 69.25 22.063-.627-1.4-1.284-2.776-1.94-4.157l.72.625-34.438-86.655-2.53 33.844c-30.974-31.598-73.044-52.084-122.376-57zm236.814 31.593L217.22 160.875c-30.663 7.52-54.566 32.236-60.907 63.344l-.188-.22-106.563 39.563 74.313 74.312 93-8.03.375-.033 23.03-2.468-77.31-94.938 13.28-13.28 89.406 109.75 10.875 13.374-17.124 1.844-28.22 3.03a448.92 448.92 0 0 0 47.22 47.907c7.62-56.784 52.175-102.042 108.625-110.717a443.59 443.59 0 0 0-47.75-46.688l-2.78 26.125-1.844 17.125L321.28 270l-107.874-88.03 13.25-13.25 93.094 75.905 2.22-20.844 7.905-91.936-74.28-74.28zM401.53 301.625a111.011 111.011 0 0 0-25.436 4.156l13.875 16.908a116.11 116.11 0 0 1 23.467-5.282c-3.863-5.344-7.814-10.62-11.906-15.78zm-43.78 11.25a110.843 110.843 0 0 0-20.5 13.063l15.75 17.28a117.217 117.217 0 0 1 19.125-12.812l-14.375-17.53zm68 22.438a98.919 98.919 0 0 0-22.97 3.03l14.5 17.688c6.108-1.82 12.447-3.043 18.97-3.686-3.387-5.76-6.865-11.445-10.5-17.03zm-102.03 3.53a110.815 110.815 0 0 0-16.845 25.344l17.313 14.125c4.28-7.958 9.44-15.37 15.375-22.093l-15.844-17.376zm60.467 6.282c-6.67 3.247-12.908 7.267-18.625 11.875l16.282 17.875a102.325 102.325 0 0 1 17.312-11.47l-14.97-18.28zm-32 24.938a98.164 98.164 0 0 0-13.25 20.28l17.97 14.688a102.341 102.341 0 0 1 11.624-17l-16.342-17.967zm94.094.218v.22c-45.915 0-82.96 36.985-83.06 82.875a444.741 444.741 0 0 0 127.06 44.313c-8.284-45.328-23.3-88.145-44-127.407zM299.94 382.626a111.035 111.035 0 0 0-3.97 26.938 448.277 448.277 0 0 0 15.063 11.343c.93-8.58 2.75-16.88 5.44-24.812l-16.533-13.47zm32.156 26.25a98.892 98.892 0 0 0-3.094 24.375 447.679 447.679 0 0 0 16.063 9.938 101.51 101.51 0 0 1 4.218-20.282l-17.186-14.03z" fill="#c9c9c9" stroke="#000000" stroke-opacity="1" stroke-width="5" fill-opacity="1"></path></g></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+59
View File
@@ -0,0 +1,59 @@
// The game feed: a chat-like column of short-lived messages pinned to the
// lower-left corner of the map. Newest lines sit at the bottom and each line
// retires on its own, so the map is never permanently obstructed.
export const FEED_TTL_MS = 4500;
export const FEED_ALERT_TTL_MS = 8000;
const FEED_FADE_MS = 400;
export class GameFeed {
constructor($container, options = {}) {
this.$container = $container;
this.ttl = options.ttl !== undefined ? options.ttl : FEED_TTL_MS;
this.alertTtl = options.alertTtl !== undefined ? options.alertTtl : FEED_ALERT_TTL_MS;
this.fade = options.fade !== undefined ? options.fade : FEED_FADE_MS;
this._timers = new Set();
}
// Appends one line at the bottom of the feed. `alert` marks a message the
// player must not miss (being declared war on) and lets it linger longer.
// A negative `ttl` keeps the line until `clear` or a later retirement.
push(text, { alert = false, ttl } = {}) {
if (!this.$container || !this.$container.length) return null;
const life = ttl !== undefined ? ttl : alert ? this.alertTtl : this.ttl;
const $line = $("<div class='feed-line'></div>").text(text);
if (alert) $line.addClass("feed-alert");
this.$container.append($line);
if (life >= 0) {
const timer = setTimeout(() => {
this._timers.delete(timer);
this._retire($line);
}, life);
this._timers.add(timer);
}
return $line;
}
lineCount() {
return this.$container ? this.$container.children(".feed-line").length : 0;
}
_retire($line) {
$line.addClass("leaving");
if (this.fade <= 0) {
$line.remove();
return;
}
const timer = setTimeout(() => {
this._timers.delete(timer);
$line.remove();
}, this.fade);
this._timers.add(timer);
}
clear() {
for (const timer of this._timers) clearTimeout(timer);
this._timers.clear();
if (this.$container) this.$container.empty();
}
}
+536
View File
@@ -0,0 +1,536 @@
// The game screen's HUD: the top and bottom panels, selection and stack menus,
// hover tooltips, the pillage forecast and the modal wiring. These methods run
// on the GameScreen instance (composed in ../game_screen.js) and only read the
// snapshot and forward orders.
import { describeNews } from "../modals.js";
import { compact, groupDigits, improvements as fmtImprovements } from "../../../shared/text_format.js";
import {
CIVILISATIONS,
TRAINABLE_UNIT_IDS,
BUILDINGS,
GOVERNMENTS,
TECHNOLOGIES,
NEWS_WAR,
pillageSteps,
} from "../../../shared/data.js";
import { defenseFactors, unitDefense } from "../../../shared/rules.js";
import { dateString } from "../../../shared/game_clock.js";
import { DEFAULT_UNIT_SPEED } from "../../../shared/game_state/constants.js";
export const panelMethods = {
_bindHud() {
$("#city-train").on("click", () => this._openTrainModal());
$("#city-build").on("click", () => this._openBuildingsModal());
$("#civ-flag").on("click", () => this._openNationModal());
$("#news-button").on("click", () => this._openNewsModal());
this.trainModal.onTrain = (cityId, protoIndex) =>
this.sendOrder({ type: "train", city: cityId, proto: protoIndex });
this.trainModal.onCancel = (cityId, index) =>
this.sendOrder({ type: "cancel_train", city: cityId, index });
this.buildingsModal.onBuild = (cityId, index) =>
this.sendOrder({ type: "build", city: cityId, building: index });
this.buildingsModal.onDemolish = (cityId, index) =>
this.sendOrder({ type: "demolish", city: cityId, building: index });
this.nationModal.onGovernment = (index) =>
this.sendOrder({ type: "government", government: index });
this.nationModal.onTechnology = (index) =>
this.sendOrder({ type: "technology", technology: index });
this.nationModal.onDeclareWar = (target) =>
this.sendOrder({ type: "declare_war", target });
$("#unit-attack")
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
.on("mouseleave", () => this._hideStatTooltip());
$("#unit-defense")
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
.on("mouseleave", () => this._hideStatTooltip());
$("#unit-pillage")
.on("click", () => this._pillageSelected())
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
.on("mouseleave", () => this._hideStatTooltip());
// A click anywhere outside the stack menu dismisses it.
$(document).on("mousedown.stackmenu", (event) => {
if (!this.$stackMenu.hasClass("hidden") && !$(event.target).closest("#stack-menu").length) {
this._hideStackMenu();
}
});
$(window).on("keydown.game", (event) => this._onKey(event));
},
// A small hover tooltip anchored above a stat, fed by its data-tooltip text.
_showStatTooltip($stat) {
const text = $stat.attr("data-tooltip");
if (!text) return;
this._hoveredStat = $stat;
this.$statTooltip.text(text).removeClass("hidden");
const offset = $stat.offset() || { left: 0, top: 0 };
const width = this.$statTooltip.outerWidth() || 0;
const height = this.$statTooltip.outerHeight() || 0;
this.$statTooltip.css({
left: `${Math.max(8, offset.left + $stat.outerWidth() / 2 - width / 2)}px`,
top: `${Math.max(8, offset.top - height - 8)}px`,
});
},
_hideStatTooltip() {
this._hoveredStat = null;
this.$statTooltip.addClass("hidden");
},
// ------------------------------------------------------- selection ------
// Selects the given unit ids (already filtered to our own), refreshing the
// map highlight and the bottom panel. A single id is a normal selection; an
// empty list clears; several ids form a stack order.
_selectUnits(ids) {
const units = [];
for (const id of ids) {
const unit = this._unit(id);
if (unit && unit.civ === this.localCiv) units.push(unit);
}
this.selectedUnitIds = units.map((unit) => unit.id);
this.selectedCityId = 0;
this.map.setSelectedUnits(this.selectedUnitIds);
this._hideStackMenu();
this._showUnits(units);
},
_selectedUnits() {
return this.selectedUnitIds.map((id) => this._unit(id)).filter(Boolean);
},
_openStackMenu(coords, ids, clientX, clientY) {
const units = [];
for (const id of ids) {
const unit = this._unit(id);
if (unit && unit.civ === this.localCiv) units.push(unit);
}
if (units.length === 0) return;
const $menu = this.$stackMenu.empty();
const selection = this._selectedUnits();
if (selection.length > 0) {
const $move = $("<button class='stack-item stack-move'></button>").text("Move here");
$move.on("click", () => {
this._hideStackMenu();
this._orderMove(selection, coords);
});
$menu.append($move);
}
for (const unit of units) {
const proto = this.protoUnits[unit.proto];
const $item = $("<button class='stack-item'></button>")
.text(`${proto ? proto.name : "Unit"} — ${Math.round(unit.hp)}/${unit.maxHp} HP`);
$item.on("click", () => this._selectUnits([unit.id]));
$menu.append($item);
}
const $all = $("<button class='stack-item stack-all'></button>").text(
`Select all (${units.length})`
);
$all.on("click", () => this._selectUnits(units.map((unit) => unit.id)));
$menu.append($all);
$menu.removeClass("hidden").css({ left: `${clientX}px`, top: `${clientY}px` });
},
_hideStackMenu() {
if (this.$stackMenu && this.$stackMenu.length) this.$stackMenu.addClass("hidden");
},
// The slowest member of a group sets the pace. The fallback matches the
// server's DEFAULT_UNIT_SPEED so prediction cannot disagree with the
// authoritative movement cost.
_groupSpeed(units) {
let speed = Infinity;
for (const unit of units) {
const proto = this.protoUnits[unit.proto];
const value = proto && proto.speed ? proto.speed : DEFAULT_UNIT_SPEED;
speed = Math.min(speed, value);
}
return Number.isFinite(speed) ? speed : null;
},
async _groupMovePlan(units, coords) {
const speed = units.length > 1 ? this._groupSpeed(units) : null;
const targets = [];
const routes = new Map();
for (const unit of units) {
const route = await this.map.planMove(unit, coords, speed);
if (!route || route.length < 2) continue;
routes.set(unit.id, route);
if (!this._isMilitaryUnit(unit)) continue;
for (const point of route) {
const owner = this.map.territory.get(`${point.x},${point.y}`);
if (owner === undefined || owner < 0 || owner === unit.civ) continue;
if (this._isAtWar(unit.civ, owner)) continue;
if (!targets.includes(owner)) targets.push(owner);
}
}
return { targets, speed, routes };
},
// ------------------------------------------------------- pillage -------
// What the selected units would remove, gain and heal by pillaging the tile
// they stand on, or null when they cannot pillage.
_pillageForecast(units) {
if (units.length === 0) return null;
const coords = units[0].coords;
const sameTile = units.every((unit) => unit.coords[0] === coords[0] && unit.coords[1] === coords[1]);
if (!sameTile) return null;
const owner = this._territoryOwner.has(`${coords[0]},${coords[1]}`)
? this._territoryOwner.get(`${coords[0]},${coords[1]}`)
: -1;
if (owner < 0 || owner === this.localCiv) return null;
// A unit in combat cannot pillage.
if (this._battles.has(`${coords[0]},${coords[1]}`)) return null;
const population = this._population.get(`${coords[0]},${coords[1]}`) || 0;
const { steps, gdpMultiplier } = pillageSteps(population, units.length);
const ordered = units.slice().sort((a, b) => a.hp / a.maxHp - b.hp / b.maxHp);
const healed = ordered.map((unit, index) =>
Math.min(unit.maxHp, unit.hp + steps[index].heal) - unit.hp
);
return { population, steps, gdpMultiplier, healed };
},
_updatePillageButton(units) {
const $button = $("#unit-pillage");
const forecast = this._pillageForecast(units);
if (!forecast) {
$button.addClass("hidden").removeAttr("data-tooltip");
return;
}
const removed = forecast.population - forecast.steps[forecast.steps.length - 1].remaining;
const share = forecast.population > 0 ? (removed / forecast.population) * 100 : 0;
const heals = forecast.healed.map((hp) => `+${Math.round(hp)}`).join(", ");
$button
.removeClass("hidden")
.attr(
"data-tooltip",
`Removes ${groupDigits(Math.round(removed))} people (${share.toFixed(0)}%) · ` +
`GDP/capita ×${forecast.gdpMultiplier.toFixed(2)} · heals ${heals} HP`
);
},
_isMilitaryUnit(unit) {
const proto = this.protoUnits[unit.proto];
return !!(proto && proto.military);
},
_isAtWar(a, b) {
if (a < 0 || b < 0 || a === b) return false;
return this._wars.has(a < b ? `${a}:${b}` : `${b}:${a}`);
},
_civName(index) {
const civ = this.civilisations[index];
return civ ? civ.name : `Nation ${index + 1}`;
},
_civDemonym(index) {
const civ = this.civilisations[index];
return civ && civ.demonym ? civ.demonym : this._civName(index);
},
// ------------------------------------------------------- top panel -----
_updateTopPanel(state) {
const civ = this.civilisations[this.localCiv];
if (civ) {
$("#civ-name").text(civ.name);
const flag = civ.flag || (CIVILISATIONS.find((c) => c.id === civ.id) || {}).flag;
if (flag) $("#civ-flag-img").attr("src", `assets/flags/${flag}`);
}
$("#player-name").text(this.config.playerName || (this.network.players[this.network.localPlayerId] || {}).name || "");
$("#admin-label").toggle(this.network.isAdmin);
$("#stat-date").text(dateString(state.totalHours));
const stats = state.viewerStats || {};
$("#stat-pop").text(compact(stats.population || 0));
$("#stat-gdp").text(`€${compact(stats.gdp || 0)}`);
$("#stat-gdppc").text(`€${compact(stats.gdpPerCapita || 0)}`);
$("#stat-budget").text(`€${compact(stats.budget || 0)}`);
},
_updateBottomPanel() {
if (this.selectedUnitIds.length > 0) {
this._showUnits(this._selectedUnits());
} else if (this.selectedCityId) {
const city = this._city(this.selectedCityId);
if (city) this._showCity(city);
}
},
// Shows the bottom panel for one or many selected units. A stack shows its
// size and combined health, plus the pillage action when it can use it.
_showUnits(units) {
if (units.length === 0) {
$("#unit-info").addClass("hidden");
this._hideStatTooltip();
this._refreshBottomVisibility();
return;
}
if (units.length === 1) {
this._showUnit(units[0]);
return;
}
const hp = units.reduce((sum, unit) => sum + unit.hp, 0);
const maxHp = units.reduce((sum, unit) => sum + unit.maxHp, 0);
$("#unit-name").text(`${units.length} units selected`);
$("#unit-attack, #unit-defense").addClass("hidden");
$("#unit-health-fill").css("width", `${maxHp > 0 ? Math.min(100, (hp / maxHp) * 100) : 0}%`);
$("#unit-health-label").text(`${Math.round(hp)} / ${Math.round(maxHp)} HP`);
this._updatePillageButton(units);
$("#tile-info, #city-info").addClass("hidden");
$("#unit-info").removeClass("hidden");
this._refreshBottomVisibility();
},
_showUnit(unit) {
if (!unit) {
$("#unit-info").addClass("hidden");
this._hideStatTooltip();
this._refreshBottomVisibility();
return;
}
const proto = this.protoUnits[unit.proto];
$("#unit-name").text(proto ? proto.name : "Unit");
$("#unit-attack, #unit-defense").removeClass("hidden");
this._showUnitStats(unit, proto);
const ratio = unit.maxHp > 0 ? (unit.hp / unit.maxHp) * 100 : 0;
$("#unit-health-fill").css("width", `${Math.max(0, Math.min(100, ratio))}%`);
$("#unit-health-label").text(`${Math.round(unit.hp)} / ${Math.round(unit.maxHp)} HP`);
this._updatePillageButton([unit]);
$("#tile-info, #city-info").addClass("hidden");
$("#unit-info").removeClass("hidden");
this._refreshBottomVisibility();
},
// Attack and defense of the unit where it stands, with the hovering tooltips
// listing each factor that shaped the number.
_showUnitStats(unit, proto) {
const coords = { x: unit.coords[0], y: unit.coords[1] };
const tile = this.map.getTile(coords);
const hasCity = ((this.snapshot && this.snapshot.cities) || []).some(
(city) => city.coords[0] === coords.x && city.coords[1] === coords.y
);
const baseAttack = proto && typeof proto.attack === "number" ? proto.attack : 0;
const baseDefense = proto && typeof proto.defense === "number" ? proto.defense : 0;
$("#unit-attack")
.text(`Attack ${Math.round(baseAttack)}`)
.attr("data-tooltip", `Base ${Math.round(baseAttack)}`);
const factors = defenseFactors(tile, hasCity);
const defense = unitDefense(baseDefense, tile, hasCity);
const parts = [`Base ${Math.round(baseDefense)}`];
for (const factor of factors) {
parts.push(`${factor.label} +${Math.round(baseDefense * factor.bonus)}`);
}
$("#unit-defense")
.text(`Defense ${Math.round(defense)}`)
.attr("data-tooltip", parts.join(", "));
// Keep an open tooltip in step with the unit it describes across snapshots.
if (this._hoveredStat && !this.$statTooltip.hasClass("hidden")) {
const text = this._hoveredStat.attr("data-tooltip");
if (text) this.$statTooltip.text(text);
} else {
this._hideStatTooltip();
}
},
_showCity(city) {
if (!city) {
$("#city-info").addClass("hidden");
this._refreshBottomVisibility();
return;
}
$("#city-name").text(city.name);
$("#city-population").text(`Population: ${groupDigits(city.population)}`);
$("#city-improvements").text(`Improvements: ${fmtImprovements(city.improvements)}`);
$("#tile-info, #unit-info").addClass("hidden");
$("#city-info").removeClass("hidden");
this._refreshBottomVisibility();
},
_showTile(coords) {
const k = `${coords.x},${coords.y}`;
const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1;
const civ = this.civilisations[owner];
const tile = this.map.getTile(coords);
const city = (this.snapshot.cities || []).find(
(c) => c.coords[0] === coords.x && c.coords[1] === coords.y
);
$("#tile-coords").text(`Tile (${coords.x}, ${coords.y})`);
$("#tile-owner").text(`Owner: ${civ ? civ.name : "Unclaimed"}`);
$("#tile-terrain").text(`Terrain: ${tile ? tile.terrainType : "Unknown"}`);
$("#tile-population").text(`Population: ${groupDigits(this._population.get(k) || 0)}`);
$("#tile-improvements").text(`Improvements: ${fmtImprovements(city ? city.improvements : [])}`);
$("#city-info, #unit-info").addClass("hidden");
$("#tile-info").removeClass("hidden");
this._refreshBottomVisibility();
},
_refreshBottomVisibility() {
const visible =
!$("#tile-info").hasClass("hidden") ||
!$("#city-info").hasClass("hidden") ||
!$("#unit-info").hasClass("hidden");
$("#bottom-panel").toggleClass("hidden", !visible);
},
_unit(id) {
return (this.snapshot.units || []).find((u) => u.id === id) || null;
},
_city(id) {
return (this.snapshot.cities || []).find((c) => c.id === id) || null;
},
// -------------------------------------------------------------- modals ---
_openTrainModal() {
const city = this._city(this.selectedCityId);
if (!city) return;
const trainable = TRAINABLE_UNIT_IDS.map((id) => {
const index = this.protoUnits.findIndex((p) => p.id === id);
return index >= 0 ? { ...this.protoUnits[index], serverIndex: index } : null;
}).filter(Boolean);
this.buildingsModal.close();
this.trainModal.show(
{ id: city.id, name: city.name },
(this.snapshot.viewerStats || {}).gdp || 0,
trainable,
this._trainingQueue(city.id)
);
this._updateTrainModal();
},
_trainingQueue(cityId) {
return (this.snapshot.training || {})[cityId] || [];
},
_updateTrainModal() {
if (!this.snapshot || !this.selectedCityId) return;
if (!this.trainModal.isShowing(this.selectedCityId)) {
this._trainingCityId = 0;
return;
}
const queue = this._trainingQueue(this.selectedCityId);
this.trainModal.renderQueue(queue);
const entry = queue[0];
if (!entry) {
if (this._trainingCityId === this.selectedCityId) {
this.trainModal.endTraining();
this._trainingCityId = 0;
}
return;
}
const proto = this.protoUnits[entry.protoIndex];
const name = proto ? proto.name : "unit";
if (this._trainingCityId !== this.selectedCityId) {
this._trainingCityId = this.selectedCityId;
this.trainModal.beginTraining(name, entry.totalHours, entry.elapsedHours);
} else {
this.trainModal.setTrainingProgress(name, entry.elapsedHours, entry.totalHours);
}
},
_openBuildingsModal() {
const city = this._city(this.selectedCityId);
if (!city) return;
this.trainModal.close();
this.buildingsModal.show(
{ id: city.id, name: city.name },
(this.snapshot.viewerStats || {}).gdp || 0,
(this.snapshot.viewerStats || {}).budget || 0,
BUILDINGS,
this._cityBuildings.get(city.id) || {}
);
},
_updateBuildingsModal() {
if (!this.snapshot || !this.buildingsModal.isShowing(this.selectedCityId)) return;
const stats = this.snapshot.viewerStats || {};
this.buildingsModal.refresh(
stats.gdp || 0,
stats.budget || 0,
this._cityBuildings.get(this.selectedCityId) || {}
);
},
_openNationModal() {
const stats = this.snapshot.viewerStats || {};
this.nationModal.show(
GOVERNMENTS,
stats.government || 0,
TECHNOLOGIES,
this._researched,
stats.gdp || 0,
stats.budget || 0,
stats.research || 0,
this._finances(stats),
this._diplomacyInfo(),
this._conflicts
);
},
_updateNationModal() {
if (!this.snapshot || !this.nationModal.isShowing()) return;
const stats = this.snapshot.viewerStats || {};
this.nationModal.refresh(
stats.government || 0,
this._researched,
stats.gdp || 0,
stats.budget || 0,
stats.research || 0,
this._finances(stats),
this._diplomacyInfo(),
this._conflicts
);
},
_diplomacyInfo() {
return {
civs: this.civilisations,
localCiv: this.localCiv,
relations: (this.snapshot && this.snapshot.diplomacy) || [],
stats: (this.snapshot && this.snapshot.civStats) || [],
};
},
_openNewsModal() {
this.newsModal.show(this._news, this.civilisations);
},
_updateNewsModal() {
if (!this.snapshot || !this.newsModal.isShowing()) return;
this.newsModal.refresh(this._news, this.civilisations);
},
// Pops newly arrived news into the corner feed. The first snapshot only
// records what is already known, so history never floods the screen.
_updateFeed(news) {
if (this._seenNews === null) {
this._seenNews = new Set(news.map((entry) => entry.id));
return;
}
for (const entry of news) {
if (this._seenNews.has(entry.id)) continue;
this._seenNews.add(entry.id);
this._pushNews(entry);
}
},
_pushNews(entry) {
if (entry.type === NEWS_WAR && entry.target === this.localCiv) {
this.feed.push(`${this._civName(entry.actor)} has declared war on you!`, { alert: true });
return;
}
this.feed.push(describeNews(entry, this.civilisations));
},
_finances(stats) {
return stats.breakdown || null;
},
};
+43
View File
@@ -0,0 +1,43 @@
// Tuning constants for the world map view.
export const RESYNC_HOURS = 0.75;
export const PREDICTION_GRACE_HOURS = 2.0;
export const DRAG_THRESHOLD = 5;
export const ZOOM_MIN = 0.2;
export const ZOOM_MAX = 2.0;
// A battle spreads the two stacks to either side of the tile centre, with the
// battle icon between them. Side offset is in pixels; step is the spacing
// between the (half-size) units of one stack.
export const BATTLE_SIDE_OFFSET = 15;
export const BATTLE_STACK_STEP = 9;
// The zoomed-out overview groups this many tiles per side into one block, so a
// full world is a few hundred flat elements instead of tens of thousands of
// textured ones.
export const OVERVIEW_BLOCK = 4;
// The detailed view is built from square blocks of tiles. Only the blocks that
// intersect the viewport (plus CHUNK_MARGIN blocks of slack on every side) are
// materialised, so panning costs the same no matter how big the map is. Tiles
// are pooled and recycled as blocks leave the view.
export const CHUNK_SIZE = 8;
export const CHUNK_MARGIN = 1;
// Built chunks are kept detached in an LRU cache so panning back over ground
// already visited reattaches them instead of rebuilding hundreds of tiles. The
// cap bounds how many detached tile nodes can be held at once.
export const CHUNK_CACHE_LIMIT = 160;
// Flat colours used by the simplified overview, keyed by terrain type. The
// full-resolution view keeps the textured hexes; this is only the LOD stand-in.
export const OVERVIEW_COLOURS = {
Forest: "#2f6b3a",
Hills: "#7a6a44",
Mountain: "#7d7469",
Desert: "#c2b280",
Ice: "#dcecf5",
Land: "#4f8a4f",
Sea: "#1b3f6b",
};
export const OVERVIEW_SEA = "#1b3f6b";
export const OVERVIEW_FOG = "#04040a";
+405
View File
@@ -0,0 +1,405 @@
// Turning a server snapshot into DOM: territory borders, fog, cities and units,
// plus the stack and battle layout of unit markers.
import { key, mapToLocal, parseKey } from "../../../shared/hex.js";
import { statusById } from "../../../shared/data.js";
import { BATTLE_SIDE_OFFSET, BATTLE_STACK_STEP } from "./constants.js";
import { hashEntries, pathProgress, nearestCorners, inwardOffset } from "./utils.js";
export const entityMethods = {
setCatalogue(civilisations, protoUnits) {
this.civilisations = civilisations;
this.protoUnits = protoUnits;
},
applySnapshot(state) {
this.ensureTerrain(state.seed);
this.localCiv = state.viewer;
this._applyTerritory(state.territory);
this._applyKnowledge(state.explored, state.visible);
this._applyBattles(state.battles);
this._syncCities(state.cities);
this._syncUnits(state.units);
if (!this._centered) this._centerOnCapital(state.cities);
this.animate(0);
},
// Mirrors the battlefields in the snapshot: which tile is being fought over,
// and which side defends it. Also keeps one battle marker element per tile.
_applyBattles(entries) {
this._battles = new Map();
const seen = new Set();
for (const entry of entries || []) {
const k = key(entry[0], entry[1]);
seen.add(k);
this._battles.set(k, { defender: entry[2], attacker: entry[3] });
let marker = this._battleViews.get(k);
if (!marker) {
marker = document.createElement("div");
marker.className = "battle-marker";
const icon = document.createElement("img");
icon.src = "assets/icon_battle.svg";
icon.alt = "Battle";
marker.appendChild(icon);
this.$entities[0].appendChild(marker);
this._battleViews.set(k, marker);
}
const local = mapToLocal(entry[0], entry[1]);
marker._localX = local.x;
marker._localY = local.y;
}
for (const [k, marker] of Array.from(this._battleViews)) {
if (seen.has(k)) continue;
marker.remove();
this._battleViews.delete(k);
}
},
_applyTerritory(entries) {
const signature = hashEntries(entries);
if (signature === this._territorySignature) return;
this._territorySignature = signature;
this.territory = new Map();
for (const entry of entries) {
this.territory.set(key(entry[0], entry[1]), entry[2]);
}
this._renderBorders();
this._drawOverview();
},
// Draws a thick tinted segment along every edge shared with a different owner
// (or the open sea). Each segment is
// nudged towards its owner's tile by half its width so two neighbours' borders
// sit side by side instead of painting over each other on the shared edge.
// Only the chunks currently on screen are rebuilt.
_renderBorders() {
if (!this._chunks) return;
for (const rec of this._chunks.values()) this._buildBorders(rec);
if (this._chunkCache) for (const rec of this._chunkCache.values()) rec.borderDirty = true;
},
_buildBorders(rec) {
rec.borderDirty = false;
this._recycleLayer(rec.borderEl, this._borderPool);
const fragment = document.createDocumentFragment();
for (const k of rec.tileKeys) {
const owner = this.territory.get(k);
if (owner === undefined) continue;
const civ = this.civilisations[owner];
if (!civ) continue;
const coords = parseKey(k);
const center = mapToLocal(coords.x, coords.y);
const localCenter = { x: center.x - rec.x, y: center.y - rec.y };
for (const neighbour of this.topology.neighbours(coords.x, coords.y)) {
const nk = key(neighbour.x, neighbour.y);
if (this.territory.has(nk) && this.territory.get(nk) === owner) continue;
const delta = this.topology.pixelDelta(coords, neighbour);
const target = { x: localCenter.x + delta.x, y: localCenter.y + delta.y };
const [a, b] = nearestCorners(localCenter, target);
const width = 6;
const inward = inwardOffset(a, b, localCenter, width / 2);
const el = this._acquireHex(this._borderPool);
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.hypot(dx, dy);
const angle = Math.atan2(dy, dx);
el.className = "border-seg";
el.style.left = `${a.x}px`;
el.style.top = `${a.y - width / 2}px`;
el.style.width = `${length}px`;
el.style.height = `${width}px`;
el.style.background = civ.primaryColour;
el.style.transform = inward === 0
? `rotate(${angle}rad)`
: `rotate(${angle}rad) translateY(${inward}px)`;
el.style.borderRadius = `${width / 2}px`;
fragment.appendChild(el);
}
}
rec.borderEl.appendChild(fragment);
},
_applyKnowledge(exploredEntries, visibleEntries) {
// `explored` is append-only, so a length change is a cheap exact test for it.
// Visibility changes membership without changing size, so it is hashed. Both
// matter to fog, so either change rebuilds the sets and the on-screen fog.
const visibleSignature = hashEntries(visibleEntries);
const changed =
exploredEntries.length !== this._exploredCount ||
visibleSignature !== this._visibleSignature;
if (!changed) return;
this._exploredCount = exploredEntries.length;
this._visibleSignature = visibleSignature;
this.explored = new Set(exploredEntries.map((e) => key(e[0], e[1])));
this.visible = new Set(visibleEntries.map((e) => key(e[0], e[1])));
this._refreshFog();
this._drawOverview();
},
_syncCities(cities) {
const seen = new Set();
this._cityCiv = new Map();
for (const data of cities) {
const id = data.id;
seen.add(id);
let view = this._cityViews.get(id);
if (!view) {
view = this._createCityView(data);
this._cityViews.set(id, view);
}
const coords = { x: data.coords[0], y: data.coords[1] };
this._cityCiv.set(key(coords.x, coords.y), data.civ);
const local = mapToLocal(coords.x, coords.y);
view._localX = local.x;
view._localY = local.y;
const wrappedX = local.x + this._wrapShift(local.x);
view.style.left = `${wrappedX}px`;
view.style.top = `${local.y}px`;
const owned = data.civ === this.localCiv;
const label = view._label;
label.textContent = data.name;
label.classList.toggle("capital", !!data.isCapital);
label.style.left = `${wrappedX}px`;
label.style.top = `${local.y}px`;
const marker = view.querySelector(".marker");
marker.style.background = this.civilisations[data.civ]
? this.civilisations[data.civ].primaryColour
: "#444";
marker.style.cursor = owned ? "pointer" : "default";
view.dataset.owned = owned ? "1" : "0";
this._syncStatusIcons(view.querySelector(".city-statuses"), data.statuses || []);
// Foreign cities are only shown when their tile has been explored.
const k = key(coords.x, coords.y);
const shown = owned || this.explored.has(k);
view.style.display = shown ? "" : "none";
label.style.display = shown ? "" : "none";
}
for (const [id, view] of Array.from(this._cityViews)) {
if (seen.has(id)) continue;
view.remove();
if (view._label) view._label.remove();
this._cityViews.delete(id);
}
},
_createCityView(data) {
const view = document.createElement("div");
view.className = "entity city";
const marker = document.createElement("div");
marker.className = "marker";
marker.style.backgroundImage = "url(assets/icon_modern_city.svg)";
const statuses = document.createElement("div");
statuses.className = "city-statuses";
view.appendChild(marker);
view.appendChild(statuses);
marker.addEventListener("click", (event) => {
event.stopPropagation();
if (view.dataset.owned === "1") this.onCitySelected(data.id);
});
this.$entities[0].appendChild(view);
const label = document.createElement("div");
label.className = "city-label";
this.$labels[0].appendChild(label);
view._label = label;
return view;
},
_syncUnits(units) {
const seen = new Set();
// Store every unit's authoritative data first, so motion prediction can
// see where all of them are before any single one is advanced.
for (const data of units) {
seen.add(data.id);
this._unitData.set(data.id, data);
}
for (const data of units) {
const id = data.id;
let view = this._unitViews.get(id);
if (!view) {
view = this._createUnitView(data);
this._unitViews.set(id, view);
}
const proto = this.protoUnits[data.proto];
const civ = this.civilisations[data.civ];
view.querySelector(".body").style.background = civ ? civ.primaryColour : "#666";
view.querySelector(".body img").src = proto ? `assets/${proto.icon}` : "assets/person.png";
const flag = view.querySelector(".unit-flag");
if (flag) {
if (civ && civ.flag) {
flag.src = `assets/flags/${civ.flag}`;
flag.style.display = "";
} else {
flag.style.display = "none";
}
}
view.querySelector(".health .fill").style.width =
`${Math.max(0, Math.min(100, (data.hp / data.maxHp) * 100))}%`;
view.classList.toggle("selected", this._selectedUnitIds.has(id));
this._syncUnitStatuses(view, data);
this._updateUnitMotion(data);
// Foreign units are only shown inside currently visible tiles.
const k = key(data.coords[0], data.coords[1]);
view.style.display = data.civ === this.localCiv || this.visible.has(k) ? "" : "none";
}
for (const [id, view] of Array.from(this._unitViews)) {
if (seen.has(id)) continue;
view.remove();
this._unitViews.delete(id);
this._unitMotion.delete(id);
this._unitData.delete(id);
}
this._assignStackOffsets();
},
// Spreads units sharing a tile around a small circle so every member of a
// stack stays visible and clickable. On a battlefield the two stacks are
// drawn half-size, pushed to opposite sides of the tile with the battle icon
// between them.
// Stacks are drawn as a single icon: the first unit's view stays in place
// and carries a badge with the stack size, while the rest are hidden but kept
// for hit-testing. On a battlefield the two stacks are drawn half-size,
// pushed to opposite sides of the tile with the battle icon between them.
_assignStackOffsets() {
for (const view of this._unitViews.values()) {
view.style.visibility = "";
this._setStackCount(view, 0);
}
const byTile = new Map();
for (const [id, motion] of this._unitMotion) {
const tile = this._currentTile(motion);
const k = key(tile.x, tile.y);
if (!byTile.has(k)) byTile.set(k, []);
byTile.get(k).push(id);
}
for (const [k, ids] of byTile) {
const battle = this._battles.get(k);
if (battle) {
this._assignBattleOffsets(ids, battle);
continue;
}
ids.forEach((id, index) => {
const motion = this._unitMotion.get(id);
if (motion) motion.offset = { x: 0, y: 0 };
const view = this._unitViews.get(id);
if (!view) return;
view.classList.toggle("in-battle", false);
const leader = index === 0;
this._setStackCount(view, leader ? ids.length : 0);
view.style.visibility = leader ? "" : "hidden";
});
}
},
// Writes the stack size into the badge on a unit icon, hiding it for 0 or 1.
_setStackCount(view, count) {
if (!view) return;
const badge = view.querySelector(".unit-count");
if (!badge) return;
if (count > 1) {
badge.textContent = String(count);
badge.style.display = "block";
} else {
badge.textContent = "";
badge.style.display = "none";
}
},
_assignBattleOffsets(ids, battle) {
const sides = new Map();
for (const id of ids) {
const data = this._unitData.get(id);
const side = data && data.civ === battle.attacker ? "attacker" : "defender";
if (!sides.has(side)) sides.set(side, []);
sides.get(side).push(id);
}
for (const [side, members] of sides) {
const sign = side === "attacker" ? 1 : -1;
members.forEach((id, index) => {
const motion = this._unitMotion.get(id);
if (!motion) return;
motion.offset = {
x: sign * BATTLE_SIDE_OFFSET,
y: (index - (members.length - 1) / 2) * BATTLE_STACK_STEP,
};
const view = this._unitViews.get(id);
if (view) {
view.classList.toggle("in-battle", true);
view.dataset.battleSide = side;
}
});
}
},
_currentTile(motion) {
if (motion.path.length >= 2) {
const index = pathProgress(motion).index;
return motion.path[Math.min(index, motion.path.length - 1)];
}
return motion.coords;
},
_createUnitView(data) {
const view = document.createElement("div");
view.className = "entity unit";
const body = document.createElement("div");
body.className = "body";
body.style.background = this.civilisations[data.civ]
? this.civilisations[data.civ].primaryColour
: "#666";
const img = document.createElement("img");
img.alt = "";
body.appendChild(img);
const health = document.createElement("div");
health.className = "health";
const fill = document.createElement("div");
fill.className = "fill";
health.appendChild(fill);
const count = document.createElement("div");
count.className = "unit-count";
count.style.display = "none";
const statuses = document.createElement("div");
statuses.className = "unit-statuses";
const flag = document.createElement("img");
flag.className = "unit-flag";
flag.alt = "";
view.appendChild(flag);
view.appendChild(body);
view.appendChild(health);
view.appendChild(count);
view.appendChild(statuses);
view.addEventListener("mousedown", (event) => {
event.stopPropagation();
if (event.button === 0) this.onUnitSelected(data.id);
});
this.$entities[0].appendChild(view);
return view;
},
// Draws one small icon per active status over the unit. The layout is driven
// by the snapshot's status ids and the shared catalogue, so a new status
// needs only a catalogue entry and an icon file.
_syncUnitStatuses(view, data) {
this._syncStatusIcons(view.querySelector(".unit-statuses"), data.statuses || []);
},
_syncStatusIcons(container, ids) {
if (!container) return;
const signature = ids.join(",");
if (container._statusSignature === signature) return;
container._statusSignature = signature;
container.textContent = "";
for (const id of ids) {
const def = statusById(id);
if (!def) continue;
const icon = document.createElement("img");
icon.className = "status-icon";
icon.src = `assets/${def.icon}`;
icon.alt = def.label;
icon.title = def.description || def.label;
container.appendChild(icon);
}
},
};
+293
View File
@@ -0,0 +1,293 @@
// Camera, zoom/LOD switching, pointer input, selection and screen-to-tile
// hit-testing.
import { mapToLocal, key, COL_STEP, HEX_W, HEX_H } from "../../../shared/hex.js";
import { DRAG_THRESHOLD, ZOOM_MIN, ZOOM_MAX } from "./constants.js";
import { clamp, parity } from "./utils.js";
export const inputMethods = {
// ------------------------------------------------------------- camera --
centerOn(coords) {
const local = mapToLocal(coords.x, coords.y);
this.camera.x = local.x;
this.camera.y = local.y;
this._applyCamera();
},
_centerOnCapital(cities) {
for (const city of cities) {
if (city.civ === this.localCiv && city.isCapital) {
this.centerOn({ x: city.coords[0], y: city.coords[1] });
this._centered = true;
return;
}
}
},
// `applyWrap` is false while dragging: the per-element wrapped copies are
// refreshed once per animation frame in `animate`, so a high pointer rate
// cannot multiply the work. Panning and zooming from elsewhere apply them
// immediately so the view is always consistent when read.
_applyCamera(applyWrap = true) {
const width = this.$viewport.width();
const height = this.$viewport.height();
this._updateLod(width, height);
if (this.lod) {
// With the whole world on screen there is nothing to scroll to; keep the
// camera over the map so the overview never drifts into the void.
this.camera.x = clamp(this.camera.x, this._worldMinX, this._worldMinX + this._worldWidth);
this.camera.y = clamp(this.camera.y, this._worldMinY, this._worldMinY + this._worldHeight);
}
const { x, y, zoom } = this.camera;
const tx = width / 2 - x * zoom;
const ty = height / 2 - y * zoom;
this.$world.css("transform", `translate(${tx}px, ${ty}px) scale(${zoom})`);
if (applyWrap) this._syncChunks();
},
// Switches between the textured hex view and the simplified overview. The
// detailed view only makes sense while the visible world stays within one
// horizontal period (so wrapping is seamless); past that -- when the player
// is zoomed out to see the whole map -- the coarse overview takes over.
_updateLod(width, height) {
let detailedWidth = this._worldWidth;
if (this._period > 0) detailedWidth = Math.min(detailedWidth, this._period) - HEX_W;
const detailed = detailedWidth > 0 &&
width / this.camera.zoom <= detailedWidth &&
height / this.camera.zoom <= this._worldHeight + HEX_H;
const lod = !detailed;
if (lod === this.lod) return;
this.lod = lod;
this.$world.toggleClass("lod", lod);
if (lod) {
this._clearChunks();
this._drawOverview(true);
} else {
this._chunkDirty = true;
}
},
_setupCameraInput() {
let dragging = false;
let dragged = false;
let pressX = 0;
let pressY = 0;
let lastX = 0;
let lastY = 0;
let pressButton = -1;
this.$viewport.on("mousedown", (event) => {
if (event.button !== 0 && event.button !== 2) return;
dragging = true;
dragged = false;
pressButton = event.button;
pressX = event.clientX;
pressY = event.clientY;
lastX = event.clientX;
lastY = event.clientY;
if (event.button === 0) this.$viewport.addClass("dragging");
});
$(window).on("mousemove", (event) => {
if (!dragging) return;
if (Math.abs(event.clientX - pressX) > DRAG_THRESHOLD ||
Math.abs(event.clientY - pressY) > DRAG_THRESHOLD) {
dragged = true;
}
if (dragged) {
this.camera.x -= (event.clientX - lastX) / this.camera.zoom;
this.camera.y -= (event.clientY - lastY) / this.camera.zoom;
this._applyCamera(false);
}
lastX = event.clientX;
lastY = event.clientY;
});
$(window).on("mouseup", (event) => {
if (!dragging) return;
dragging = false;
this.$viewport.removeClass("dragging");
if (dragged) return;
const coords = this.tileAtScreen(event.clientX, event.clientY);
if (!coords) return;
if (pressButton === 0) this._onLeftClick(coords);
else if (pressButton === 2) this._onRightClick(coords, event);
});
this.$viewport.on("contextmenu", (event) => event.preventDefault());
this.$viewport.on("wheel", (event) => {
event.preventDefault();
const factor = event.originalEvent.deltaY < 0 ? 1.1 : 1 / 1.1;
this.camera.zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, this.camera.zoom * factor));
this._applyCamera();
});
},
panBy(dx, dy) {
this.camera.x += dx;
this.camera.y += dy;
this._applyCamera();
},
// ------------------------------------------------------------ clicks --
_onLeftClick(coords) {
const unitId = this.unitAt(coords);
if (unitId && !this._selectedUnitIds.has(unitId)) {
this.onUnitSelected(unitId);
return;
}
if (unitId) return;
const city = this.cityAt(coords);
if (city) {
this.onCitySelected(city.id);
return;
}
this.clearSelection();
},
// With a selection, right-clicking orders it where it clicked: Ctrl queues a
// scheduled destination, a hostile stack or city is attacked, and anything
// else -- including a friendly stack, which we merge into -- is a move. With
// no selection, right-clicking a friendly stack opens its menu and any other
// tile is inspected.
_onRightClick(coords, event) {
const selected = Array.from(this._selectedUnitIds);
if (selected.length > 0) {
if (event && (event.ctrlKey || event.metaKey)) {
return this.onScheduleOrdered(selected, coords);
}
if (this._isEnemyTarget(coords)) {
return this.onAttackOrdered(selected, coords);
}
return this.onMoveOrdered(selected, coords);
}
const friendly = this.unitIdsAt(coords).filter(
(id) => (this._unitData.get(id) || {}).civ === this.localCiv
);
if (friendly.length > 0) {
this.onStackMenu(coords, friendly, event ? event.clientX : 0, event ? event.clientY : 0);
return;
}
this.onTileRequested(coords);
},
// Ids of every unit of the same civilisation sharing the tile the given unit
// is drawn on, so clicking one icon selects the whole stack.
stackIds(unitId) {
const data = this._unitData.get(unitId);
if (!data) return [];
const motion = this._unitMotion.get(unitId);
const tile = motion
? this._currentTile(motion)
: { x: data.coords[0], y: data.coords[1] };
return this.unitIdsAt(tile).filter(
(id) => (this._unitData.get(id) || {}).civ === data.civ
);
},
// Whether the tile holds a hostile unit stack or an enemy city, so a right
// click with our stack selected becomes an attack rather than a march.
_isEnemyTarget(coords) {
const enemy = this.unitIdsAt(coords).some((id) => {
const data = this._unitData.get(id);
return data && data.civ !== this.localCiv;
});
if (enemy) return true;
const k = key(coords.x, coords.y);
if (!this.visible.has(k)) return false;
const cityCiv = this._cityCiv.get(k);
return cityCiv !== undefined && cityCiv !== this.localCiv;
},
// --------------------------------------------------------- selection --
setSelectedUnits(ids) {
this._selectedUnitIds = new Set((ids || []).filter((id) => id));
for (const [unitId, view] of this._unitViews) {
view.classList.toggle("selected", this._selectedUnitIds.has(unitId));
}
},
setSelectedUnit(id) {
this.setSelectedUnits(id ? [id] : []);
},
clearSelection() {
this.setSelectedUnits([]);
this.onUnitSelected(0);
},
// ------------------------------------------------------- hit-testing --
unitIdsAt(coords) {
const local = this._clickLocal(coords);
const result = [];
for (const [id, motion] of this._unitMotion) {
const view = this._unitViews.get(id);
if (view && view.style.display === "none") continue;
const position = motion.path.length >= 2
? this._positionAlongPath(motion)
: mapToLocal(motion.coords.x, motion.coords.y);
const offset = motion.offset || { x: 0, y: 0 };
const wrapped = this._wrappedPosition(position);
if (Math.hypot(wrapped.x + offset.x - local.x, wrapped.y + offset.y - local.y) < HEX_W * 0.5) {
result.push(id);
}
}
return result;
},
unitAt(coords) {
return this.unitIdsAt(coords)[0] || 0;
},
cityAt(coords) {
for (const [id, view] of this._cityViews) {
if (view.style.display === "none") continue;
const local = this._clickLocal(coords);
const cx = parseFloat(view.style.left);
const cy = parseFloat(view.style.top);
if (Math.hypot(cx - local.x, cy - local.y) < HEX_W * 0.5) return { id };
}
return null;
},
// World position of a clicked tile at the wrapped copy nearest the camera, so
// hit-testing works across the seam.
_clickLocal(coords) {
return this._wrappedPosition(mapToLocal(coords.x, coords.y));
},
tileAtScreen(clientX, clientY) {
const rect = this.$viewport[0].getBoundingClientRect();
const screenX = clientX - rect.left;
const screenY = clientY - rect.top;
const width = this.$viewport.width();
const height = this.$viewport.height();
const worldX = (screenX - (width / 2 - this.camera.x * this.camera.zoom)) / this.camera.zoom;
const worldY = (screenY - (height / 2 - this.camera.y * this.camera.zoom)) / this.camera.zoom;
return this._worldToHex(worldX, worldY);
},
_worldToHex(worldX, worldY) {
const column = Math.round(worldX / COL_STEP);
let best = null;
let bestDistance = Infinity;
for (let dx = -1; dx <= 1; dx++) {
const x = column + dx;
const baseY = Math.round(worldY / HEX_H - 0.5 * parity(x));
for (let dy = -1; dy <= 1; dy++) {
const y = baseY + dy;
const local = mapToLocal(x, y);
const distance = Math.hypot(local.x - worldX, local.y - worldY);
if (distance < bestDistance) {
bestDistance = distance;
best = { x, y };
}
}
}
return best ? this.topology.wrapCoords(best.x, best.y) : null;
},
};
+404
View File
@@ -0,0 +1,404 @@
// Dead-reckoning unit motion between snapshots, path drawing and the local
// move prediction that mirrors GameState.find_path.
import { mapToLocal, key } from "../../../shared/hex.js";
import { HexPathfinder } from "../../../shared/hex_pathfinder.js";
import { RESYNC_HOURS, PREDICTION_GRACE_HOURS } from "./constants.js";
import { hoursAlong, samePath, pathProgress, makeSegment } from "./utils.js";
// A pathfinding slice runs for this long before handing the thread back, so
// even a cross-map search never blocks a frame for long.
const PATH_SLICE_MS = 5;
const pathNow = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
const pathDefer = (fn) => setTimeout(fn, 0);
export const motionMethods = {
// A living hostile civilian standing on the path blocks dead reckoning: the
// animation must stop before it. An enemy military unit is the fight itself
// and combat is fought on a single tile, so the mover charges onto its tile
// and stops there. Friendly units stack and never block.
_truncateAtOccupant(path, segmentHours, data) {
const proto = this.protoUnits[data.proto];
for (let i = 1; i < path.length; i++) {
const occupant = this._unitsAtCoords(path[i], data.id).find(
(other) => other.civ !== data.civ
);
if (!occupant) continue;
if (proto && proto.military && this._isMilitaryData(occupant)) {
return { path: path.slice(0, i + 1), segmentHours: segmentHours.slice(0, i) };
}
return {
path: path.slice(0, i),
segmentHours: segmentHours.slice(0, Math.max(0, i - 1)),
};
}
return { path, segmentHours };
},
_updateUnitMotion(data) {
const id = data.id;
let path = (data.path || []).map((p) => ({ x: p[0], y: p[1] }));
let segmentHours = data.segmentHours || [];
const clipped = this._truncateAtOccupant(path, segmentHours, data);
path = clipped.path;
segmentHours = clipped.segmentHours;
const existing = this._unitMotion.get(id);
if (path.length < 2 || segmentHours.length === 0) {
if (existing && existing.predicting && existing.predictAge < PREDICTION_GRACE_HOURS) {
return;
}
this._unitMotion.set(id, {
path: [],
segmentHours: [],
pathHours: 0,
coords: { x: data.coords[0], y: data.coords[1] },
predicting: false,
predictAge: 0,
});
return;
}
const authoritative = hoursAlong(data, segmentHours);
let pathHours = authoritative;
if (existing && samePath(existing.path, path)) {
pathHours = Math.abs(authoritative - existing.pathHours) > RESYNC_HOURS
? authoritative
: existing.pathHours;
}
this._unitMotion.set(id, {
path,
segmentHours,
pathHours,
coords: { x: data.coords[0], y: data.coords[1] },
predicting: false,
predictAge: 0,
});
},
animate(dtHours) {
for (const [, motion] of this._unitMotion) {
if (motion.path.length < 2) continue;
motion.pathHours += dtHours;
if (motion.predicting) motion.predictAge += dtHours;
}
this._syncChunks();
this._applyEntityWrap();
this._drawPaths();
},
// Repositions every unit and city at the wrapped copy nearest the camera.
_applyEntityWrap() {
for (const [id, motion] of this._unitMotion) {
const view = this._unitViews.get(id);
if (!view) continue;
const position = motion.path.length >= 2
? this._positionAlongPath(motion)
: mapToLocal(motion.coords.x, motion.coords.y);
// Choose the wrapped copy from the tile centre, then apply the stack
// offset, so members of one stack never split across the wrap seam.
const wrapped = this._wrappedPosition(position);
const offset = motion.offset || { x: 0, y: 0 };
view.style.left = `${wrapped.x + offset.x}px`;
view.style.top = `${wrapped.y + offset.y}px`;
}
for (const view of this._cityViews.values()) {
if (view._localX === undefined) continue;
const left = view._localX + this._wrapShift(view._localX);
view.style.left = `${left}px`;
view.style.top = `${view._localY}px`;
if (view._label) {
view._label.style.left = `${left}px`;
view._label.style.top = `${view._localY}px`;
}
}
for (const marker of this._battleViews.values()) {
if (marker._localX === undefined) continue;
marker.style.left = `${marker._localX + this._wrapShift(marker._localX)}px`;
marker.style.top = `${marker._localY}px`;
}
},
_positionAlongPath(motion) {
const path = motion.path;
const segmentHours = motion.segmentHours;
let remaining = motion.pathHours;
let index = 0;
while (index < segmentHours.length) {
const step = segmentHours[index];
if (step > 0 && remaining < step) break;
remaining -= step;
index += 1;
}
if (index >= segmentHours.length) {
index = segmentHours.length - 1;
if (index < 0) {
const local = mapToLocal(path[path.length - 1].x, path[path.length - 1].y);
return local;
}
remaining = segmentHours[index];
}
const from = path[index];
const to = path[index + 1] || from;
const a = mapToLocal(from.x, from.y);
const delta = this.topology.pixelDelta(from, to);
const step = segmentHours[index] || 1;
const t = Math.max(0, Math.min(1, remaining / step));
return { x: a.x + delta.x * t, y: a.y + delta.y * t };
},
_drawPaths() {
const fragment = document.createDocumentFragment();
for (const [id, motion] of this._unitMotion) {
if (!this._selectedUnitIds.has(id)) continue;
if (motion.path.length < 2) continue;
const index = pathProgress(motion).index;
const base = mapToLocal(motion.path[index].x, motion.path[index].y);
const current = this._positionAlongPath(motion);
let previous = base;
for (let i = index + 1; i < motion.path.length; i++) {
const from = motion.path[i - 1];
const to = motion.path[i];
const delta = this.topology.pixelDelta(from, to);
const next = { x: previous.x + delta.x, y: previous.y + delta.y };
const start = i === index + 1 ? current : previous;
const a = this._wrappedPosition(start);
const b = this._wrappedPosition(next);
fragment.appendChild(makeSegment(a, b, "#ffffff", 4));
previous = next;
}
}
this.$paths.empty()[0].appendChild(fragment);
},
// -------------------------------------------------------- move preview --
// Mirrors GameState.find_path closely enough that a click feels instant; the
// next snapshot confirms or corrects the route. `planMove` computes the route
// without touching the view, so callers can inspect it (for example to warn
// before entering foreign territory) before committing to the prediction.
planMove(unitData, goal, speed = null) {
return this._planMove(unitData, goal, speed).then((plan) => (plan ? plan.route : null));
},
predictMove(unitData, goal, speed = null, route = null) {
if (route) {
this.predictRoute(unitData, route, speed);
return Promise.resolve(route);
}
return this._planMove(unitData, goal, speed).then((plan) => {
if (!plan) return null;
this._applyPrediction(unitData, plan);
return plan.route;
});
},
// Starts a prediction from a route that was already planned, skipping the
// search. Used right after a move order so the route is only found once.
predictRoute(unitData, route, speed = null) {
const proto = this.protoUnits[unitData.proto];
if (!proto || !route || route.length < 2) return null;
const start = this._unitCoords(unitData.id) ||
{ x: unitData.coords[0], y: unitData.coords[1] };
const segmentHours = [];
for (let i = 1; i < route.length; i++) {
segmentHours.push(this._stepHours(proto, unitData.civ, route[i], speed));
}
this._applyPrediction(unitData, { route, segmentHours, start });
return route;
},
_applyPrediction(unitData, plan) {
const existing = this._unitMotion.get(unitData.id);
this._unitMotion.set(unitData.id, {
path: plan.route,
segmentHours: plan.segmentHours,
pathHours: 0,
coords: plan.start,
predicting: true,
predictAge: 0,
offset: existing ? existing.offset : { x: 0, y: 0 },
});
this.animate(0);
},
async _planMove(unitData, goal, speed = null) {
const proto = this.protoUnits[unitData.proto];
if (!proto || !proto.moveable || !this.topology) return null;
const start = this._unitCoords(unitData.id) ||
{ x: unitData.coords[0], y: unitData.coords[1] };
if (start.x === goal.x && start.y === goal.y) return null;
const path = await this._search(unitData, proto, start, goal, speed);
if (!path || path.length < 2) return null;
const segmentHours = [];
for (let i = 1; i < path.length; i++) {
segmentHours.push(this._stepHours(proto, unitData.civ, path[i], speed));
}
return { route: path, segmentHours, start };
},
// The A* this mirrors recomputes the cheapest movement cost on every node,
// and the tile scan behind it dominated the search. Cache it here (the
// TerrainStats memoises it too) and index the units once instead of scanning
// every unit for every tile.
_search(unitData, proto, start, goal, speed) {
const civ = unitData.civ;
const targetCiv = this._targetCivFor(unitData, goal);
const occupancy = this._buildOccupancy(unitData.id);
const enterCache = new Map();
const costCache = new Map();
const heuristicCache = new Map();
const bestSpeed = Math.max(speed || proto.speed, 0.0001);
const fastestHours = this.terrainStats.minMovementCostMultiplier() / (bestSpeed * 1.5);
const iterator = new HexPathfinder().findPathIter(
start,
goal,
(coords) => this.topology.neighbours(coords.x, coords.y),
(coords, isGoal) => {
const k = key(coords.x, coords.y) + (isGoal ? ":goal" : "");
if (!enterCache.has(k)) {
enterCache.set(k, this._canEnter(civ, proto, coords, unitData.id, isGoal, occupancy, targetCiv));
}
return enterCache.get(k);
},
(_from, to) => {
const k = key(to.x, to.y);
if (!costCache.has(k)) costCache.set(k, this._stepHours(proto, civ, to, speed));
return costCache.get(k);
},
(from, to) => {
const k = key(from.x, from.y);
if (!heuristicCache.has(k)) {
const delta = this.topology.pixelDelta(from, to);
heuristicCache.set(k, (Math.hypot(delta.x, delta.y) / this._maxStepLength) * fastestHours);
}
return heuristicCache.get(k);
}
);
return this._runSearch(iterator);
},
// Drives the search in short time slices across timer turns, so a long route
// never freezes the frame. Starting a new search cancels the previous one.
_runSearch(iterator) {
this.cancelPathfinding();
const token = { cancelled: false };
this._pathToken = token;
return new Promise((resolve) => {
const pump = () => {
if (token.cancelled) {
resolve(null);
return;
}
const deadline = pathNow() + PATH_SLICE_MS;
let step = iterator.next();
while (!step.done && pathNow() < deadline) step = iterator.next();
if (step.done) {
this._pathToken = null;
resolve(step.value);
} else {
pathDefer(pump);
}
};
pump();
});
},
cancelPathfinding() {
if (!this._pathToken) return;
this._pathToken.cancelled = true;
this._pathToken = null;
},
_isMilitaryData(data) {
const proto = this.protoUnits[data.proto];
return !!(proto && proto.military);
},
_unitsAtCoords(coords, ignoreId = 0) {
const result = [];
for (const [id, data] of this._unitData) {
if (id === ignoreId) continue;
const motion = this._unitMotion.get(id);
const tile = motion && motion.path.length >= 2
? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)]
: { x: data.coords[0], y: data.coords[1] };
if (tile.x === coords.x && tile.y === coords.y) result.push(data);
}
return result;
},
// Same as _unitsAtCoords, but every unit is bucketed by its tile once so a
// search can look up occupancy in constant time instead of scanning them all
// for every tile it considers.
_buildOccupancy(ignoreId) {
const index = new Map();
for (const [id, data] of this._unitData) {
if (id === ignoreId) continue;
const motion = this._unitMotion.get(id);
const tile = motion && motion.path.length >= 2
? motion.path[Math.min(pathProgress(motion).index, motion.path.length - 1)]
: { x: data.coords[0], y: data.coords[1] };
const k = key(tile.x, tile.y);
const list = index.get(k);
if (list) list.push(data);
else index.set(k, [data]);
}
return index;
},
_unitCoords(id) {
const motion = this._unitMotion.get(id);
if (!motion) return null;
if (motion.path.length >= 2) {
const index = pathProgress(motion).index;
return motion.path[Math.min(index, motion.path.length - 1)];
}
return motion.coords;
},
// The civilisation a route is aimed at: the enemy city or unit on the goal,
// or failing that whoever owns the goal tile. Crossing that civ's land is
// allowed; every other foreign territory is a wall.
_targetCivFor(unitData, goal) {
const k = key(goal.x, goal.y);
const cityCiv = this._cityCiv.get(k);
if (cityCiv !== undefined && cityCiv !== unitData.civ) return cityCiv;
const hostile = this._unitsAtCoords(goal, unitData.id).find((data) => data.civ !== unitData.civ);
if (hostile) return hostile.civ;
return this.territory.has(k) ? this.territory.get(k) : -1;
},
// Mirrors GameState._canUnitEnter: friendly units share a tile, while a
// hostile unit or city is only a valid destination (`isGoal`). Foreign
// territory is a wall too, except for the civilisation being targeted, so a
// route never cuts through a third party and provokes a war.
_canEnter(civ, proto, coords, moverId = 0, isGoal = false, occupancy = null, targetCiv = -1) {
const k = key(coords.x, coords.y);
if (!this.explored.has(k)) return false;
const tile = this.tiles[k];
if (!tile) return false;
if (!proto.traversableTerrains.includes(tile.terrainClass)) return false;
const owner = this.territory.has(k) ? this.territory.get(k) : -1;
if (!isGoal && owner >= 0 && owner !== civ && owner !== targetCiv) return false;
const occupants = occupancy ? (occupancy.get(k) || []) : this._unitsAtCoords(coords, moverId);
const hostile = occupants.filter((data) => data.civ !== civ);
if (hostile.length > 0) {
if (!isGoal) return false;
if (proto.military !== true) return false;
const anyMilitary = hostile.some((data) => this._isMilitaryData(data));
if (!anyMilitary) return false;
}
const cityCiv = this._cityCiv.get(k);
if (cityCiv !== undefined && cityCiv !== civ) {
if (!isGoal) return false;
if (proto.military !== true) return false;
}
return true;
},
_stepHours(proto, civ, coords, speed = null) {
const k = key(coords.x, coords.y);
let effective = Math.max(speed || proto.speed, 0.0001);
if (this.territory.get(k) === civ) effective *= 1.5;
return this.terrainStats.movementCostMultiplier(coords) / effective;
},
};
+486
View File
@@ -0,0 +1,486 @@
// Terrain construction, world-space bounds, the coarse overview used at low
// zoom, and the chunked world-space layers.
//
// The detailed map is not one element per tile of the whole world. It is built
// from square CHUNK_SIZE x CHUNK_SIZE tile blocks that are materialised only
// while they intersect the viewport (plus a margin) and recycled the moment
// they leave it. Panning therefore costs the same on a 200x200 map as on a
// 20x20 one. The chunk wrappers live in the terrain, border and fog layers so
// the layer z-order is unchanged; tiles inside a wrapper are positioned
// relative to the chunk, and the wrapper itself is moved by `transform` and
// wrapped by a whole map period.
import { MapTopology, mapToLocal, key, parseKey, HEX_W, HEX_H, COL_STEP } from "../../../shared/hex.js";
import { MapGenerator } from "../../../shared/map_generator.js";
import { TerrainStats } from "../../../shared/terrain_stats.js";
import { MAP_CONFIG } from "../../../shared/data.js";
import { OVERVIEW_BLOCK, OVERVIEW_FOG, CHUNK_SIZE, CHUNK_MARGIN, CHUNK_CACHE_LIMIT } from "./constants.js";
import { overviewColour, blendColours } from "./utils.js";
export const terrainMethods = {
ensureTerrain(seed) {
if (seed === this.seed) return;
this.seed = seed;
this.topology = new MapTopology(MAP_CONFIG.mapSize, MAP_CONFIG.topology === "cylindrical");
const generator = new MapGenerator(MAP_CONFIG, this.topology);
generator.generate(seed);
this.tiles = generator.tiles;
this.terrainStats = new TerrainStats(this.tiles);
this._maxStepLength = this._computeMaxStepLength();
this._period = this.topology.periodPixels();
this._computeWorldBounds();
this._chunkSize = CHUNK_SIZE;
this._chunkCols = Math.ceil(this.topology.width / CHUNK_SIZE);
this._chunkRows = Math.ceil(this.topology.height / CHUNK_SIZE);
this._chunkOriginCache = new Map();
this._chunkCache = new Map();
this._chunkCacheLimit = CHUNK_CACHE_LIMIT;
this._destroyAllChunks();
this.$terrain.empty();
this.$borders.empty();
this.$fog.empty();
this.$terrain.addClass("detail-layer");
this.$borders.addClass("detail-layer");
this.$fog.addClass("detail-layer");
this._territorySignature = "";
this._exploredCount = -1;
this._visibleSignature = null;
this._chunkDirty = true;
this._chunkView = { x: null, y: null, zoom: null };
this.$entities.empty();
this.$labels.empty();
this._unitViews.clear();
this._unitMotion.clear();
this._unitData.clear();
this._cityViews.clear();
this._cityCiv.clear();
for (const view of this._battleViews.values()) view.remove();
this._battleViews.clear();
this._battles.clear();
this._centered = false;
this._chunkDirty = true;
// The overview is only built when the player is (or becomes) zoomed out;
// keeping it out of startup leaves the initial view to the visible chunks.
if (this.lod) this._drawOverview(true);
this._syncChunks();
},
// Pixel bounds of the whole world, used to lay out the overview and clamp the
// camera when the simplified map is on screen.
_computeWorldBounds() {
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const k in this.tiles) {
const coords = parseKey(k);
const local = mapToLocal(coords.x, coords.y);
minX = Math.min(minX, local.x - HEX_W / 2);
maxX = Math.max(maxX, local.x + HEX_W / 2);
minY = Math.min(minY, local.y - HEX_H / 2);
maxY = Math.max(maxY, local.y + HEX_H / 2);
}
if (!isFinite(minX)) {
minX = 0;
maxX = 0;
minY = 0;
maxY = 0;
}
this._worldMinX = minX;
this._worldMinY = minY;
this._worldWidth = maxX - minX;
this._worldHeight = maxY - minY;
},
getTile(coords) {
return this.tiles[key(coords.x, coords.y)] || null;
},
// ------------------------------------------------------------- chunks --
// Geometry of the chunk at column/row (cx, cy): the inclusive tile range it
// covers, plus its unwrapped pixel origin and size. Cached because culling
// walks every chunk cell on each camera move.
_chunkOrigin(cx, cy) {
const cacheKey = cx + "," + cy;
const cached = this._chunkOriginCache.get(cacheKey);
if (cached) return cached;
const size = this._chunkSize;
const t = this.topology;
const x0 = t.originX + cx * size;
const y0 = t.originY + cy * size;
const x1 = Math.min(x0 + size - 1, t.originX + t.width - 1);
const y1 = Math.min(y0 + size - 1, t.originY + t.height - 1);
const center = mapToLocal(x0, y0);
const origin = {
x0,
y0,
x1,
y1,
x: center.x,
y: center.y,
px: (x1 - x0) * COL_STEP,
py: (y1 - y0) * HEX_H,
};
this._chunkOriginCache.set(cacheKey, origin);
return origin;
},
// Creates the empty wrappers that hold one chunk in each detailed layer.
_newChunk() {
const terrain = this._acquireWrapper();
const fog = this._acquireWrapper();
const borders = this._acquireWrapper();
this.$terrain[0].appendChild(terrain);
this.$fog[0].appendChild(fog);
this.$borders[0].appendChild(borders);
return { terrain, fog, borders };
},
_createChunk(cx, cy) {
const chunkKey = cx + "," + cy;
const cached = this._chunkCache.get(chunkKey);
if (cached) {
// Reattach the already-built chunk; only the layers that changed while it
// was parked need rebuilding.
this._chunkCache.delete(chunkKey);
this.$terrain[0].appendChild(cached.terrainEl);
this.$fog[0].appendChild(cached.fogEl);
this.$borders[0].appendChild(cached.borderEl);
this._chunks.set(chunkKey, cached);
if (cached.fogDirty) this._buildFog(cached);
if (cached.borderDirty) this._buildBorders(cached);
this._setChunkPosition(cached, this._wrapShift(cached.x));
return cached;
}
const origin = this._chunkOrigin(cx, cy);
const rec = {
cx,
cy,
x0: origin.x0,
y0: origin.y0,
x1: origin.x1,
y1: origin.y1,
x: origin.x,
y: origin.y,
px: origin.px,
py: origin.py,
shift: null,
tileKeys: [],
fogDirty: false,
borderDirty: false,
terrainEl: null,
fogEl: null,
borderEl: null,
};
const wrappers = this._newChunk();
rec.terrainEl = wrappers.terrain;
rec.fogEl = wrappers.fog;
rec.borderEl = wrappers.borders;
this._chunks.set(chunkKey, rec);
this._buildTerrain(rec);
this._buildBorders(rec);
this._buildFog(rec);
this._setChunkPosition(rec, this._wrapShift(rec.x));
return rec;
},
_setChunkPosition(rec, shift) {
rec.shift = shift;
const transform = `translateX(${rec.x + shift}px) translateY(${rec.y}px)`;
rec.terrainEl.style.transform = transform;
rec.fogEl.style.transform = transform;
rec.borderEl.style.transform = transform;
},
// Parks a built chunk off-screen so panning back over it is a reattach rather
// than a rebuild. Old chunks are dropped (and their tiles pooled) once the
// cache is full.
_detachChunk(chunkKey, rec) {
rec.terrainEl.parentNode && rec.terrainEl.parentNode.removeChild(rec.terrainEl);
rec.fogEl.parentNode && rec.fogEl.parentNode.removeChild(rec.fogEl);
rec.borderEl.parentNode && rec.borderEl.parentNode.removeChild(rec.borderEl);
this._chunks.delete(chunkKey);
this._chunkCache.set(chunkKey, rec);
while (this._chunkCache.size > this._chunkCacheLimit) {
const [oldKey, old] = this._chunkCache.entries().next().value;
this._chunkCache.delete(oldKey);
this._destroyChunk(old);
}
},
_destroyChunk(rec) {
this._recycleLayer(rec.terrainEl, this._hexPool);
this._recycleLayer(rec.fogEl, this._hexPool);
this._recycleLayer(rec.borderEl, this._borderPool);
this._releaseWrapper(rec.terrainEl);
this._releaseWrapper(rec.fogEl);
this._releaseWrapper(rec.borderEl);
},
// Parks every materialised chunk (used when the overview takes over) so
// zooming back in is cheap.
_clearChunks() {
if (!this._chunks || this._chunks.size === 0) return;
for (const [chunkKey, rec] of Array.from(this._chunks)) this._detachChunk(chunkKey, rec);
},
_destroyAllChunks() {
if (this._chunks) {
for (const rec of this._chunks.values()) this._destroyChunk(rec);
this._chunks.clear();
}
if (this._chunkCache) {
for (const rec of this._chunkCache.values()) this._destroyChunk(rec);
this._chunkCache.clear();
}
},
// Materialises every chunk that overlaps the viewport (expanded by
// CHUNK_MARGIN blocks) and releases the rest. Cheap enough to run each frame:
// it walks the chunk grid, not the tiles.
_syncChunks() {
if (!this.topology || !this._chunks || this.lod) return;
const width = this.$viewport.width();
const height = this.$viewport.height();
if (!width || !height) return;
const zoom = this.camera.zoom || 1;
const cameraX = this.camera.x;
const cameraY = this.camera.y;
if (
!this._chunkDirty &&
cameraX === this._chunkView.x &&
cameraY === this._chunkView.y &&
zoom === this._chunkView.zoom
) {
return;
}
this._chunkDirty = false;
this._chunkView = { x: cameraX, y: cameraY, zoom };
const halfW = width / (2 * zoom);
const halfH = height / (2 * zoom);
const slackX = CHUNK_MARGIN * this._chunkSize * COL_STEP;
const slackY = CHUNK_MARGIN * this._chunkSize * HEX_H;
const left = cameraX - halfW - slackX;
const right = cameraX + halfW + slackX;
const top = cameraY - halfH - slackY;
const bottom = cameraY + halfH + slackY;
const wanted = new Set();
for (let cy = 0; cy < this._chunkRows; cy++) {
for (let cx = 0; cx < this._chunkCols; cx++) {
const origin = this._chunkOrigin(cx, cy);
const shift = this._wrapShift(origin.x);
const minX = origin.x + shift - HEX_W;
const maxX = origin.x + shift + origin.px + HEX_W;
const minY = origin.y - HEX_H;
const maxY = origin.y + origin.py + HEX_H;
if (minX > right || maxX < left || minY > bottom || maxY < top) continue;
const chunkKey = cx + "," + cy;
wanted.add(chunkKey);
let rec = this._chunks.get(chunkKey);
if (!rec) {
rec = this._createChunk(cx, cy);
} else if (rec.shift !== shift) {
this._setChunkPosition(rec, shift);
}
}
}
for (const [chunkKey, rec] of Array.from(this._chunks)) {
if (wanted.has(chunkKey)) continue;
this._detachChunk(chunkKey, rec);
}
},
// -------------------------------------------------------- chunk content --
_buildTerrain(rec) {
this._recycleLayer(rec.terrainEl, this._hexPool);
const fragment = document.createDocumentFragment();
rec.tileKeys = [];
for (let y = rec.y0; y <= rec.y1; y++) {
for (let x = rec.x0; x <= rec.x1; x++) {
const k = key(x, y);
const tile = this.tiles[k];
if (!tile) continue;
rec.tileKeys.push(k);
const local = mapToLocal(x, y);
const div = this._acquireHex(this._hexPool);
div.className = "hex terrain detail-layer";
div.style.left = `${local.x - rec.x - HEX_W / 2}px`;
div.style.top = `${local.y - rec.y - HEX_H / 2}px`;
div.style.backgroundPosition = `${-tile.col * HEX_W}px 0`;
fragment.appendChild(div);
}
}
rec.terrainEl.appendChild(fragment);
},
// Fog is rebuilt per materialised chunk, so a change in visibility only costs
// the few hundred tiles actually on screen instead of the whole map.
_buildFog(rec) {
rec.fogDirty = false;
this._recycleLayer(rec.fogEl, this._hexPool);
const fragment = document.createDocumentFragment();
for (let y = rec.y0; y <= rec.y1; y++) {
for (let x = rec.x0; x <= rec.x1; x++) {
const k = key(x, y);
if (!this.tiles[k] || this.visible.has(k)) continue;
const local = mapToLocal(x, y);
const div = this._acquireHex(this._hexPool);
div.className = "hex fog detail-layer " +
(this.explored.has(k) ? "fog-explored" : "fog-unexplored");
div.style.left = `${local.x - rec.x - HEX_W / 2}px`;
div.style.top = `${local.y - rec.y - HEX_H / 2}px`;
fragment.appendChild(div);
}
}
rec.fogEl.appendChild(fragment);
},
_refreshFog() {
if (!this._chunks) return;
for (const rec of this._chunks.values()) this._buildFog(rec);
if (this._chunkCache) for (const rec of this._chunkCache.values()) rec.fogDirty = true;
},
// ------------------------------------------------------------ pooling --
_acquireWrapper() {
const el = this._wrapperPool.pop();
if (el) {
el.className = "chunk";
el.style.cssText = "";
return el;
}
return document.createElement("div");
},
_releaseWrapper(el) {
el.className = "";
el.style.cssText = "";
el.parentNode && el.parentNode.removeChild(el);
this._wrapperPool.push(el);
},
_acquireHex(pool) {
return pool.pop() || document.createElement("div");
},
_recycleLayer(wrapper, pool) {
let child = wrapper.firstChild;
while (child) {
const next = child.nextSibling;
wrapper.removeChild(child);
child.className = "";
child.style.cssText = "";
pool.push(child);
child = next;
}
},
// --------------------------------------------------------------- wrap --
// Pixel offset (a whole number of map periods) that moves an element sitting
// at `centerX` to the copy closest to the camera. Choosing the half-open band
// [camera - P/2, camera + P/2) keeps neighbouring tiles contiguous, so the
// world tiles seamlessly in both directions.
_wrapShift(centerX) {
if (!this._period || this.lod) return 0;
const period = this._period;
return Math.ceil((this.camera.x - period / 2 - centerX) / period) * period;
},
_wrappedPosition(point) {
return { x: point.x + this._wrapShift(point.x), y: point.y };
},
// ---------------------------------------------------------------- LOD --
// Builds the simplified map as a coarse grid: one flat block per
// OVERVIEW_BLOCK x OVERVIEW_BLOCK tiles. At low zoom these few hundred
// elements replace the detailed chunks, so seeing the entire map stays cheap.
_drawOverview(force = false) {
if (!this.$overview || !this.$overview.length || !this.topology) return;
// While the detailed view is on screen the overview is hidden; keep the
// redraw for the moment the player zooms out rather than paying for it on
// every hourly snapshot.
if (!force && !this.lod) return;
const step = OVERVIEW_BLOCK;
const startX = this.topology.originX;
const startY = this.topology.originY;
const endX = startX + this.topology.width;
const endY = startY + this.topology.height;
const fragment = document.createDocumentFragment();
for (let by = startY; by < endY; by += step) {
for (let bx = startX; bx < endX; bx += step) {
const block = this._overviewBlock(bx, by, step);
if (block) fragment.appendChild(block);
}
}
this.$overview.empty()[0].appendChild(fragment);
},
_overviewBlock(bx, by, step) {
const endX = Math.min(bx + step, this.topology.originX + this.topology.width);
const endY = Math.min(by + step, this.topology.originY + this.topology.height);
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
let representative = null;
let land = null;
let owner = -1;
let visible = false;
let explored = false;
for (let y = by; y < endY; y++) {
for (let x = bx; x < endX; x++) {
const k = key(x, y);
const tile = this.tiles[k];
if (!tile) continue;
if (!representative) representative = tile;
if (tile.terrainClass !== "Sea" && !land) land = tile;
const local = mapToLocal(x, y);
minX = Math.min(minX, local.x - HEX_W / 2);
maxX = Math.max(maxX, local.x + HEX_W / 2);
minY = Math.min(minY, local.y - HEX_H / 2);
maxY = Math.max(maxY, local.y + HEX_H / 2);
const tileOwner = this.territory.get(k);
if (tileOwner !== undefined && tileOwner >= 0 && this.civilisations[tileOwner]) {
owner = tileOwner;
}
if (this.visible.has(k)) visible = true;
if (this.explored.has(k)) explored = true;
}
}
if (!representative) return null;
let colour = overviewColour(land || representative);
if (owner >= 0) colour = blendColours(colour, this.civilisations[owner].primaryColour, 0.45);
if (!visible) colour = blendColours(colour, OVERVIEW_FOG, explored ? 0.62 : 1);
const div = document.createElement("div");
div.className = "overview-block";
div.style.left = `${minX}px`;
div.style.top = `${minY}px`;
div.style.width = `${maxX - minX}px`;
div.style.height = `${maxY - minY}px`;
div.style.background = colour;
return div;
},
_computeMaxStepLength() {
let longest = 0;
for (const neighbour of this.topology.neighbours(0, 0)) {
const delta = this.topology.pixelDelta({ x: 0, y: 0 }, neighbour);
longest = Math.max(longest, Math.hypot(delta.x, delta.y));
}
return longest > 0 ? longest : 1.0;
},
};
+151
View File
@@ -0,0 +1,151 @@
// Pure helpers for the map view: geometry, path timing and colour blending.
// Nothing here touches the DOM or the MapView instance, so it is all trivially
// testable.
import { HEX_W, HEX_H } from "../../../shared/hex.js";
import { OVERVIEW_COLOURS, OVERVIEW_SEA } from "./constants.js";
export function parity(x) {
return x & 1;
}
// Order-independent FNV-style hash of an [x, y, ...] entry array, used to skip
// rebuilding a layer when the snapshot did not actually change it.
export function hashEntries(entries) {
let hash = 2166136261;
for (const entry of entries) {
hash = Math.imul(hash ^ (entry[0] + 4096), 16777619);
hash = Math.imul(hash ^ (entry[1] + 4096), 16777619);
hash = Math.imul(hash ^ (entry.length > 2 ? entry[2] + 8 : 0), 16777619);
}
return `${entries.length}:${(hash >>> 0).toString(36)}`;
}
// How far along its path a unit is, in segment-hours, from a snapshot.
export function hoursAlong(data, segmentHours) {
const index = Math.min(data.pathIndex || 0, segmentHours.length);
let total = 0;
for (let i = 0; i < index; i++) total += segmentHours[i];
return total + (data.progressHours || 0);
}
export function samePath(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i].x !== b[i].x || a[i].y !== b[i].y) return false;
}
return true;
}
// The segment index a motion is currently on and how far into it it is.
export function pathProgress(motion) {
let remaining = motion.pathHours;
let index = 0;
while (index < motion.segmentHours.length) {
const step = motion.segmentHours[index];
if (step <= 0) {
index += 1;
continue;
}
if (remaining < step) break;
remaining -= step;
index += 1;
}
return { index, remaining };
}
export function nearestCorners(center, target) {
const half = { x: HEX_W / 2, y: HEX_H / 2 };
const corners = [
{ x: half.x, y: 0 },
{ x: half.x * 0.5, y: -half.y },
{ x: -half.x * 0.5, y: -half.y },
{ x: -half.x, y: 0 },
{ x: -half.x * 0.5, y: half.y },
{ x: half.x * 0.5, y: half.y },
];
let first = 0;
let second = 1;
let firstDistance = Infinity;
let secondDistance = Infinity;
for (let i = 0; i < corners.length; i++) {
const point = { x: center.x + corners[i].x, y: center.y + corners[i].y };
const distance = (point.x - target.x) ** 2 + (point.y - target.y) ** 2;
if (distance < firstDistance) {
secondDistance = firstDistance;
second = first;
firstDistance = distance;
first = i;
} else if (distance < secondDistance) {
secondDistance = distance;
second = i;
}
}
return [
{ x: center.x + corners[first].x, y: center.y + corners[first].y },
{ x: center.x + corners[second].x, y: center.y + corners[second].y },
];
}
// Signed distance to nudge an edge segment along its normal so it moves towards
// `center` (the owning tile). Used to keep neighbours' borders side by side
// instead of overlapping on the shared edge.
export function inwardOffset(a, b, center, distance) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.hypot(dx, dy) || 1;
const nx = -dy / length;
const ny = dx / length;
const midX = (a.x + b.x) / 2;
const midY = (a.y + b.y) / 2;
const dot = (center.x - midX) * nx + (center.y - midY) * ny;
return dot >= 0 ? distance : -distance;
}
export function makeSegment(a, b, colour, width, offset = 0) {
const div = document.createElement("div");
div.className = "border-seg";
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.hypot(dx, dy);
const angle = Math.atan2(dy, dx);
div.style.left = `${a.x}px`;
div.style.top = `${a.y - width / 2}px`;
div.style.width = `${length}px`;
div.style.height = `${width}px`;
div.style.background = colour;
div.style.transform = offset === 0
? `rotate(${angle}rad)`
: `rotate(${angle}rad) translateY(${offset}px)`;
div.style.borderRadius = `${width / 2}px`;
return div;
}
export function overviewColour(tile) {
if (tile.terrainType && OVERVIEW_COLOURS[tile.terrainType]) {
return OVERVIEW_COLOURS[tile.terrainType];
}
return tile.terrainClass === "Sea" ? OVERVIEW_SEA : OVERVIEW_COLOURS.Land;
}
// Mixes two #rrggbb colours, `t` being how much of `b` to use.
export function blendColours(a, b, t) {
const ca = parseColour(a);
const cb = parseColour(b);
const mix = (i) => Math.round(ca[i] + (cb[i] - ca[i]) * t);
return `rgb(${mix(0)}, ${mix(1)}, ${mix(2)})`;
}
function parseColour(value) {
const hex = value.charAt(0) === "#" ? value.slice(1) : value;
return [
parseInt(hex.slice(0, 2), 16),
parseInt(hex.slice(2, 4), 16),
parseInt(hex.slice(4, 6), 16),
];
}
export function clamp(value, min, max) {
if (min > max) return (min + max) / 2;
return Math.max(min, Math.min(max, value));
}
+92
View File
@@ -0,0 +1,92 @@
// The city buildings modal: upgrade and demolish the buildings of one city.
import { groupDigits } from "../../../shared/text_format.js";
import {
buildingBuildCost,
buildingRefund,
buildingUpkeep,
} from "../../../shared/rules.js";
import { effectsText } from "./format.js";
export class BuildingsModal {
constructor() {
this.$modal = $("#modal-buildings");
this.$title = $("#buildings-title");
this.$budget = $("#buildings-budget");
this.$list = $("#buildings-list");
this.cityId = 0;
this.cityName = "";
this.buildings = [];
this.rows = [];
this.onBuild = () => {};
this.onDemolish = () => {};
this.$modal.find(".modal-close").on("click", () => this.close());
}
isShowing(cityId) {
return !this.$modal.hasClass("hidden") && this.cityId === cityId;
}
show(city, gdp, budget, buildings, levels) {
this.cityId = city.id;
this.cityName = city.name;
this.buildings = buildings;
this.$title.text(`${city.name} — Buildings`);
this.$list.empty();
this.rows = buildings.map((proto, index) => this._makeRow(proto, index));
this.refresh(gdp, budget, levels);
this.$modal.removeClass("hidden");
}
_makeRow(proto, index) {
const $card = $("<div class='row-card'></div>");
const $info = $("<div class='info'></div>");
const $name = $("<div class='name'></div>");
const $desc = $("<div class='desc'></div>").text(proto.description);
const $effects = $("<div class='effects'></div>");
const $cost = $("<div class='cost'></div>");
$info.append($name, $desc, $effects, $cost);
const $actions = $("<div class='actions'></div>");
const $upgrade = $("<button class='aero'></button>");
const $downgrade = $("<button class='aero'></button>");
$upgrade.on("click", () => this.onBuild(this.cityId, index));
$downgrade.on("click", () => this.onDemolish(this.cityId, index));
$actions.append($upgrade, $downgrade);
$card.append($info, $actions);
this.$list.append($card);
return { proto, index, $name, $effects, $cost, $upgrade, $downgrade };
}
refresh(gdp, budget, levels) {
this.$budget.text(`Budget: €${groupDigits(Math.round(budget))}`);
for (const row of this.rows) {
const level = levels[row.index] || 0;
row.$name.text(`${row.proto.name} — Level ${level} / ${row.proto.maxLevel}`);
row.$effects.text(effectsText(row.proto.effects, level > 0 ? level : 1));
const parts = [];
if (level > 0) parts.push(`Upkeep €${groupDigits(Math.round(buildingUpkeep(row.proto, gdp, level)))}/h`);
if (level < row.proto.maxLevel) {
parts.push(`Build cost €${groupDigits(Math.round(buildingBuildCost(row.proto, gdp, level)))}`);
}
row.$cost.text(parts.join(" · "));
if (level < row.proto.maxLevel) {
const cost = buildingBuildCost(row.proto, gdp, level);
row.$upgrade.text(`Upgrade €${groupDigits(Math.round(cost))}`).prop("disabled", budget < cost);
} else {
row.$upgrade.text("Max level").prop("disabled", true);
}
if (level > 0) {
row.$downgrade
.text(`Downgrade +€${groupDigits(Math.round(buildingRefund(row.proto, gdp, level)))}`)
.prop("disabled", false);
} else {
row.$downgrade.text("Not built").prop("disabled", true);
}
}
}
close() {
this.$modal.addClass("hidden");
this.cityId = 0;
}
}
+34
View File
@@ -0,0 +1,34 @@
// A generic yes/no prompt. `show` takes the callback it should run when the
// player confirms; cancelling (or closing) runs nothing.
export class ConfirmModal {
constructor() {
this.$modal = $("#modal-confirm");
this.$title = $("#confirm-title");
this.$message = $("#confirm-message");
this._onConfirm = null;
this.$modal.find(".modal-close").on("click", () => this.close());
this.$modal.find("#confirm-cancel").on("click", () => this.close());
this.$modal.find("#confirm-ok").on("click", () => {
const callback = this._onConfirm;
this.close();
if (callback) callback();
});
}
isShowing() {
return !this.$modal.hasClass("hidden");
}
show(title, message, onConfirm) {
this.$title.text(title);
this.$message.text(message);
this._onConfirm = onConfirm || null;
this.$modal.removeClass("hidden");
}
close() {
this.$modal.addClass("hidden");
this._onConfirm = null;
}
}
+51
View File
@@ -0,0 +1,51 @@
// Shared formatting helpers for the modal panels.
import { groupDigits } from "../../../shared/text_format.js";
export function formatEffect(effect, level) {
const value = level <= 0 ? 0 : effect.baseValue * Math.pow(level, effect.exponent);
switch (effect.stat) {
case "gdp":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% GDP`;
case "budget_income":
return `${value >= 0 ? "+" : ""}${(value * 100).toFixed(1)}% budget income`;
case "research":
return `${value >= 0 ? "+" : ""}${value.toFixed(2)} research/h`;
case "culture":
return `${value >= 0 ? "+" : ""}${value.toFixed(2)} culture/h`;
default:
return `${effect.stat} ${value.toFixed(2)}`;
}
}
export function effectsText(effects, level) {
const parts = effects.map((e) => formatEffect(e, level));
return parts.length > 0 ? parts.join(" · ") : "No effect";
}
export const SOURCE_LABELS = { building: "building", government: "government", technology: "research" };
export function signedMoney(value) {
return `${value < 0 ? "−" : "+"}€${groupDigits(Math.round(Math.abs(value)))}/h`;
}
export function percent(value) {
return `${value < 0 ? "−" : "+"}${(Math.abs(value) * 100).toFixed(1)}%`;
}
// Renders one news entry as text, resolving civ indices to names and demonyms.
export function describeNews(entry, civs) {
const name = (index) => (civs[index] ? civs[index].name : `Nation ${index + 1}`);
const people = (index) => {
const civ = civs[index];
return civ && civ.demonym ? `The ${civ.demonym}` : name(index);
};
switch (entry.type) {
case "war":
return `${people(entry.actor)} declared war on ${name(entry.target)}`;
case "capture":
return `${people(entry.to)} captured ${entry.city} from ${name(entry.from)}`;
default:
return entry.type;
}
}
+388
View File
@@ -0,0 +1,388 @@
// The nation panel: government, technology, budget, diplomacy and conflicts.
import { compact, groupDigits } from "../../../shared/text_format.js";
import {
governmentChangeCost,
prerequisitesMet,
} from "../../../shared/rules.js";
import { effectsText, signedMoney, percent, SOURCE_LABELS } from "./format.js";
export class NationModal {
constructor() {
this.$modal = $("#modal-nation");
this.$governmentSub = $("#government-sub");
this.$governmentList = $("#government-list");
this.$technologySub = $("#technology-sub");
this.$technologyList = $("#technology-list");
this.$budgetSub = $("#budget-sub");
this.$budgetList = $("#budget-list");
this.$diplomacySub = $("#diplomacy-sub");
this.$diplomacyList = $("#diplomacy-list");
this.$conflictsSub = $("#conflicts-sub");
this.$conflictsList = $("#conflicts-list");
this.governments = [];
this.technologies = [];
this.currentGovernment = 0;
this.researched = new Set();
this.governmentRows = [];
this.technologyRows = [];
this.diplomacy = null;
this.diplomacySort = { key: "name", dir: 1 };
this.conflicts = [];
this.budgetRows = {
income: this._makeBudgetRow("Tax income", "State revenue drawn from the nation's GDP."),
building: this._makeBudgetRow("Building upkeep", "Upkeep of every building across your cities."),
units: this._makeBudgetRow("Military upkeep", "Upkeep of all your units."),
net: this._makeBudgetRow("Net balance", "Projected change to the treasury each hour."),
};
this.onGovernment = () => {};
this.onTechnology = () => {};
this.onDeclareWar = () => {};
this.$modal.find(".modal-close").on("click", () => this.close());
this.$modal.find(".tab").on("click", (event) => {
const tab = $(event.currentTarget).data("tab");
this.$modal.find(".tab").removeClass("active");
$(event.currentTarget).addClass("active");
this.$modal.find(".tab-page").removeClass("active");
$(`#tab-${tab}`).addClass("active");
});
}
isShowing() {
return !this.$modal.hasClass("hidden");
}
show(governments, currentGovernment, technologies, researched, gdp, budget, research, finances, diplomacy = null, conflicts = []) {
this.governments = governments;
this.technologies = technologies;
this.diplomacy = diplomacy;
this.conflicts = conflicts || [];
this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government"));
this.technologyRows = technologies.map((proto, index) => this._makeRow(this.$technologyList, proto, index, "technology"));
this.refresh(currentGovernment, researched, gdp, budget, research, finances, diplomacy, conflicts);
this.$modal.removeClass("hidden");
}
_makeBudgetRow(name, description) {
const $card = $("<div class='row-card'></div>");
const $info = $("<div class='info'></div>");
const $name = $("<div class='name'></div>").text(name);
const $desc = $("<div class='desc'></div>").text(description);
const $details = $("<div class='budget-detail'></div>");
const $value = $("<div class='value'></div>");
$info.append($name, $desc, $details);
$card.append($info, $value);
this.$budgetList.append($card);
return { $value, $details };
}
_renderBudgetRow(row, value, lines) {
row.$value
.text(signedMoney(value))
.toggleClass("positive", value >= 0)
.toggleClass("negative", value < 0);
row.$details.empty();
for (const line of lines) {
row.$details.append($("<div class='budget-line'></div>").text(line));
}
}
_sourceLines(sources, format, empty) {
if (!sources || sources.length === 0) return [empty];
return sources.map((source) => format(source));
}
_makeRow($list, proto, index, kind) {
const $card = $("<div class='row-card'></div>");
const $info = $("<div class='info'></div>");
const $name = $("<div class='name'></div>");
const $desc = $("<div class='desc'></div>").text(proto.description || "");
const $effects = $("<div class='effects'></div>");
const $cost = $("<div class='cost'></div>");
$info.append($name, $desc, $effects, $cost);
const $actions = $("<div class='actions'></div>");
const $action = $("<button class='aero'></button>");
$action.on("click", () => {
if (kind === "government") this.onGovernment(index);
else this.onTechnology(index);
});
$actions.append($action);
$card.append($info, $actions);
$list.append($card);
return { proto, index, kind, $name, $effects, $cost, $action };
}
refresh(currentGovernment, researched, gdp, budget, research, finances = {}, diplomacy = null, conflicts = null) {
this.currentGovernment = currentGovernment;
this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number));
if (diplomacy !== null) this.diplomacy = diplomacy;
if (conflicts !== null) this.conflicts = conflicts || [];
this.$governmentSub.text(
`Government — ${this.governments[currentGovernment] ? this.governments[currentGovernment].name : "None"} · Budget: €${groupDigits(Math.round(budget))}`
);
this.$technologySub.text(`Research points: ${groupDigits(Math.round(research))}`);
const breakdown = finances || {};
const income = breakdown.income || { rate: 0, base: 0, modifier: 0, total: 0, sources: [] };
const building = breakdown.buildingUpkeep || { total: 0, sources: [] };
const units = breakdown.unitUpkeep || { total: 0, sources: [] };
const net = breakdown.net || 0;
this.$budgetSub.text(
`Treasury: €${groupDigits(Math.round(budget))} · Net: ${signedMoney(net)}`
);
this._renderBudgetRow(this.budgetRows.income, income.total, [
`Tax rate ${(income.rate * 100).toFixed(2)}% of GDP — base €${groupDigits(Math.round(income.base))}/h`,
...this._sourceLines(
income.sources,
(s) => `${s.label} (${SOURCE_LABELS[s.kind] || s.kind}) ${percent(s.effect)} → ${signedMoney(s.amount)}`,
"No bonuses"
),
]);
this._renderBudgetRow(
this.budgetRows.building,
-building.total,
this._sourceLines(
building.sources,
(s) => `${s.label} — €${groupDigits(Math.round(s.amount))}/h`,
"No buildings"
)
);
this._renderBudgetRow(
this.budgetRows.units,
-units.total,
this._sourceLines(
units.sources,
(s) => `${s.label} ×${s.count} — €${groupDigits(Math.round(s.amount))}/h`,
"No units"
)
);
this._renderBudgetRow(this.budgetRows.net, net, [
`Income ${signedMoney(income.total)}`,
`Expenses −€${groupDigits(Math.round(building.total + units.total))}/h`,
]);
for (const row of this.governmentRows) {
const proto = row.proto;
row.$name.text(proto.name);
row.$effects.text(effectsText(proto.effects, 1));
if (row.index === currentGovernment) {
row.$cost.text("Currently in power");
row.$action.text("Current government").prop("disabled", true);
} else {
const cost = governmentChangeCost(proto, gdp);
row.$cost.text(`Adoption cost €${groupDigits(Math.round(cost))}`);
row.$action.text(`Adopt €${groupDigits(Math.round(cost))}`).prop("disabled", budget < cost);
}
}
for (const row of this.technologyRows) {
const proto = row.proto;
row.$name.text(proto.name);
row.$effects.text(effectsText(proto.effects, 1));
if (this.researched.has(row.index)) {
row.$cost.text("Researched");
row.$action.text("Researched").prop("disabled", true);
} else if (!prerequisitesMet(proto, this.researched, this.technologies)) {
const names = proto.prerequisites
.map((id) => {
const found = this.technologies.find((t) => t.id === id);
return found ? found.name : id;
})
.join(", ");
row.$cost.text(`Requires: ${names}`);
row.$action.text("Locked").prop("disabled", true);
} else {
row.$cost.text(`Cost ${groupDigits(Math.round(proto.cost))} research points`);
row.$action.text(`Research ${groupDigits(Math.round(proto.cost))}`).prop("disabled", research < proto.cost);
}
}
this._renderDiplomacy();
this._renderConflicts();
}
// Lists every other nation's economy in a sortable table, with its relation
// to the player and a button to declare war. `diplomacy` is
// { civs, localCiv, relations, stats }, where relations is the snapshot's
// [a, b, status] matrix and stats its per-civ { population, gdp, gdpPerCapita }.
// The table starts sorted alphabetically by nation and every header toggles
// the ordering.
_renderDiplomacy() {
if (!this.$diplomacyList || !this.$diplomacyList.length) return;
this.$diplomacyList.empty();
const info = this.diplomacy;
if (!info) return;
const civs = info.civs || [];
const localCiv = info.localCiv;
const wars = new Set();
for (const entry of info.relations || []) {
if (entry[2] !== "war") continue;
if (entry[0] === localCiv) wars.add(entry[1]);
else if (entry[1] === localCiv) wars.add(entry[0]);
}
if (this.$diplomacySub && this.$diplomacySub.length) {
const atWar = wars.size;
this.$diplomacySub.text(
atWar === 0
? "Your nation is at peace with every other nation."
: `Your nation is at war with ${atWar} nation${atWar === 1 ? "" : "s"}.`
);
}
const rows = [];
for (let index = 0; index < civs.length; index++) {
const stats = (info.stats || [])[index] || {};
const self = index === localCiv;
rows.push({
index,
name: civs[index].name,
gdp: stats.gdp || 0,
gdpPerCapita: stats.gdpPerCapita || 0,
population: stats.population || 0,
atWar: self ? false : wars.has(index),
self,
});
}
this._sortDiplomacyRows(rows);
const columns = [
{ key: "name", label: "Nation" },
{ key: "gdp", label: "GDP", numeric: true },
{ key: "gdpPerCapita", label: "GDP / capita", numeric: true },
{ key: "population", label: "Population", numeric: true },
{ key: "status", label: "Status" },
];
const $table = $("<table class='diplomacy-table'></table>");
const $head = $("<thead></thead>");
const $headRow = $("<tr></tr>");
for (const column of columns) {
const $th = $("<th></th>").attr("data-sort", column.key).text(column.label);
if (column.numeric) $th.addClass("numeric");
if (this.diplomacySort.key === column.key) {
const descending = this.diplomacySort.dir < 0;
$th.addClass(descending ? "sorted-desc" : "sorted-asc");
$th.append(
$("<span class='sort-arrow'></span>").text(descending ? "▼" : "▲")
);
}
$th.on("click", () => this._sortDiplomacyBy(column.key));
$headRow.append($th);
}
$head.append($headRow);
const $body = $("<tbody></tbody>");
for (const row of rows) {
const $tr = $("<tr></tr>");
if (row.self) $tr.addClass("local-nation");
$tr.append($("<td class='name'></td>").text(row.name));
$tr.append($("<td class='numeric'></td>").text(`€${compact(row.gdp)}`));
$tr.append($("<td class='numeric'></td>").text(`€${compact(row.gdpPerCapita)}`));
$tr.append($("<td class='numeric'></td>").text(compact(row.population)));
const $status = $("<td class='status'></td>");
if (row.self) {
$status.append($("<span class='relation'></span>").text("Your nation"));
} else {
$status.append(
$("<span class='relation'></span>").text(row.atWar ? "At war" : "At peace")
);
const $action = $("<button class='aero'></button>");
if (row.atWar) {
$action.text("At war").prop("disabled", true);
} else {
$action.text("Declare war");
$action.on("click", () => this.onDeclareWar(row.index));
}
$status.append($action);
}
$tr.append($status);
$body.append($tr);
}
$table.append($head, $body);
this.$diplomacyList.append($table);
}
// Clicking a header sorts by it, reversing the order when it is already the
// active column.
_sortDiplomacyBy(key) {
if (this.diplomacySort.key === key) {
this.diplomacySort.dir *= -1;
} else {
this.diplomacySort.key = key;
this.diplomacySort.dir = 1;
}
this._renderDiplomacy();
}
_sortDiplomacyRows(rows) {
const { key, dir } = this.diplomacySort;
rows.sort((a, b) => {
let result;
if (key === "name") result = a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
else if (key === "status") result = (a.atWar ? 1 : 0) - (b.atWar ? 1 : 0);
else result = a[key] - b[key];
if (result === 0) result = a.index - b.index;
return dir * result;
});
}
// Lists every ongoing war between the world's nations, whoever is fighting.
// Clicking a conflict reveals the date it began and the military and civilian
// dead on each side.
_renderConflicts() {
if (!this.$conflictsList || !this.$conflictsList.length) return;
this.$conflictsList.empty();
const conflicts = this.conflicts || [];
if (this.$conflictsSub && this.$conflictsSub.length) {
this.$conflictsSub.text(
conflicts.length === 0
? "No ongoing conflicts."
: `${conflicts.length} ongoing conflict${conflicts.length === 1 ? "" : "s"} between the world's nations.`
);
}
if (conflicts.length === 0) {
this.$conflictsList.append(
$("<div class='desc'></div>").text("The world is at peace.")
);
return;
}
for (const conflict of conflicts) this._makeConflictRow(conflict);
}
_makeConflictRow(conflict) {
const $card = $("<div class='row-card conflict-card'></div>");
const $info = $("<div class='info'></div>");
const $name = $("<div class='name'></div>").text(
`${this._civName(conflict.a)} vs ${this._civName(conflict.b)}`
);
const $desc = $("<div class='desc'></div>").text(`Ongoing since ${conflict.started}`);
$info.append($name, $desc);
const $detail = $("<div class='conflict-detail hidden'></div>");
$detail.append(
$("<div class='conflict-line'></div>").text(`Conflict started ${conflict.started}`)
);
for (const side of conflict.sides || []) {
$detail.append(
$("<div class='conflict-side'></div>").text(
`${this._civName(side.civ)} — Military: ${groupDigits(side.military || 0)} · ` +
`Civilians: ${groupDigits(side.civilians || 0)}`
)
);
}
$card.append($info, $detail);
$card.on("click", () => $detail.toggleClass("hidden"));
this.$conflictsList.append($card);
}
_civName(index) {
const civ = this.diplomacy && this.diplomacy.civs ? this.diplomacy.civs[index] : null;
return civ ? civ.name : `Nation ${index + 1}`;
}
close() {
this.$modal.addClass("hidden");
}
}
+41
View File
@@ -0,0 +1,41 @@
// The news feed: a reverse-chronological list of newsworthy events. Civ indices
// are resolved to names through the catalogue the game screen passes in.
import { describeNews } from "./format.js";
export class NewsModal {
constructor() {
this.$modal = $("#modal-news");
this.$list = $("#news-list");
this.$modal.find(".modal-close").on("click", () => this.close());
}
isShowing() {
return !this.$modal.hasClass("hidden");
}
show(news, civs) {
this.refresh(news, civs);
this.$modal.removeClass("hidden");
}
refresh(news, civs = []) {
if (!this.$list || !this.$list.length) return;
this.$list.empty();
const entries = (news || []).slice().reverse();
if (entries.length === 0) {
this.$list.append($("<div class='news-empty'></div>").text("No news yet."));
return;
}
for (const entry of entries) {
const $item = $("<div class='news-item'></div>");
$item.append($("<div class='news-text'></div>").text(describeNews(entry, civs)));
$item.append($("<div class='news-date'></div>").text(entry.date || ""));
this.$list.append($item);
}
}
close() {
this.$modal.addClass("hidden");
}
}
+126
View File
@@ -0,0 +1,126 @@
// The city training modal: choose a unit to build and manage the city's queue.
import { groupDigits, hours } from "../../../shared/text_format.js";
import { ECONOMY } from "../../../shared/data.js";
export class TrainModal {
constructor() {
this.$modal = $("#modal-train");
this.$title = $("#train-title");
this.$list = $("#train-list");
this.$status = $("#train-status");
this.$queue = $("#train-queue");
this.$label = $("#train-label");
this.$progress = $("#train-progress");
this.cityId = 0;
this.gdp = 0;
this.protoNames = new Map();
this.onTrain = () => {};
this.onCancel = () => {};
this.$modal.find(".modal-close").on("click", () => this.close());
}
isShowing(cityId) {
return !this.$modal.hasClass("hidden") && this.cityId === cityId;
}
show(city, gdp, trainableUnits, queue = []) {
this.cityId = city.id;
this.gdp = gdp;
this.protoNames = new Map();
this.$title.text(`${city.name} — Train`);
this.$list.empty();
this.$status.addClass("hidden");
for (let index = 0; index < trainableUnits.length; index++) {
const proto = trainableUnits[index];
if (!proto.cost && proto.cost !== 0) continue;
const button = $("<button class='aero'></button>");
const duration = ECONOMY.trainingHours(gdp, proto.cost);
button.text(`${proto.name} — Cost ${groupDigits(proto.cost)} — ${hours(duration)}`);
const serverIndex = proto.serverIndex !== undefined ? proto.serverIndex : index;
this.protoNames.set(serverIndex, proto.name);
button.on("click", () => this.onTrain(city.id, serverIndex));
this.$list.append(button);
}
this.renderQueue(queue);
this.$modal.removeClass("hidden");
}
// Draws the city's training queue: one row per run of contiguous identical
// units, with the cumulated hours until each run is ready and a button to
// cancel its last unit. Cancelling refunds nothing.
renderQueue(queue) {
if (!this.$queue || !this.$queue.length) return;
this.$queue.empty();
if (!queue || queue.length === 0) {
this.$queue.append(
$("<div class='desc queue-empty'></div>").text("No units queued.")
);
return;
}
let cumulative = 0;
groupQueue(queue).forEach((group, groupIndex) => {
let firstReady = 0;
for (const index of group.indices) {
const entry = queue[index];
const elapsed = index === 0 ? (entry.elapsedHours || 0) : 0;
cumulative += Math.max(entry.totalHours - elapsed, 0);
if (index === group.indices[0]) firstReady = cumulative;
}
const count = group.indices.length;
const name = this.protoNames.get(group.protoIndex) || "Unit";
const label = count > 1 ? `${name} ×${count}` : name;
const $row = $("<div class='row-card queue-row'></div>");
const $info = $("<div class='info'></div>");
$info.append($("<div class='name'></div>").text(`${groupIndex + 1}. ${label}`));
$info.append(
$("<div class='desc'></div>").text(
count > 1
? `Next in ${hours(firstReady)} · all ready in ${hours(cumulative)}`
: `Ready in ${hours(cumulative)}`
)
);
const $actions = $("<div class='actions'></div>");
const $cancel = $("<button class='aero'></button>").text(count > 1 ? "Cancel last" : "Cancel");
$cancel.on("click", () => this.onCancel(this.cityId, group.indices[group.indices.length - 1]));
$actions.append($cancel);
$row.append($info, $actions);
this.$queue.append($row);
});
}
beginTraining(name, totalHours, elapsedHours = 0) {
this.$label.text(`Training ${name}…`);
this.$progress.css("width", `${Math.min(100, (elapsedHours / Math.max(totalHours, 0.0001)) * 100)}%`);
this.$status.removeClass("hidden");
}
setTrainingProgress(name, elapsedHours, totalHours) {
const remaining = Math.max(0, Math.ceil(totalHours - elapsedHours));
this.$label.text(`Training ${name}… (${remaining} h left)`);
this.$progress.css("width", `${Math.min(100, (elapsedHours / Math.max(totalHours, 0.0001)) * 100)}%`);
}
endTraining() {
this.$label.text("Training complete");
this.$progress.css("width", "100%");
}
close() {
this.$modal.addClass("hidden");
this.cityId = 0;
}
}
// Runs of contiguous queue entries with the same unit type, as lists of queue
// indices. Non-adjacent entries of the same type stay separate so a row only
// ever represents the units sitting together.
function groupQueue(queue) {
const groups = [];
queue.forEach((entry, index) => {
const last = groups[groups.length - 1];
if (last && last.protoIndex === entry.protoIndex) last.indices.push(index);
else groups.push({ protoIndex: entry.protoIndex, indices: [index] });
});
return groups;
}
+95
View File
@@ -0,0 +1,95 @@
// Buildings that can be raised in a city. Costs and upkeep scale with the
// city's share of national GDP, so early levels are cheap and later ones bite.
import { EFFECT_GDP, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
export const BUILDINGS = [
{
id: "market",
name: "Market",
description: "A hub of local trade. Raises national GDP, with diminishing returns.",
maxLevel: 10,
baseCost: 0,
costGdpFraction: 0.01,
costAlpha: 1.6,
baseUpkeep: 0,
upkeepGdpFraction: 0.00003,
upkeepAlpha: 1.5,
refundFraction: 0.5,
effects: [effect(EFFECT_GDP, 0.04, 0.75)],
},
{
id: "bank",
name: "Bank",
description: "Channels private wealth into the state coffers. Raises budget income.",
maxLevel: 10,
baseCost: 0,
costGdpFraction: 0.02,
costAlpha: 1.7,
baseUpkeep: 0,
upkeepGdpFraction: 0.00004,
upkeepAlpha: 1.6,
refundFraction: 0.5,
effects: [effect(EFFECT_BUDGET_INCOME, 0.05, 0.7)],
},
{
id: "research_lab",
name: "Research lab",
description: "Applies science to industry and government. Produces research points.",
maxLevel: 10,
baseCost: 0,
costGdpFraction: 0.015,
costAlpha: 1.65,
baseUpkeep: 0,
upkeepGdpFraction: 0.00005,
upkeepAlpha: 1.55,
refundFraction: 0.5,
effects: [effect(EFFECT_RESEARCH, 1.0, 0.9)],
},
{
id: "university",
name: "University",
description: "Higher education produces research and a little culture.",
maxLevel: 10,
baseCost: 0,
costGdpFraction: 0.025,
costAlpha: 1.7,
baseUpkeep: 0,
upkeepGdpFraction: 0.00008,
upkeepAlpha: 1.6,
refundFraction: 0.5,
effects: [effect(EFFECT_RESEARCH, 2.0, 0.8), effect(EFFECT_CULTURE, 0.5, 0.7)],
},
{
id: "museum",
name: "Museum",
description: "Preserves national heritage, strengthening culture against foreign ideas.",
maxLevel: 10,
baseCost: 0,
costGdpFraction: 0.02,
costAlpha: 1.6,
baseUpkeep: 0,
upkeepGdpFraction: 0.00005,
upkeepAlpha: 1.55,
refundFraction: 0.5,
effects: [effect(EFFECT_CULTURE, 1.5, 0.85)],
},
{
id: "cinema_studio",
name: "Cinema studio",
description: "Mass media spreads the nation's culture at home and abroad.",
maxLevel: 10,
baseCost: 0,
costGdpFraction: 0.03,
costAlpha: 1.75,
baseUpkeep: 0,
upkeepGdpFraction: 0.0001,
upkeepAlpha: 1.65,
refundFraction: 0.5,
effects: [effect(EFFECT_CULTURE, 3.0, 0.7)],
},
];
export function buildingById(id) {
return BUILDINGS.find((b) => b.id === id) || null;
}
+149
View File
@@ -0,0 +1,149 @@
// The playable civilisations: colours, flag art and the pool of city names
// drawn on when settlements are placed.
export const CIVILISATIONS = [
{
id: "france",
name: "France",
demonym: "French",
primaryColour: "#0d0d5c",
accentColour: "#8c8c82",
flag: "flag_france.png",
cityNames: ["Lyon", "Marseille", "Toulouse", "Nice", "Nantes", "Montpellier", "Strasbourg", "Bordeaux", "Lille"],
},
{
id: "britain",
name: "Britain",
demonym: "British",
primaryColour: "#a81818",
accentColour: "#0722ff",
flag: "flag_britain.png",
cityNames: ["Birmingham", "Manchester", "Glasgow", "Liverpool", "Leeds", "Sheffield", "Edinburgh", "Bristol", "Cardiff"],
},
{
id: "slovenia",
name: "Slovenia",
demonym: "Slovenian",
primaryColour: "#005da4",
accentColour: "#ed1c24",
flag: "flag_slovenia.png",
cityNames: ["Ljubljana", "Maribor", "Celje", "Kranj", "Koper", "Velenje", "Novo Mesto", "Ptuj", "Kamnik", "Jesenice"],
},
{
id: "poland",
name: "Poland",
demonym: "Polish",
primaryColour: "#a6000b",
accentColour: "#ffffff",
flag: "flag_poland.png",
cityNames: ["Warsaw", "Kraków", "Łódź", "Wrocław", "Poznań", "Gdańsk", "Szczecin", "Bydgoszcz", "Lublin", "Katowice"],
},
{
id: "switzerland",
name: "Switzerland",
demonym: "Swiss",
primaryColour: "#da291c",
accentColour: "#ffffff",
flag: "flag_switzerland.png",
cityNames: ["Bern", "Zürich", "Geneva", "Basel", "Lausanne", "Lucerne", "Winterthur", "St. Gallen", "Lugano", "Biel"],
},
{
id: "ticino",
name: "Ticino",
demonym: "Ticinese",
primaryColour: "#4646e0",
accentColour: "#a6000b",
flag: "flag_ticino.png",
cityNames: ["Bellinzona", "Lugano", "Locarno", "Mendrisio", "Chiasso", "Airolo", "Biasca", "Ascona", "Faido", "Giubiasco"],
},
{
id: "galicia",
name: "Galicia",
demonym: "Galician",
primaryColour: "#006dad",
accentColour: "#ffffff",
flag: "flag_galicia.png",
cityNames: ["Santiago de Compostela", "Vigo", "A Coruña", "Ourense", "Lugo", "Pontevedra", "Ferrol", "Vilagarcía de Arousa", "Ribeira", "Redondela"],
},
{
id: "germany",
name: "Germany",
demonym: "German",
primaryColour: "#141414",
accentColour: "#ffce00",
flag: "flag_germany.png",
cityNames: ["Berlin", "Hamburg", "Munich", "Cologne", "Frankfurt", "Stuttgart", "Düsseldorf", "Leipzig", "Dortmund", "Essen"],
},
{
id: "iran",
name: "Iran",
demonym: "Iranian",
primaryColour: "#18a645",
accentColour: "#f01c21",
flag: "flag_iran.png",
cityNames: ["Tehran", "Mashhad", "Isfahan", "Karaj", "Shiraz", "Tabriz", "Qom", "Ahvaz", "Kermanshah", "Urmia"],
},
{
id: "usa",
name: "United States",
demonym: "American",
primaryColour: "#3c3b6e",
accentColour: "#b22234",
flag: "flag_usa.png",
cityNames: ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix", "Philadelphia", "San Antonio", "San Diego", "Dallas", "San Jose"],
},
{
id: "aotearoa",
name: "Aotearoa",
demonym: "New Zealander",
primaryColour: "#00247d",
accentColour: "#cc142b",
flag: "flag_aotearoa.png",
cityNames: ["Auckland", "Wellington", "Christchurch", "Hamilton", "Tauranga", "Dunedin", "Palmerston North", "Napier", "Nelson", "Rotorua"],
},
{
id: "new_hampshire",
name: "New Hampshire",
demonym: "New Hampshirite",
primaryColour: "#12355b",
accentColour: "#f2b705",
flag: "flag_new_hampshire.png",
cityNames: ["Manchester", "Nashua", "Concord", "Derry", "Dover", "Rochester", "Salem", "Merrimack", "Portsmouth", "Laconia"],
},
{
id: "florida",
name: "Florida",
demonym: "Floridian",
primaryColour: "#782f40",
accentColour: "#ceb888",
flag: "flag_florida.png",
cityNames: ["Jacksonville", "Miami", "Tampa", "Orlando", "St. Petersburg", "Hialeah", "Tallahassee", "Fort Lauderdale", "Port St. Lucie", "Cape Coral"],
},
{
id: "australia",
name: "Australia",
demonym: "Australian",
primaryColour: "#00843d",
accentColour: "#ffcd00",
flag: "flag_australia.png",
cityNames: ["Sydney", "Melbourne", "Brisbane", "Perth", "Adelaide", "Gold Coast", "Canberra", "Newcastle", "Wollongong", "Hobart"],
},
{
id: "uruguay",
name: "Uruguay",
demonym: "Uruguayan",
primaryColour: "#0038a8",
accentColour: "#fcd116",
flag: "flag_uruguay.png",
cityNames: ["Montevideo", "Salto", "Paysandú", "Las Piedras", "Rivera", "Maldonado", "Tacuarembó", "Melo", "Artigas", "Mercedes"],
},
{
id: "vietnam",
name: "Vietnam",
demonym: "Vietnamese",
primaryColour: "#da251d",
accentColour: "#ffff00",
flag: "flag_vietnam.png",
cityNames: ["Ho Chi Minh City", "Hanoi", "Haiphong", "Da Nang", "Can Tho", "Bien Hoa", "Hue", "Nha Trang", "Buon Ma Thuot", "Vung Tau"],
},
];
+41
View File
@@ -0,0 +1,41 @@
// Combat, garrison and pillage rules. Shared so the server (which applies them)
// and the browser (which forecasts them) compute the same numbers.
// Units garrisoned in a friendly city regain this many hit points each hour.
export const CITY_GARRISON_HEAL_PER_HOUR = 1;
// Combat is fought on a single tile: the attacking stack charges onto the
// defending stack's tile and the two share it until one side is wiped out.
// Every day a battle lasts the tile loses this fraction of its population and
// of its GDP per capita. Once the fighting stops, GDP per capita recovers by
// `recoveryPerDay` each day, starting `recoveryDelayDays` after the last battle.
export const BATTLE = {
populationLossPerDay: 0.3,
gdpPerCapitaLossPerDay: 0.3,
recoveryDelayDays: 30,
recoveryPerDay: 100,
};
// Pillaging: each unit removes this fraction of the tile's remaining
// population, cuts its GDP per capita by this multiplier, and heals one hit
// point per this many people driven off.
export const PILLAGE = {
populationLoss: 0.1,
gdpPenalty: 0.7,
healPerPeople: 1000,
};
// The result of pillaging a tile with `count` units in sequence, starting from
// its current population. Each step removes a tenth of what is left and heals
// for the people it removed; the GDP-per-capita multiplier compounds. Shared so
// the server and the forecast tooltip compute the same numbers.
export function pillageSteps(population, count) {
const steps = [];
let remaining = population;
for (let i = 0; i < count; i++) {
const removed = remaining * PILLAGE.populationLoss;
remaining -= removed;
steps.push({ removed, remaining, heal: removed / PILLAGE.healPerPeople });
}
return { steps, gdpMultiplier: Math.pow(PILLAGE.gdpPenalty, count) };
}
+35
View File
@@ -0,0 +1,35 @@
// National economy constants and the pure formulas the server and the browser
// both use to derive production, training time, budget income and population
// growth.
export const ECONOMY = {
baseGdpPerCapita: 10_000,
basePopulation: 100_000,
cityTileGdpMultiplier: 2.0,
capitalTileGdpMultiplier: 2.5,
citySurroundingGdpBonus: 0.3,
gdpPerProductionPoint: 100_000,
budgetGdpRate: 0.001,
startingBudget: 0,
basePopulationGrowthRate: 0.005,
productionCapacity(gdp) {
if (this.gdpPerProductionPoint <= 0) return 0;
return gdp / this.gdpPerProductionPoint;
},
trainingHours(gdp, cost) {
const capacity = this.productionCapacity(gdp);
if (capacity <= 0) return 0;
return cost / capacity;
},
budgetIncome(gdp, hours = 1) {
return gdp * this.budgetGdpRate * hours;
},
populationGrowthFactor(hoursPerYear, hours = 1) {
if (this.basePopulationGrowthRate <= 0 || hoursPerYear <= 0 || hours <= 0) return 0;
return Math.pow(1 + this.basePopulationGrowthRate, hours / hoursPerYear) - 1;
},
};
+19
View File
@@ -0,0 +1,19 @@
// Stat effects shared by every data-driven entry (buildings, governments,
// technologies). An effect scales with a level so a building's benefit grows
// with each upgrade.
export const EFFECT_GDP = "gdp";
export const EFFECT_RESEARCH = "research";
export const EFFECT_CULTURE = "culture";
export const EFFECT_BUDGET_INCOME = "budget_income";
export function effect(stat, baseValue, exponent = 1.0) {
return { stat, baseValue, exponent };
}
// The value of an effect at `level`. A level of zero or less contributes
// nothing, so an unbuilt building never has an effect.
export function effectValue(e, level) {
if (level <= 0) return 0;
return e.baseValue * Math.pow(level, e.exponent);
}
+35
View File
@@ -0,0 +1,35 @@
// Forms of government a civilisation may adopt. Each grants a flat modifier to
// the stats it favours.
import { EFFECT_GDP, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
export const GOVERNMENTS = [
{
id: "republic",
name: "Republic",
description: "Rule by elected representatives. A balanced government that spreads its support across the economy, the treasury and the sciences.",
baseCost: 0,
costGdpFraction: 0.02,
effects: [effect(EFFECT_GDP, 0.05, 1.0), effect(EFFECT_BUDGET_INCOME, 0.1, 1.0), effect(EFFECT_RESEARCH, 1.0, 1.0)],
},
{
id: "monarchy",
name: "Monarchy",
description: "Rule by a hereditary sovereign. The crown favours commerce and tradition, lifting the economy and the nation's cultural output.",
baseCost: 0,
costGdpFraction: 0.03,
effects: [effect(EFFECT_GDP, 0.1, 1.0), effect(EFFECT_CULTURE, 1.0, 1.0)],
},
{
id: "technocracy",
name: "Technocracy",
description: "Rule by technical experts. A single-minded drive for progress pours resources into research while economic growth takes second place.",
baseCost: 0,
costGdpFraction: 0.04,
effects: [effect(EFFECT_RESEARCH, 3.0, 1.0), effect(EFFECT_GDP, 0.03, 1.0)],
},
];
export function governmentById(id) {
return GOVERNMENTS.find((g) => g.id === id) || null;
}
+16
View File
@@ -0,0 +1,16 @@
// Catalogue barrel. Every module re-exports its slice of the game's data here,
// so callers can import from a single place while each concern lives in its own
// small file.
export * from "./effects.js";
export * from "./relations.js";
export * from "./civilisations.js";
export * from "./units.js";
export * from "./buildings.js";
export * from "./governments.js";
export * from "./technologies.js";
export * from "./statuses.js";
export * from "./combat.js";
export * from "./economy.js";
export * from "./map_config.js";
export * from "./terrain.js";
+50
View File
@@ -0,0 +1,50 @@
// World-generation configuration. The seed is overridden per game; the rest
// tunes the shape and populating of the map.
export const MAP_CONFIG = {
mapSize: { x: 100, y: 100 },
topology: "cylindrical",
terrainSeed: 0,
seaLevel: -0.05,
falloffEnabled: true,
falloffStart: 0.55,
falloffStrength: 0.6,
iceLatitude: 0.9,
noise: {
frequency: 0.02,
octaves: 1,
lacunarity: 2.0,
gain: 0.5,
},
// The finer field that roughens the coastline, and how strongly it bends the
// shore away from the smooth continent outline.
coastNoise: {
frequency: 0.09,
octaves: 2,
lacunarity: 2.0,
gain: 0.5,
},
coastRoughness: 0.4,
// Third pass: clusters of terrain laid over the plains, independent of the
// continent shape so no terrain type follows the landmasses. `coverage` is
// the share of the land aimed for and `size` the tiles per cluster (jittered
// per cluster). Earlier entries take priority where clusters overlap.
terrainBiomes: [
{ terrainType: "Mountain", coverage: 0.03, size: 10 },
{ terrainType: "Hills", coverage: 0.1, size: 26 },
{ terrainType: "Forest", coverage: 0.16, size: 40 },
{ terrainType: "Desert", coverage: 0.08, size: 48 },
],
capitalMargin: 2,
minCapitalDistance: 8,
minCapitalLandmass: 40,
// Landmasses and seas smaller than these fractions of the map are smoothed
// away, so the world reads as a few big continents rather than speckle. The
// thresholds scale with the map, so tiny test maps are left alone.
minIslandFraction: 0.0015,
minLakeFraction: 0.0015,
connectOceans: true,
citiesPerCiv: 10,
// Minimum distance between any two cities during generation (never below 5).
citySpacing: 5,
};
+12
View File
@@ -0,0 +1,12 @@
// Diplomatic relations between two civilisations. Only peace and war exist for
// now, but the catalogue is data-driven so further statuses (alliances,
// truces, ...) can be slotted in without touching the simulation.
export const RELATION_PEACE = "peace";
export const RELATION_WAR = "war";
export const RELATIONS = [RELATION_PEACE, RELATION_WAR];
// Kinds of news item shown in the feed. Newsworthy events are recorded in
// GameState with their in-game hour so every client can render them.
export const NEWS_WAR = "war";
export const NEWS_CAPTURE = "capture";
+40
View File
@@ -0,0 +1,40 @@
// Status effects a unit or city can carry. They are data-driven so new ones can
// be added without touching the simulation or the renderer: the server derives
// the active ids, applies their fields, and ships the ids in snapshots; the
// browser draws one icon per id.
//
// A status may define `hpLossPerDay` (a fraction of maximum hit points taken
// each day), a `cityHpLossPerDay` override for when the unit stands in a city,
// and an `upkeepMultiplier` applied to the unit's upkeep while it is active.
export const STATUS_ENCIRCLED = "encircled";
export const STATUS_BESIEGED = "besieged";
export const STATUS_BATTLE = "battle";
export const STATUSES = [
{
id: STATUS_ENCIRCLED,
label: "Encircled",
icon: "encirclement.svg",
description: "Cut off from every allied city while a hostile unit is adjacent. Loses 10% of its maximum hit points each day, or 2% inside a city.",
hpLossPerDay: 0.10,
cityHpLossPerDay: 0.02,
},
{
id: STATUS_BESIEGED,
label: "Besieged",
icon: "encirclement.svg",
description: "Cut off from every other allied city while a hostile unit is adjacent.",
},
{
id: STATUS_BATTLE,
label: "In battle",
icon: "icon_battle.svg",
description: "Fighting an enemy unit on the same tile. Upkeep is tripled and the unit cannot pillage while the battle rages.",
upkeepMultiplier: 3,
},
];
export function statusById(id) {
return STATUSES.find((status) => status.id === id) || null;
}
+51
View File
@@ -0,0 +1,51 @@
// Research tree. A technology costs research points and may require earlier
// technologies before it can be started.
import { EFFECT_GDP, EFFECT_BUDGET_INCOME, EFFECT_RESEARCH, EFFECT_CULTURE, effect } from "./effects.js";
export const TECHNOLOGIES = [
{
id: "scientific_method",
name: "Scientific Method",
description: "A disciplined approach to experiment and evidence. Lays the groundwork for every later technology.",
cost: 20,
prerequisites: [],
effects: [effect(EFFECT_RESEARCH, 1.0, 1.0), effect(EFFECT_GDP, 0.02, 1.0)],
},
{
id: "industrial_automation",
name: "Industrial Automation",
description: "Robotics and control systems take over the factory floor, lifting national output.",
cost: 60,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_GDP, 0.08, 1.0)],
},
{
id: "renewable_energy",
name: "Renewable Energy",
description: "Clean generation cuts waste and strengthens the public finances.",
cost: 80,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_GDP, 0.03, 1.0), effect(EFFECT_BUDGET_INCOME, 0.05, 1.0)],
},
{
id: "quantum_computing",
name: "Quantum Computing",
description: "Machines that reason in superposition. A step change in the pace of discovery.",
cost: 120,
prerequisites: ["scientific_method"],
effects: [effect(EFFECT_RESEARCH, 4.0, 1.0)],
},
{
id: "space_program",
name: "Space Program",
description: "Reaching orbit inspires the nation and spins off technology across the economy.",
cost: 300,
prerequisites: ["industrial_automation", "quantum_computing"],
effects: [effect(EFFECT_CULTURE, 3.0, 1.0), effect(EFFECT_GDP, 0.05, 1.0)],
},
];
export function technologyById(id) {
return TECHNOLOGIES.find((t) => t.id === id) || null;
}
+19
View File
@@ -0,0 +1,19 @@
// The terrain TileSet, flattened. `col` is the atlas column in terrain.png
// (one row, 7 tiles of 63x56); the class decides land/sea/ice. The generator
// lays terrain types out as biome clusters over the land, so `minElevation` is
// only the authored ordering hint and no longer picks tiles. `defenseBonus` is
// the fraction added to a unit's defense while it stands on the tile.
export const TERRAIN_TILES = [
{ col: 0, terrainType: "Forest", terrainClass: "Land", populationMultiplier: 0.3, gdpMultiplier: 0.5, movementCostMultiplier: 1.5, defenseBonus: 0.33, minElevation: 0.06 },
{ col: 1, terrainType: "Hills", terrainClass: "Land", populationMultiplier: 0.7, gdpMultiplier: 0.8, movementCostMultiplier: 1.8, defenseBonus: 0.33, minElevation: 0.2 },
{ col: 2, terrainType: "Ice", terrainClass: "Ice", populationMultiplier: 0, gdpMultiplier: 0, movementCostMultiplier: 4.0, defenseBonus: 0, minElevation: 0 },
{ col: 3, terrainType: "Land", terrainClass: "Land", populationMultiplier: 1.0, gdpMultiplier: 1.0, movementCostMultiplier: 1.0, defenseBonus: 0, minElevation: -0.08 },
{ col: 4, terrainType: "Desert", terrainClass: "Land", populationMultiplier: 0.05, gdpMultiplier: 0.2, movementCostMultiplier: 3.0, defenseBonus: 0, minElevation: -0.2 },
{ col: 5, terrainType: "Sea", terrainClass: "Sea", populationMultiplier: 0, gdpMultiplier: 0, movementCostMultiplier: 0, defenseBonus: 0, minElevation: 0 },
{ col: 6, terrainType: "Mountain", terrainClass: "Land", populationMultiplier: 0.1, gdpMultiplier: 0.8, movementCostMultiplier: 5.0, defenseBonus: 1.0, minElevation: 0.34 },
];
// A city standing on a tile adds this fraction on top of the terrain's own
// defensive bonus.
export const CITY_DEFENSE_BONUS = 0.5;
+62
View File
@@ -0,0 +1,62 @@
// Unit archetypes. `military` units fight, capture cities and may enter an
// enemy military unit's tile. `attack` is the damage dealt per combat round and
// `defense` the damage absorbed per round; a defender's defense is doubled (the
// 100% defensive bonus). `range` is how many tiles away the unit can strike
// (1 is melee).
export const PROTO_UNITS = [
{
id: "modern_infantry",
name: "Modern infantry",
maxHp: 500,
cost: 100_000_000,
upkeep: 5000,
moveable: true,
speed: 0.5,
military: true,
attack: 100,
defense: 40,
range: 1,
traversableTerrains: ["Land"],
icon: "icon_modern_infantry.svg",
},
{
id: "worker",
name: "Worker",
maxHp: 100,
cost: 0,
upkeep: 0,
moveable: true,
speed: 0.5,
military: false,
attack: 0,
defense: 0,
range: 1,
traversableTerrains: ["Land"],
icon: "icon_settler.svg",
},
{
id: "artillery",
name: "Artillery",
maxHp: 400,
cost: 300_000_000,
upkeep: 12_000,
moveable: true,
speed: 0.25,
military: true,
attack: 250,
// Barely armoured: nearly every hit gets through.
defense: 5,
// Outranges infantry, so melee units cannot strike back from two tiles.
range: 2,
traversableTerrains: ["Land"],
icon: "icon_artillery.svg",
},
];
// Only units in this list can be trained from the city screen.
export const TRAINABLE_UNIT_IDS = ["modern_infantry", "artillery"];
export function protoUnitById(id) {
return PROTO_UNITS.find((u) => u.id === id) || null;
}
+305
View File
@@ -0,0 +1,305 @@
// Combat: battle detection, the daily combat round, captures, daily attrition
// and the lasting scars a battlefield leaves on a tile.
import { key } from "../hex.js";
import { BATTLE } from "../data/combat.js";
import { STATUS_BATTLE, statusById } from "../data/statuses.js";
import { defenseMultiplier } from "../rules.js";
import { DEFENDER_DEFENSE_MULTIPLIER, HOURS_PER_DAY } from "./constants.js";
export const combatMethods = {
isMilitary(unit) {
return this._isMilitary(unit);
},
_isMilitary(unit) {
const proto = this.unitProto(unit);
return !!(proto && proto.military);
},
_unitAttack(unit) {
const proto = this.unitProto(unit);
return proto && typeof proto.attack === "number" ? proto.attack : 0;
},
_unitDefense(unit) {
const proto = this.unitProto(unit);
return proto && typeof proto.defense === "number" ? proto.defense : 0;
},
// Fraction of extra defense granted by the tile a unit occupies (terrain
// plus any city), as a multiplier.
_defenseMultiplierAt(coords) {
const tile = this.tiles[key(coords.x, coords.y)];
return defenseMultiplier(tile, !!this.cityAt(coords));
},
// A unit's base defense, boosted by its tile, and doubled when it is the
// defending side of the exchange.
_effectiveDefense(unit, isDefender) {
let defense = this._unitDefense(unit) * this._defenseMultiplierAt(unit.coords);
if (isDefender) defense *= DEFENDER_DEFENSE_MULTIPLIER;
return defense;
},
// Unique attacker/defender pairs. A pair is two hostile military units on the
// same tile; the lower id is treated as the attacker so both sides trade one
// blow rather than two.
_combatPairs() {
const pairs = new Map();
for (const unit of this.units) {
const defender = this._combatTarget(unit);
if (!defender) continue;
const pairKey = unit.id < defender.id
? `${unit.id}:${defender.id}`
: `${defender.id}:${unit.id}`;
if (!pairs.has(pairKey)) pairs.set(pairKey, { attacker: unit, defender });
}
return Array.from(pairs.values());
},
// The enemy military unit on the given unit's own tile it trades blows with,
// or null. With every combatant on one tile this is simply the weakest
// hostile military unit there, so a stack focuses its fire.
_combatTarget(unit) {
if (!this._isMilitary(unit)) return null;
const enemies = this.unitsAt(unit.coords).filter(
(other) => other.id !== unit.id && other.civ !== unit.civ && this._isMilitary(other)
);
if (enemies.length === 0) return null;
enemies.sort((a, b) => (a.hp - b.hp) || (a.id - b.id));
return enemies[0];
},
// Combat is fought on a single tile, so every unit sharing a tile with a
// hostile military unit is locked in place until one side is wiped out.
_engagedUnits() {
return this._battleUnitIds();
},
// Every tile holding military units of two or more hostile civilisations is
// a battlefield: the stacks share the tile and fight there.
_battleTiles() {
const byTile = new Map();
for (const unit of this.units) {
if (!this._isMilitary(unit)) continue;
const k = key(unit.coords.x, unit.coords.y);
let entry = byTile.get(k);
if (!entry) {
entry = { coords: { x: unit.coords.x, y: unit.coords.y }, civs: new Set(), units: [] };
byTile.set(k, entry);
}
entry.civs.add(unit.civ);
entry.units.push(unit);
}
const battles = [];
for (const entry of byTile.values()) {
if (entry.civs.size >= 2) battles.push(entry);
}
return battles;
},
// The ids of every unit currently trading blows in a battle.
_battleUnitIds() {
const ids = new Set();
for (const battle of this._battleTiles()) {
for (const unit of battle.units) ids.add(unit.id);
}
return ids;
},
// The statuses shown for a unit this instant: the supply statuses stored on
// the unit plus "in battle", which is derived from the live combat state so
// it appears and clears with the fighting rather than only on the daily tick.
_effectiveStatuses(unit, inBattle) {
const statuses = unit.statuses || [];
if (!inBattle || statuses.includes(STATUS_BATTLE)) return statuses;
return [...statuses, STATUS_BATTLE];
},
// The combined upkeep multiplier of the given statuses.
_statusUpkeepMultiplier(statuses) {
let multiplier = 1;
for (const id of statuses) {
const def = statusById(id);
if (def && typeof def.upkeepMultiplier === "number") multiplier *= def.upkeepMultiplier;
}
return multiplier;
},
// Applies every active status once per day. The only effect so far is
// attrition, but the loop is generic so new daily fields slot in here.
_applyDailyStatusEffects() {
const battle = this._battleUnitIds();
const damage = new Map();
for (const unit of this.units) {
const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
if (statuses.length === 0) continue;
const inCity = !!this.cityAt(unit.coords);
let fraction = 0;
for (const id of statuses) {
const def = statusById(id);
if (def) fraction += this._statusHpLossPerDay(def, inCity);
}
if (fraction > 0) damage.set(unit, unit.maxHp * fraction);
}
if (damage.size === 0) return;
const destroyed = [];
for (const [unit, amount] of damage) {
unit.hp -= amount;
if (unit.hp <= 0) {
unit.hp = 0;
destroyed.push(unit);
}
}
this._recordMilitaryCasualties(destroyed);
for (const unit of destroyed) this._destroyUnit(unit);
if (destroyed.length > 0) this._visibilityDirty = true;
this._emitChanged();
},
// One combat round: at most one per day, resolved in `tickHour`. Every pair
// of hostile military units sharing a tile trades blows and damage lands
// simultaneously, so a mutual kill destroys both units.
_resolveCombatRound() {
const damage = new Map();
for (const { attacker, defender } of this._combatPairs()) {
const toDefender = Math.max(
1,
this._unitAttack(attacker) - this._effectiveDefense(defender, true)
);
damage.set(defender, (damage.get(defender) || 0) + toDefender);
const toAttacker = Math.max(
1,
this._unitAttack(defender) - this._effectiveDefense(attacker, false)
);
damage.set(attacker, (damage.get(attacker) || 0) + toAttacker);
}
if (damage.size > 0) {
const destroyed = [];
for (const [unit, amount] of damage) {
unit.hp -= amount;
if (unit.hp <= 0) {
unit.hp = 0;
destroyed.push(unit);
}
}
this._recordMilitaryCasualties(destroyed);
for (const unit of destroyed) this._destroyUnit(unit);
if (destroyed.length > 0) {
this._visibilityDirty = true;
this._emitChanged();
}
}
// A battle can free a besieged city the same tick it ends, so claim it now.
this._resolveCaptures();
},
// A military unit standing on an enemy city with no defender left captures it.
_resolveCaptures() {
const claimed = new Set();
for (const unit of this.units) {
if (!this._isMilitary(unit)) continue;
const coords = unit.coords;
if (this._hasHostileOn(coords, unit.civ)) continue;
const city = this._cityByCoords.get(key(coords.x, coords.y));
if (!city || city.civ === unit.civ || claimed.has(city.id)) continue;
claimed.add(city.id);
this._captureCity(city, unit.civ);
}
},
_destroyUnit(unit) {
const index = this.units.indexOf(unit);
if (index >= 0) this.units.splice(index, 1);
this._unitById.delete(unit.id);
},
// Called when a unit finishes stepping onto a tile. A military unit entering
// an enemy city captures it, but only once every defender has fallen.
_onUnitArrived(unit) {
if (this._hasHostileOn(unit.coords, unit.civ)) return;
const city = this._cityByCoords.get(key(unit.coords.x, unit.coords.y));
if (city && city.civ !== unit.civ && this._isMilitary(unit)) {
this._captureCity(city, unit.civ);
}
},
// Tallies the military dead of a battle before the units are removed, while
// every participant is still on the tile so the enemies can be identified.
_recordMilitaryCasualties(destroyed) {
for (const unit of destroyed) {
if (!this._isMilitary(unit)) continue;
const enemies = new Set();
for (const other of this.unitsAt(unit.coords)) {
if (other.id !== unit.id && other.civ !== unit.civ) enemies.add(other.civ);
}
if (enemies.size === 0) {
for (const neighbour of this._neighbours(unit.coords)) {
for (const other of this.unitsAt(neighbour)) {
if (other.civ !== unit.civ) enemies.add(other.civ);
}
}
}
for (const enemy of enemies) this._recordCasualties(unit.civ, enemy, "military", 1);
}
},
// The civilians killed on a battlefield belong to the tile's owner; when
// several hostile civilisations share the tile the toll is split between them.
_recordBattleCivilianCasualties(battle, amount) {
const owner = this.civAt(battle.coords);
if (owner < 0) return;
const opponents = Array.from(battle.civs).filter((civ) => civ !== owner);
if (opponents.length === 0) return;
const share = amount / opponents.length;
for (const enemy of opponents) this._recordCasualties(owner, enemy, "civilians", share);
},
// Every tile where a battle is raging loses a share of its population and its
// GDP per capita each day, and its recovery clock is pushed back to 30 days
// after the last day of fighting.
_tickBattleTiles() {
for (const battle of this._battleTiles()) {
const k = key(battle.coords.x, battle.coords.y);
const population = this.tilePopulation.get(k);
if (population !== undefined) {
const removed = population * BATTLE.populationLossPerDay;
this.tilePopulation.set(k, population - removed);
this._recordBattleCivilianCasualties(battle, removed);
}
const perCapita = this.getTileGdpPerCapita(battle.coords);
let scarred = this.tileBattleGdpDeficit.has(k);
if (perCapita > 0) {
const deficit = (this.tileBattleGdpDeficit.get(k) || 0) +
perCapita * BATTLE.gdpPerCapitaLossPerDay;
this.tileBattleGdpDeficit.set(k, deficit);
this._gdpPerCapitaCache.delete(k);
scarred = true;
}
if (scarred) {
this.tileBattleRecoveryStart.set(
k,
this.totalHours + BATTLE.recoveryDelayDays * HOURS_PER_DAY
);
}
}
},
// Once a battlefield has been quiet for `recoveryDelayDays`, its GDP per
// capita climbs back by `recoveryPerDay` a day until the scar is gone.
_tickGdpRecovery() {
for (const [k, deficit] of Array.from(this.tileBattleGdpDeficit)) {
const start = this.tileBattleRecoveryStart.get(k);
if (start === undefined || this.totalHours < start) continue;
const remaining = deficit - BATTLE.recoveryPerDay;
if (remaining <= 0) {
this.tileBattleGdpDeficit.delete(k);
this.tileBattleRecoveryStart.delete(k);
} else {
this.tileBattleGdpDeficit.set(k, remaining);
}
this._gdpPerCapitaCache.delete(k);
}
},
};
+19
View File
@@ -0,0 +1,19 @@
// Simulation constants and tuning knobs. Kept together so the rest of the
// model can import the ones it needs.
// The news feed keeps the most recent entries and drops the oldest ones, so a
// long-running game does not grow the snapshot without bound.
export const MAX_NEWS = 100;
export const UNIT_VISION = 2;
export const TERRITORY_VISION = 1;
export const HOURS_PER_DAY = 24;
export const DAYS_PER_YEAR = 365;
export const HOURS_PER_YEAR = HOURS_PER_DAY * DAYS_PER_YEAR;
export const CONTROLLED_SPEED_MULTIPLIER = 1.5;
export const DEFAULT_UNIT_SPEED = 0.5;
export const MIN_STEP_COST = 0.0001;
// A city may hold at most this many ordered units in its training queue.
export const TRAINING_QUEUE_LIMIT = 100;
// A defending unit's defense is doubled: a 100% defensive bonus.
export const DEFENDER_DEFENSE_MULTIPLIER = 2.0;
+85
View File
@@ -0,0 +1,85 @@
// Diplomacy: war declarations, the war record with its casualty tallies, and
// the news feed.
import { RELATION_PEACE, RELATION_WAR, NEWS_WAR } from "../data/relations.js";
import { dateString } from "../game_clock.js";
import { MAX_NEWS } from "./constants.js";
import { warKey } from "./helpers.js";
export const diplomacyMethods = {
isAtWar(a, b) {
if (a < 0 || b < 0 || a === b) return false;
return this.wars.has(warKey(a, b));
},
getRelation(a, b) {
return this.isAtWar(a, b) ? RELATION_WAR : RELATION_PEACE;
},
// Declares war from `civ` on `target`. Fails for unknown or identical
// civilisations and when the two are already at war. The declaration is
// newsworthy and reaches every player through the next snapshot.
requestDeclareWar(civ, target) {
if (!this._validCiv(civ) || !this._validCiv(target) || civ === target) return false;
if (!this._declareWar(civ, target)) return false;
this._emitChanged();
return true;
},
_validCiv(civ) {
return Number.isInteger(civ) && civ >= 0 && civ < this.civilisations.length;
},
// Records the war and its news item without emitting; the caller decides when
// to publish. Returns false when the pair is already at war.
_declareWar(actor, target) {
if (actor === target) return false;
const k = warKey(actor, target);
if (this.wars.has(k)) return false;
this.wars.add(k);
this.conflicts.set(k, {
a: Math.min(actor, target),
b: Math.max(actor, target),
startHours: this.totalHours,
startDate: dateString(this.totalHours),
military: new Map(),
civilians: new Map(),
});
this._addNews({ type: NEWS_WAR, actor, target });
return true;
},
// The ongoing war record between two civilisations, or null when they are at
// peace. Casualties are only tallied while a war is recorded, so fighting that
// somehow happens without a declaration leaves no conflict behind.
_conflictBetween(a, b) {
return this.conflicts.get(warKey(a, b)) || null;
},
// Records that `civ` has suffered `amount` casualties of `kind` ("military"
// or "civilians") in its war against `enemy`.
_recordCasualties(civ, enemy, kind, amount) {
if (!(amount > 0)) return;
const conflict = this._conflictBetween(civ, enemy);
if (!conflict) return;
const counts = kind === "military" ? conflict.military : conflict.civilians;
counts.set(civ, (counts.get(civ) || 0) + amount);
},
getNews() {
return this.news;
},
_addNews(entry) {
const record = {
id: this._nextNewsId,
totalHours: this.totalHours,
date: dateString(this.totalHours),
...entry,
};
this._nextNewsId += 1;
this.news.push(record);
while (this.news.length > MAX_NEWS) this.news.shift();
return record;
},
};
+388
View File
@@ -0,0 +1,388 @@
// Economy: population and GDP aggregates, budget income and upkeep breakdowns,
// research/culture accrual and the daily economic ticks.
import { ECONOMY } from "../data/economy.js";
import {
EFFECT_GDP,
EFFECT_BUDGET_INCOME,
EFFECT_RESEARCH,
EFFECT_CULTURE,
} from "../data/effects.js";
import { CITY_GARRISON_HEAL_PER_HOUR } from "../data/combat.js";
import { buildingUpkeep, prerequisitesMet } from "../rules.js";
import { key } from "../hex.js";
import { HOURS_PER_DAY, HOURS_PER_YEAR } from "./constants.js";
import { effectAt, mergeModifiers } from "./helpers.js";
export const economyMethods = {
// ------------------------------------------------------ aggregates --
getPlayerPopulation(civ) {
return Math.round(this._playerAggregates(civ).population);
},
// The live economy: the raw sums over the civ's territory, adjusted by the
// balancing factors map generation assigned so every civilisation starts with
// the same population and GDP per capita. The factors stay applied as the raw
// figures grow, so terrain and war keep shaping the economy without ever
// recreating the arbitrary starting gap.
_playerAggregates(civ) {
const base = this._basePlayerAggregates(civ);
const populationScale = this._populationBalance.get(civ) || 1;
const perCapitaScale = this._perCapitaBalance.get(civ) || 1;
return {
population: base.population * populationScale,
gdp: base.gdp * populationScale * perCapitaScale,
};
},
// The economy exactly as the tiles describe it, before balancing.
_basePlayerAggregates(civ) {
let population = 0;
let baseGdp = 0;
const cells = this._territoryByCiv.get(civ) || [];
for (const coords of cells) {
const pop = this.tilePopulation.get(key(coords.x, coords.y)) || 0;
population += pop;
baseGdp += pop * this.getTileGdpPerCapita(coords);
}
const modifiers = this.getCivModifiers(civ);
const gdp = baseGdp * (1 + (modifiers[EFFECT_GDP] || 0));
return { population, gdp };
},
getTileGdpPerCapita(coords) {
const k = key(coords.x, coords.y);
if (this._gdpPerCapitaCache.has(k)) return this._gdpPerCapitaCache.get(k);
const owner = this.civAt(coords);
if (owner < 0) return 0;
let perCapita = ECONOMY.baseGdpPerCapita * this.terrainStats.gdpMultiplier(coords);
const city = this.cityAt(coords);
if (city) {
perCapita *= city.isCapital
? ECONOMY.capitalTileGdpMultiplier
: ECONOMY.cityTileGdpMultiplier;
} else if (this._isNextToCity(coords, owner)) {
perCapita *= 1 + ECONOMY.citySurroundingGdpBonus;
}
// Pillaging leaves a lasting mark on the tile's productivity.
perCapita *= this.tileGdpPenalty.get(k) !== undefined ? this.tileGdpPenalty.get(k) : 1;
// Fighting scars the tile too, but that loss recovers over time.
perCapita -= this.tileBattleGdpDeficit.get(k) || 0;
if (perCapita < 0) perCapita = 0;
this._gdpPerCapitaCache.set(k, perCapita);
return perCapita;
},
getTileGdp(coords) {
return (this.tilePopulation.get(key(coords.x, coords.y)) || 0) * this.getTileGdpPerCapita(coords);
},
getPlayerGdp(civ) {
return this._playerAggregates(civ).gdp;
},
getPlayerUpkeep(civ, gdp = -1) {
let total = this.getBuildingUpkeep(civ, gdp);
const battle = this._battleUnitIds();
for (const unit of this.units) {
if (unit.civ !== civ) continue;
const proto = this.unitProto(unit);
if (!proto) continue;
const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
total += proto.upkeep * this._statusUpkeepMultiplier(statuses);
}
return total;
},
getBuildingUpkeep(civ, gdp = -1) {
let total = 0;
if (gdp < 0) gdp = this.getPlayerGdp(civ);
for (const city of this.cities) {
if (city.civ !== civ) continue;
for (const indexStr of Object.keys(city.buildings)) {
const index = Number(indexStr);
total += buildingUpkeep(this.protoBuildings[index], gdp, city.buildings[indexStr]);
}
}
return total;
},
getCityBuildingLevel(city, protoIndex) {
return city.buildings[protoIndex] || 0;
},
// Income and upkeep with the source behind every figure, so the UI can show
// which buildings, government or technologies shape the budget. Amounts are
// per in-game hour, matching the rest of the economy figures.
budgetBreakdown(civ) {
const gdp = this.getPlayerGdp(civ);
const rate = ECONOMY.budgetGdpRate;
const base = gdp * rate;
const incomeSources = this._incomeSources(civ);
const modifier = incomeSources.reduce((sum, source) => sum + source.effect, 0);
const income = base * (1 + modifier);
const buildingSources = this._buildingUpkeepSources(civ, gdp);
const buildingUpkeep = buildingSources.reduce((sum, source) => sum + source.amount, 0);
const unitSources = this._unitUpkeepSources(civ);
const unitUpkeep = unitSources.reduce((sum, source) => sum + source.amount, 0);
return {
income: {
rate,
base,
modifier,
total: income,
sources: incomeSources.map((source) => ({ ...source, amount: base * source.effect })),
},
buildingUpkeep: { total: buildingUpkeep, sources: buildingSources },
unitUpkeep: { total: unitUpkeep, sources: unitSources },
net: income - buildingUpkeep - unitUpkeep,
};
},
// Every building, government and researched technology that shifts budget
// income, combined per source and expressed as a fraction of the base take.
_incomeSources(civ) {
const sources = new Map();
const add = (label, kind, effect) => {
if (effect === 0) return;
const k = `${kind}:${label}`;
const entry = sources.get(k) || { label, kind, effect: 0 };
entry.effect += effect;
sources.set(k, entry);
};
for (const city of this.cities) {
if (city.civ !== civ) continue;
for (const indexStr of Object.keys(city.buildings)) {
const proto = this.protoBuildings[Number(indexStr)];
const level = city.buildings[indexStr];
for (const e of proto.effects) {
if (e.stat === EFFECT_BUDGET_INCOME) add(proto.name, "building", effectAt(e, level));
}
}
}
const government = this.governments[this.getGovernment(civ)];
if (government) {
for (const e of government.effects) {
if (e.stat === EFFECT_BUDGET_INCOME) add(government.name, "government", effectAt(e, 1));
}
}
const researched = this.researched.get(civ) || new Set();
for (const index of researched) {
const tech = this.technologies[index];
for (const e of tech.effects) {
if (e.stat === EFFECT_BUDGET_INCOME) add(tech.name, "technology", effectAt(e, 1));
}
}
return this._sortSources(Array.from(sources.values()));
},
_buildingUpkeepSources(civ, gdp) {
const sources = new Map();
for (const city of this.cities) {
if (city.civ !== civ) continue;
for (const indexStr of Object.keys(city.buildings)) {
const index = Number(indexStr);
const amount = buildingUpkeep(this.protoBuildings[index], gdp, city.buildings[indexStr]);
if (amount === 0) continue;
const entry = sources.get(index) || { label: this.protoBuildings[index].name, kind: "building", amount: 0 };
entry.amount += amount;
sources.set(index, entry);
}
}
return this._sortSources(Array.from(sources.values()));
},
_unitUpkeepSources(civ) {
const sources = new Map();
const battle = this._battleUnitIds();
for (const unit of this.units) {
if (unit.civ !== civ) continue;
const proto = this.unitProto(unit);
if (!proto || !proto.upkeep) continue;
const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
const entry = sources.get(unit.proto) || { label: proto.name, kind: "unit", amount: 0, count: 0 };
entry.amount += proto.upkeep * this._statusUpkeepMultiplier(statuses);
entry.count += 1;
sources.set(unit.proto, entry);
}
return this._sortSources(Array.from(sources.values()));
},
_sortSources(sources) {
const weight = (source) => (source.amount !== undefined ? source.amount : source.effect);
return sources.sort((a, b) => weight(b) - weight(a));
},
// ------------------------------------------------------- modifiers --
getCivBuildingModifiers(civ) {
const totals = {};
for (const city of this.cities) {
if (city.civ !== civ) continue;
for (const indexStr of Object.keys(city.buildings)) {
const proto = this.protoBuildings[Number(indexStr)];
const level = city.buildings[indexStr];
for (const e of proto.effects) {
totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, level);
}
}
}
return totals;
},
getCivGovernmentModifiers(civ) {
const totals = {};
const resource = this.governments[this.getGovernment(civ)];
if (!resource) return totals;
for (const e of resource.effects) {
totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, 1);
}
return totals;
},
getCivModifiers(civ) {
const totals = this.getCivBuildingModifiers(civ);
mergeModifiers(totals, this.getCivGovernmentModifiers(civ));
mergeModifiers(totals, this.getCivTechnologyModifiers(civ));
return totals;
},
// ------------------------------------------- research and culture --
getResearch(civ) {
return this.research.get(civ) || 0;
},
getCulture(civ) {
return this.culture.get(civ) || 0;
},
getGovernment(civ) {
return this.government.get(civ) || 0;
},
getGovernmentResource(civ) {
const index = this.getGovernment(civ);
if (index < 0 || index >= this.governments.length) return null;
return this.governments[index];
},
isResearched(civ, index) {
const set = this.researched.get(civ);
return set ? set.has(index) : false;
},
getResearchedIndices(civ) {
const set = this.researched.get(civ) || new Set();
return Array.from(set);
},
canResearchTechnology(civ, index) {
if (civ < 0 || civ >= this.civilisations.length) return false;
if (index < 0 || index >= this.technologies.length) return false;
if (this.isResearched(civ, index)) return false;
return prerequisitesMet(
this.technologies[index],
this.researched.get(civ) || new Set(),
this.technologies
);
},
getCivTechnologyModifiers(civ) {
const totals = {};
const set = this.researched.get(civ) || new Set();
for (const index of set) {
for (const e of this.technologies[index].effects) {
totals[e.stat] = (totals[e.stat] || 0) + effectAt(e, 1);
}
}
return totals;
},
getBudget(civ) {
return this.budgets.has(civ) ? this.budgets.get(civ) : ECONOMY.startingBudget;
},
getTraining(cityId) {
return this.training.get(cityId) || null;
},
// ---------------------------------------------------- daily ticks --
_tickPopulation() {
const factor = ECONOMY.populationGrowthFactor(HOURS_PER_YEAR, HOURS_PER_DAY);
if (factor === 0) return;
for (const k of Array.from(this.tilePopulation.keys())) {
this.tilePopulation.set(k, this.tilePopulation.get(k) * (1 + factor));
}
},
_tickEconomy() {
const hours = HOURS_PER_DAY;
for (const civ of Array.from(this.budgets.keys())) {
const gdp = this.getPlayerGdp(civ);
const modifiers = this.getCivModifiers(civ);
const income = ECONOMY.budgetIncome(gdp, hours) * (1 + (modifiers[EFFECT_BUDGET_INCOME] || 0));
const upkeep = this.getPlayerUpkeep(civ, gdp) * hours;
this.budgets.set(civ, this.getBudget(civ) + income - upkeep);
}
},
_tickBuildings() {
for (let civ = 0; civ < this.civilisations.length; civ++) {
const modifiers = this.getCivModifiers(civ);
const scienceRate = modifiers[EFFECT_RESEARCH] || 0;
const cultureRate = modifiers[EFFECT_CULTURE] || 0;
if (scienceRate === 0 && cultureRate === 0) continue;
this.research.set(civ, this.getResearch(civ) + scienceRate);
this.culture.set(civ, this.getCulture(civ) + cultureRate);
}
},
_tickTraining() {
const completed = [];
for (const [cityId, queue] of this.training) {
const city = this.findCity(cityId);
if (!city) {
completed.push(cityId);
continue;
}
// One hour of production is spent on the head of the queue; any overflow
// carries into the next ordered unit, so a fast producer can finish
// several cheap units in the same tick.
let hoursLeft = 1;
while (hoursLeft > 0 && queue.length > 0) {
const entry = queue[0];
const proto = this.protoUnits[entry.protoIndex];
if (!proto) {
queue.shift();
continue;
}
const remaining = Math.max(entry.totalHours - entry.elapsedHours, 0);
if (hoursLeft >= remaining) {
hoursLeft -= remaining;
this._spawnUnit(city.coords, city.civ, proto);
this._visibilityDirty = true;
queue.shift();
} else {
entry.elapsedHours += hoursLeft;
hoursLeft = 0;
}
}
if (queue.length === 0) completed.push(cityId);
}
for (const cityId of completed) this.training.delete(cityId);
},
// Units resting inside one of their own cities recover a little health each
// in-game hour.
_tickGarrisonHealing() {
for (const unit of this.units) {
if (unit.hp >= unit.maxHp) continue;
const city = this.cityAt(unit.coords);
if (!city || city.civ !== unit.civ) continue;
unit.hp = Math.min(unit.maxHp, unit.hp + CITY_GARRISON_HEAL_PER_HOUR);
}
},
};
+41
View File
@@ -0,0 +1,41 @@
// Small helpers shared by the GameState mixins.
import { parseKey } from "../hex.js";
import { effectValue } from "../data/effects.js";
// Canonical key for an unordered pair of civilisations.
export function warKey(a, b) {
return a < b ? `${a}:${b}` : `${b}:${a}`;
}
// The value of a catalogue effect at `level`. Kept as a local name so the
// economy code reads naturally.
export function effectAt(e, level) {
return effectValue(e, level);
}
export function mergeModifiers(totals, additions) {
for (const stat of Object.keys(additions)) {
totals[stat] = (totals[stat] || 0) + additions[stat];
}
}
// Flattens a Map<"x,y", value> into [x, y, value] triples for the wire.
export function serializeCells(map) {
const result = [];
for (const [k, value] of map) {
const coords = parseKey(k);
result.push([coords.x, coords.y, value]);
}
return result;
}
// Flattens a Set<"x,y"> into [x, y, true] triples for the wire.
export function serializeSet(set) {
const result = [];
for (const k of set) {
const coords = parseKey(k);
result.push([coords.x, coords.y, true]);
}
return result;
}
+362
View File
@@ -0,0 +1,362 @@
// Movement: translating orders into paths, advancing units along them each
// tick, and the travel-cost model the browser mirrors for prediction.
import { key } from "../hex.js";
import {
MIN_STEP_COST,
DEFAULT_UNIT_SPEED,
CONTROLLED_SPEED_MULTIPLIER,
} from "./constants.js";
export const movementMethods = {
requestMove(unitId, goal) {
return this.requestGroupMove([unitId], goal);
},
// Orders one or more units onto `goal`. Friendly units may stack there; a
// hostile military unit turns the move into an attack. Units moving together
// adopt the speed of the slowest member, so a stack holds formation.
requestGroupMove(unitIds, goal) {
const units = this._moveableUnits(unitIds);
if (units.length === 0) return false;
const speed = units.length > 1
? Math.min(...units.map((unit) => this._unitSpeed(unit)))
: null;
let moved = 0;
for (const unit of units) {
// Only a unit that actually receives the new route drops its scheduled
// stops; a unit whose route cannot be found keeps its existing movement
// rather than being silently emptied out.
if (this._orderMove(unit, goal, speed)) {
unit.waypoints = [];
moved += 1;
}
}
if (moved === 0) return false;
this._emitChanged();
return true;
},
// Explicit attack on the hostile force holding `goal`: an enemy unit stack or
// an enemy city. The attack is a march to the tile; combat is joined on
// arrival. If the enemy has moved away by then, the order is simply a move.
requestAttack(unitIds, goal) {
const units = this._moveableUnits(unitIds).filter((unit) => this._isMilitary(unit));
if (units.length === 0) return false;
const civ = units[0].civ;
const enemyMilitary = this.enemyUnitsAt(goal, civ).some((other) => this._isMilitary(other));
const city = this.cityAt(goal);
const enemyCity = !!city && city.civ !== civ;
if (!enemyMilitary && !enemyCity) return false;
return this.requestGroupMove(units.map((unit) => unit.id), goal);
},
// Orders units onto one or more destinations in turn. With `append`, the new
// destinations join the queue already scheduled for each unit; otherwise they
// replace it. When a unit reaches one destination a fresh path is computed to
// the next, so the route adapts to a changing world.
requestScheduledMove(unitIds, goals, append = false) {
const destinations = (Array.isArray(goals) ? goals : [goals])
.filter((goal) => goal && Number.isFinite(goal.x) && Number.isFinite(goal.y))
.map((goal) => ({ x: goal.x, y: goal.y }));
if (destinations.length === 0) return false;
const units = this._moveableUnits(unitIds);
if (units.length === 0) return false;
const speed = units.length > 1
? Math.min(...units.map((unit) => this._unitSpeed(unit)))
: null;
let moved = 0;
for (const unit of units) {
if (!Array.isArray(unit.waypoints)) unit.waypoints = [];
if (append) {
for (const goal of destinations) unit.waypoints.push({ ...goal });
if (this._hasRoute(unit) || this._followNextWaypoint(unit, speed)) moved += 1;
} else {
const previous = unit.waypoints;
unit.waypoints = destinations.slice(1).map((goal) => ({ ...goal }));
if (this._orderMove(unit, destinations[0], speed)) moved += 1;
// An unroutable first leg leaves the unit exactly as it was.
else unit.waypoints = previous;
}
}
if (moved === 0) return false;
this._emitChanged();
return true;
},
_moveableUnits(unitIds) {
const ids = Array.isArray(unitIds) ? unitIds : [unitIds];
const units = [];
for (const id of ids) {
const unit = this.findUnit(Number(id));
if (!unit) continue;
const proto = this.unitProto(unit);
if (!proto || !proto.moveable) continue;
units.push(unit);
}
return units;
},
// Whether the unit still has an unfinished route to its current destination.
_hasRoute(unit) {
return !!unit.path && unit.path.length > 1 && unit.pathIndex < unit.path.length - 1;
},
// Pops the next scheduled destination and routes to it, keeping the rest of
// the queue. Returns false when nothing is left or the next destination is
// unreachable (in which case the whole schedule is abandoned).
_followNextWaypoint(unit, speed = null) {
if (!unit.waypoints || unit.waypoints.length === 0) return false;
const next = unit.waypoints.shift();
const pace = speed !== null ? speed : unit.moveSpeed;
if (!this._orderMove(unit, next, pace)) {
this._stopUnit(unit);
return false;
}
return true;
},
_orderMove(unit, goal, speed) {
// Attacking: a hostile military unit standing on the goal. Combat itself is
// joined on arrival; this only decides whether the march declares war.
const target = this.enemyUnitsAt(goal, unit.civ).find((other) => this._isMilitary(other)) || null;
const attacking = !!target && this._isMilitary(unit);
const path = this.findPath(unit, unit.coords, goal);
if (path.length < 2) {
return false;
}
// Marching a military unit into, or attacking, a country we are at peace
// with declares war on it. The browser confirms the move first, but the
// server enforces the rule so the two can never disagree.
if (this._isMilitary(unit)) {
if (attacking) this._declareWar(unit.civ, target.civ);
for (const coords of path) {
const owner = this.civAt(coords);
if (owner >= 0 && owner !== unit.civ) this._declareWar(unit.civ, owner);
}
}
unit.path = path;
unit.pathIndex = 0;
unit.progressHours = 0;
unit.moveSpeed = speed;
return true;
},
advanceMovement(hours) {
if (hours <= 0) return;
// Units sharing a tile with a hostile military unit are locked in place
// until the daily combat round resolves, so neither side can slip away
// mid-fight. The set grows as units charge in so an ordered swap cannot let
// two enemies march through each other.
const engaged = this._engagedUnits();
for (const unit of this.units) {
if (!this._hasRoute(unit) && (!unit.waypoints || unit.waypoints.length === 0)) continue;
if (engaged.has(unit)) continue;
let hoursLeft = hours;
while (hoursLeft > 0) {
// Reaching the end of one leg picks up the next scheduled destination
// and recomputes the path from where the unit now stands.
if (!this._hasRoute(unit) && !this._followNextWaypoint(unit)) break;
const next = unit.path[unit.pathIndex + 1];
const occupants = this.unitsAt(next).filter((other) => other !== unit);
const enemy = occupants.find((other) => other.civ !== unit.civ);
if (enemy && !this._isBlockingEnemy(unit, enemy)) {
// A hostile civilian is an obstacle; route around it, and when the
// destination has become unreachable, abandon the move instead of
// stalling on the spot forever.
if (this._repathAround(unit)) {
const ahead = this.unitsAt(unit.path[unit.pathIndex + 1])
.find((other) => other !== unit && other.civ !== unit.civ);
if (ahead) break;
continue;
}
this._stopUnit(unit);
break;
}
// A hostile military unit does not stop the charge: combat is fought on
// a single tile, so the unit steps in and the two stacks share it.
// Friendly occupants do not block either: the unit simply stacks.
const cost = Math.max(this._tileTravelHours(unit, next), MIN_STEP_COST);
const needed = cost - unit.progressHours;
if (hoursLeft >= needed) {
hoursLeft -= needed;
unit.pathIndex += 1;
unit.progressHours = 0;
unit.coords = next;
this._visibilityDirty = true;
this._onUnitArrived(unit);
if (this._hasHostileOn(unit.coords, unit.civ)) {
// The fight is joined: hold here and lock every participant. The
// unit that charged in is the attacker; the occupants defend.
this._stopUnit(unit);
unit.invader = true;
for (const other of this.unitsAt(unit.coords)) {
if (other.civ !== unit.civ && this._isMilitary(other)) {
other.invader = false;
engaged.add(other);
}
}
engaged.add(unit);
break;
}
} else {
unit.progressHours += hoursLeft;
hoursLeft = 0;
}
}
if (!this._hasRoute(unit) && (!unit.waypoints || unit.waypoints.length === 0)) {
this._stopUnit(unit);
}
}
},
// A hostile military unit standing on the next tile is an opponent to fight,
// not an obstacle to route around.
_isBlockingEnemy(unit, occupant) {
return this._isMilitary(unit) && this._isMilitary(occupant) && occupant.civ !== unit.civ;
},
// Every unit on the tile, ours or not, that is hostile to `civ`.
_hasHostileOn(coords, civ) {
return this.unitsAt(coords).some((unit) => unit.civ !== civ);
},
// Recomputes a route from where the unit stands to the destination it was
// already heading for. Returns false when no route exists any more.
_repathAround(unit) {
if (!unit.path || unit.path.length < 2) return false;
const goal = unit.path[unit.path.length - 1];
const path = this.findPath(unit, unit.coords, goal);
if (path.length < 2) return false;
unit.path = path;
unit.pathIndex = 0;
unit.progressHours = 0;
return true;
},
// Abandons every movement for the unit: no route, no partial step and no
// remaining scheduled destinations.
_stopUnit(unit) {
unit.path = [];
unit.pathIndex = 0;
unit.progressHours = 0;
unit.moveSpeed = null;
unit.waypoints = [];
},
// ------------------------------------------------------ travel cost --
_isControlledBy(unit, coords) {
return this.civAt(coords) === unit.civ;
},
_unitSpeed(unit) {
const proto = this.unitProto(unit);
return Math.max(proto ? proto.speed : DEFAULT_UNIT_SPEED, 0.0001);
},
_effectiveSpeedAt(unit, coords) {
// A group move pins every unit to the slowest member's speed.
let speed = unit.moveSpeed || this._unitSpeed(unit);
if (this._isControlledBy(unit, coords)) speed *= CONTROLLED_SPEED_MULTIPLIER;
return speed;
},
_tileTravelHours(unit, coords) {
return this.terrainStats.movementCostMultiplier(coords) / this._effectiveSpeedAt(unit, coords);
},
_fastestTileTravelHours(unit) {
return this._minMovementCost / (this._unitSpeed(unit) * CONTROLLED_SPEED_MULTIPLIER);
},
_heuristic(unit, from, to) {
const delta = this.topology.pixelDelta(from, to);
const tiles = Math.hypot(delta.x, delta.y) / this._maxStepLength;
return tiles * this._fastestTileTravelHours(unit);
},
_computeMaxStepLength() {
let longest = 0;
for (const neighbour of this._neighbours({ x: 0, y: 0 })) {
const delta = this.topology.pixelDelta({ x: 0, y: 0 }, neighbour);
longest = Math.max(longest, Math.hypot(delta.x, delta.y));
}
return longest > 0 ? longest : 1.0;
},
// --------------------------------------------------------- pathfinding --
// Whether `unit` may occupy `coords`: passable terrain and no hostile unit
// there unless the move is an attack (military into military, which turns the
// step into a fight). Friendly units may share a tile, so they never block.
// Enemy cities are only open to military units, since walking one in
// captures it.
//
// Foreign territory is a wall as well, so a route never cuts through a third
// party and provokes a war for purely technical reasons. The one exception is
// the civilisation being targeted (`targetCiv`, the owner of the goal), whose
// land the route may cross to reach it.
//
// A hostile unit or city is only a valid destination (`isGoal`), never a tile
// to pass through: pathfinding routes around enemies instead of through them.
_canUnitEnter(unit, coords, isGoal = false, targetCiv = -1) {
const proto = this.unitProto(unit);
if (!this.canEnter(unit.civ, proto, coords)) return false;
const owner = this.civAt(coords);
if (!isGoal && owner >= 0 && owner !== unit.civ && owner !== targetCiv) return false;
const occupants = this.unitsAt(coords).filter((other) => other !== unit);
if (occupants.length > 0) {
const hostile = occupants.filter((other) => other.civ !== unit.civ);
if (hostile.length > 0) {
if (!isGoal) return false;
if (!this._isMilitary(unit)) return false;
if (!hostile.some((other) => this._isMilitary(other))) return false;
}
}
const city = this._cityByCoords.get(key(coords.x, coords.y));
if (city && city.civ !== unit.civ) {
if (!isGoal) return false;
if (!this._isMilitary(unit)) return false;
}
return true;
},
findPath(unit, start, goal) {
const targetCiv = this._targetCivFor(unit, goal);
const enterCache = new Map();
const costCache = new Map();
const heuristicCache = new Map();
return this._pathfinder.findPath(
start,
goal,
(coords) => this._neighbours(coords),
(coords, isGoal) => {
const k = key(coords.x, coords.y) + (isGoal ? ":goal" : "");
if (!enterCache.has(k)) enterCache.set(k, this._canUnitEnter(unit, coords, isGoal, targetCiv));
return enterCache.get(k);
},
(_from, to) => {
const k = key(to.x, to.y);
if (!costCache.has(k)) costCache.set(k, this._tileTravelHours(unit, to));
return costCache.get(k);
},
(from, to) => {
const k = key(from.x, from.y);
if (!heuristicCache.has(k)) heuristicCache.set(k, this._heuristic(unit, from, to));
return heuristicCache.get(k);
}
);
},
// The civilisation a route is aimed at: the enemy city or unit on the goal,
// or failing that whoever owns the goal tile. Crossing that civ's land is
// allowed; every other foreign territory is a wall.
_targetCivFor(unit, goal) {
const city = this._cityByCoords.get(key(goal.x, goal.y));
if (city && city.civ !== unit.civ) return city.civ;
for (const other of this.unitsAt(goal)) {
if (other !== unit && other.civ !== unit.civ) return other.civ;
}
return this.civAt(goal);
},
};
+164
View File
@@ -0,0 +1,164 @@
// Player-issued orders that are not movement: training, building, government,
// research and pillaging.
import { key } from "../hex.js";
import { ECONOMY } from "../data/economy.js";
import { GOVERNMENTS } from "../data/governments.js";
import { PILLAGE, pillageSteps } from "../data/combat.js";
import { buildingBuildCost, buildingRefund } from "../rules.js";
import { TRAINING_QUEUE_LIMIT } from "./constants.js";
export const orderMethods = {
// Adds one unit to the city's training queue. The queue holds at most
// TRAINING_QUEUE_LIMIT units; the first entry is built while the rest wait.
requestTrain(cityId, protoIndex) {
const city = this.findCity(cityId);
if (!city) return false;
if (protoIndex < 0 || protoIndex >= this.protoUnits.length) return false;
const proto = this.protoUnits[protoIndex];
const queue = this.training.get(cityId);
if (queue && queue.length >= TRAINING_QUEUE_LIMIT) return false;
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(city.civ));
if (capacity <= 0 || proto.cost <= 0) {
this._spawnUnit(city.coords, city.civ, proto);
this._visibilityDirty = true;
this._emitChanged();
return true;
}
const entry = {
protoIndex,
capacity,
elapsedHours: 0,
totalHours: proto.cost / capacity,
};
if (queue) queue.push(entry);
else this.training.set(cityId, [entry]);
this._emitChanged();
return true;
},
// Cancels the queued unit at `index` in the city's training queue. Nothing is
// refunded: the time already spent on it is lost.
cancelTrain(cityId, index) {
const queue = this.training.get(cityId);
if (!queue) return false;
const at = Number(index);
if (!Number.isInteger(at) || at < 0 || at >= queue.length) return false;
queue.splice(at, 1);
if (queue.length === 0) this.training.delete(cityId);
this._emitChanged();
return true;
},
requestBuild(cityId, protoIndex) {
const city = this.findCity(cityId);
if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false;
const proto = this.protoBuildings[protoIndex];
const civ = city.civ;
const level = this.getCityBuildingLevel(city, protoIndex);
if (level >= proto.maxLevel) return false;
const gdp = this.getPlayerGdp(civ);
const cost = buildingBuildCost(proto, gdp, level);
if (cost > 0 && this.getBudget(civ) < cost) return false;
if (cost > 0) this.budgets.set(civ, this.getBudget(civ) - cost);
city.buildings[protoIndex] = level + 1;
this._emitChanged();
return true;
},
requestDemolish(cityId, protoIndex) {
const city = this.findCity(cityId);
if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false;
const proto = this.protoBuildings[protoIndex];
const civ = city.civ;
const level = this.getCityBuildingLevel(city, protoIndex);
if (level <= 0) return false;
const refund = buildingRefund(proto, this.getPlayerGdp(civ), level);
if (refund > 0) this.budgets.set(civ, this.getBudget(civ) + refund);
if (level <= 1) delete city.buildings[protoIndex];
else city.buildings[protoIndex] = level - 1;
this._emitChanged();
return true;
},
requestSetGovernment(civ, index) {
if (civ < 0 || civ >= this.civilisations.length) return false;
if (index < 0 || index >= this.governments.length) return false;
if (this.getGovernment(civ) === index) return false;
const cost = GOVERNMENTS[index].baseCost +
GOVERNMENTS[index].costGdpFraction * Math.max(this.getPlayerGdp(civ), 0);
if (cost > 0 && this.getBudget(civ) < cost) return false;
if (cost > 0) this.budgets.set(civ, this.getBudget(civ) - cost);
this.government.set(civ, index);
this._emitChanged();
return true;
},
requestResearchTechnology(civ, index) {
if (!this.canResearchTechnology(civ, index)) return false;
const technology = this.technologies[index];
if (this.getResearch(civ) < technology.cost) return false;
this.research.set(civ, this.getResearch(civ) - technology.cost);
this.researched.get(civ).add(index);
this._emitChanged();
return true;
},
// Whether the units may pillage the tile they stand on: all of them are on
// one hostile tile where no battle is raging.
canPillage(unitIds) {
const units = this._unitsForOrder(unitIds);
if (units.length === 0) return false;
const coords = units[0].coords;
const owner = this.civAt(coords);
if (owner < 0 || owner === units[0].civ) return false;
if (!units.every((unit) => unit.coords.x === coords.x && unit.coords.y === coords.y)) {
return false;
}
return !this._battleTiles().some(
(battle) => battle.coords.x === coords.x && battle.coords.y === coords.y
);
},
// Pillages the tile the units stand on. Every unit in the stack takes a turn,
// the weakest first (least HP relative to maximum): each removes 10% of the
// remaining population, cuts the tile's GDP per capita by 30% and heals one
// hit point per 1000 people it drives off.
requestPillage(unitIds) {
const units = this._unitsForOrder(unitIds);
if (!this.canPillage(unitIds)) return false;
const coords = units[0].coords;
const owner = this.civAt(coords);
const k = key(coords.x, coords.y);
const ordered = units.slice().sort((a, b) => a.hp / a.maxHp - b.hp / b.maxHp);
let population = this.tilePopulation.get(k) || 0;
const { steps } = pillageSteps(population, ordered.length);
let penalty = this.tileGdpPenalty.get(k) !== undefined ? this.tileGdpPenalty.get(k) : 1;
let drivenOff = 0;
for (let i = 0; i < ordered.length; i++) {
penalty *= PILLAGE.gdpPenalty;
const unit = ordered[i];
unit.hp = Math.min(unit.maxHp, unit.hp + steps[i].heal);
drivenOff += steps[i].removed;
}
population = steps.length > 0 ? steps[steps.length - 1].remaining : population;
this.tilePopulation.set(k, population);
this.tileGdpPenalty.set(k, penalty);
this._gdpPerCapitaCache.delete(k);
// The people driven off the tile are civilians lost to its owner in the war
// against the pillaging civilisation.
if (owner >= 0) this._recordCasualties(owner, units[0].civ, "civilians", drivenOff);
this._emitChanged();
return true;
},
_unitsForOrder(unitIds) {
const ids = Array.isArray(unitIds) ? unitIds : [unitIds];
const units = [];
for (const id of ids) {
const unit = this.findUnit(Number(id));
if (unit) units.push(unit);
}
return units;
},
};
+244
View File
@@ -0,0 +1,244 @@
// Serialisation: turning the live model into the snapshots the server ships to
// each viewer, with fog-of-war applied per civilisation.
import { key, parseKey } from "../hex.js";
import { serializeSet } from "./helpers.js";
export const serializationMethods = {
snapshot(viewerCiv = -1) {
return this.viewerSnapshot(this.serializeShared(), viewerCiv);
},
serializeShared() {
return {
seed: this.seed,
totalHours: this.totalHours,
civs: this.civilisations.map((c) => ({ id: c.id, name: c.name })),
protos: this.protoUnits.map((p) => ({ id: p.id, name: p.name })),
buildings: this.protoBuildings.map((b) => ({ id: b.id, name: b.name })),
governments: this.governments.map((g) => ({ id: g.id, name: g.name })),
technologies: this.technologies.map((t) => ({ id: t.id, name: t.name })),
cities: this._serializeCities(),
units: this._serializeUnits(),
territory: this._serializedTerritory,
population: this._serializePopulation(),
civStats: this._serializeCivStats(),
budgets: this._serializeCivFloats(this.budgets),
research: this._serializeCivFloats(this.research),
culture: this._serializeCivFloats(this.culture),
government: this._serializeCivInts(this.government),
researched: this._serializeResearched(),
diplomacy: this._serializeDiplomacy(),
conflicts: this._serializeConflicts(),
news: this._serializeNews(),
battles: this._serializeBattles(),
training: Object.fromEntries(
Array.from(this.training.entries()).map(([cityId, queue]) => [
cityId,
queue.map((entry) => ({ ...entry })),
])
),
};
},
viewerSnapshot(shared, viewerCiv, stats = null) {
const result = { ...shared };
result.viewer = viewerCiv;
result.viewerStats = stats || this.viewerStats(viewerCiv);
result.explored = this._cachedExplored(viewerCiv);
result.visible = serializeSet(this.visible.get(viewerCiv) || new Set());
return result;
},
viewerStats(viewerCiv) {
if (viewerCiv < 0) {
return {
population: 0, gdp: 0, gdpPerCapita: 0, budget: 0,
upkeep: 0, research: 0, culture: 0, government: 0,
breakdown: null,
};
}
const aggregates = this._playerAggregates(viewerCiv);
const population = Math.round(aggregates.population);
const gdp = aggregates.gdp;
return {
population,
gdp,
gdpPerCapita: population > 0 ? gdp / population : 0,
budget: this.getBudget(viewerCiv),
upkeep: this.getPlayerUpkeep(viewerCiv, gdp),
research: this.getResearch(viewerCiv),
culture: this.getCulture(viewerCiv),
government: this.getGovernment(viewerCiv),
breakdown: this.budgetBreakdown(viewerCiv),
};
},
_serializeCities() {
return this.cities.map((city) => ({
id: city.id,
civ: city.civ,
coords: [city.coords.x, city.coords.y],
name: city.name,
isCapital: city.isCapital,
population: city.population,
improvements: city.improvements,
buildings: { ...city.buildings },
statuses: city.statuses || [],
}));
},
_serializeUnits() {
const battle = this._battleUnitIds();
return this.units.map((unit) => {
const rawPath = unit.path || [];
const pathIndex = unit.pathIndex;
const path = rawPath.map((coords) => [coords.x, coords.y]);
const segmentHours = [];
for (let i = 1; i < rawPath.length; i++) {
segmentHours.push(this._tileTravelHours(unit, rawPath[i]));
}
let stepHours = 0;
if (pathIndex + 1 < rawPath.length) {
stepHours = this._tileTravelHours(unit, rawPath[pathIndex + 1]);
}
return {
id: unit.id,
civ: unit.civ,
proto: unit.proto,
coords: [unit.coords.x, unit.coords.y],
hp: unit.hp,
maxHp: unit.maxHp,
statuses: this._effectiveStatuses(unit, battle.has(unit.id)),
path,
pathIndex,
progressHours: unit.progressHours,
stepHours,
segmentHours,
waypoints: (unit.waypoints || []).map((coords) => [coords.x, coords.y]),
};
});
},
_serializePopulation() {
const result = [];
for (const [k, value] of this.tilePopulation) {
const coords = parseKey(k);
result.push([coords.x, coords.y, Math.round(value)]);
}
return result;
},
// One entry per civilisation with its headline economy figures, so the
// diplomacy panel can show every nation's standing without a second lookup.
_serializeCivStats() {
return this.civilisations.map((civ, index) => {
const aggregates = this._playerAggregates(index);
const population = Math.round(aggregates.population);
const gdp = aggregates.gdp;
return {
population,
gdp,
gdpPerCapita: population > 0 ? gdp / population : 0,
};
});
},
_serializeCivFloats(values) {
const result = [];
for (let i = 0; i < this.civilisations.length; i++) {
result.push(values.has(i) ? values.get(i) : 0);
}
return result;
},
_serializeCivInts(values) {
const result = [];
for (let i = 0; i < this.civilisations.length; i++) {
result.push(values.has(i) ? values.get(i) : 0);
}
return result;
},
_serializeResearched() {
const result = [];
for (let i = 0; i < this.civilisations.length; i++) {
const set = this.researched.get(i) || new Set();
const indices = Array.from(set).sort((a, b) => a - b);
result.push(indices);
}
return result;
},
// Every unordered pair with its current status. With a handful of
// civilisations the whole matrix is tiny, and it lets the client show the
// status of every nation without a second lookup.
_serializeDiplomacy() {
const result = [];
for (let a = 0; a < this.civilisations.length; a++) {
for (let b = a + 1; b < this.civilisations.length; b++) {
result.push([a, b, this.getRelation(a, b)]);
}
}
return result;
},
// Every ongoing war with its start date and the casualties each side has
// suffered, broken down into military and civilian dead.
_serializeConflicts() {
const result = [];
for (const conflict of this.conflicts.values()) {
result.push({
a: conflict.a,
b: conflict.b,
started: conflict.startDate,
startHours: conflict.startHours,
sides: [conflict.a, conflict.b].map((civ) => ({
civ,
military: conflict.military.get(civ) || 0,
civilians: Math.round(conflict.civilians.get(civ) || 0),
})),
});
}
return result;
},
_serializeNews() {
return this.news.map((entry) => ({ ...entry }));
},
// One entry per battlefield: [x, y, defenderCiv, attackerCiv]. The tile's
// owner is the defender when present; otherwise the lowest-numbered civ on
// the tile defends. The browser uses this to draw the two stacks facing each
// other with the battle icon between them.
_serializeBattles() {
const result = [];
for (const battle of this._battleTiles()) {
const civs = Array.from(battle.civs).sort((a, b) => a - b);
const owner = this.civAt(battle.coords);
const defender = this._defenderCiv(battle, civs, owner);
const attacker = civs.find((civ) => civ !== defender);
if (attacker === undefined) continue;
result.push([battle.coords.x, battle.coords.y, defender, attacker]);
}
return result;
},
// The side that charged in is the attacker; the occupants defend. When that is
// ambiguous (a mutual charge, or a third party) fall back to the tile's owner
// and then to the lowest-numbered civ present.
_defenderCiv(battle, civs, owner) {
const invaders = new Set();
let hasDefender = false;
for (const unit of battle.units) {
if (unit.invader) invaders.add(unit.civ);
else hasDefender = true;
}
const defenders = civs.filter((civ) => !invaders.has(civ));
if (invaders.size > 0 && hasDefender && defenders.length > 0) {
if (defenders.length === 1) return defenders[0];
return defenders.includes(owner) ? owner : defenders[0];
}
return civs.includes(owner) ? owner : civs[0];
},
};
+165
View File
@@ -0,0 +1,165 @@
// Supply: encirclement and siege detection, including the maritime exception
// that keeps a coastal city supplied through allied naval units.
import { key } from "../hex.js";
import { STATUS_ENCIRCLED, STATUS_BESIEGED } from "../data/statuses.js";
export const statusMethods = {
// Recomputes the status of every unit and city. The supply search only runs
// for an occupant that has a hostile unit adjacent, and only once a day (and
// at setup), so it never touches the snapshot path.
_refreshStatuses() {
const components = new Map();
for (const unit of this.units) {
if (!this._hasAdjacentHostile(unit.coords, unit.civ)) {
unit.statuses = [];
continue;
}
unit.statuses = this._unitStatuses(unit, this._componentsFor(components, unit.civ));
}
for (const city of this.cities) {
if (!this._hasAdjacentHostile(city.coords, city.civ)) {
city.statuses = [];
continue;
}
city.statuses = this._cityStatuses(city, this._componentsFor(components, city.civ));
}
},
// The trigger: encirclement is only evaluated when a hostile unit is next to
// the tile.
_hasAdjacentHostile(coords, civ) {
for (const neighbour of this._neighbours(coords)) {
if (this.unitsAt(neighbour).some((unit) => unit.civ !== civ)) return true;
}
return false;
},
_componentsFor(cache, civ) {
if (!cache.has(civ)) cache.set(civ, this._landComponents(civ));
return cache.get(civ);
},
_isNaval(unit) {
const proto = this.unitProto(unit);
return !!(proto && proto.traversableTerrains.includes("Sea"));
},
// Land connected components for one civ, with enemy-occupied and enemy-held
// city tiles acting as walls. Each component remembers how many of the civ's
// cities it contains, which is all the supply check needs.
_landComponents(civ) {
const byTile = new Map();
const cityCountByComp = new Map();
const totalCities = this.cities.filter((city) => city.civ === civ).length;
let component = 0;
for (const start of this.landCells) {
const startKey = key(start.x, start.y);
if (byTile.has(startKey) || !this._landPassable(start, civ)) continue;
const id = component++;
byTile.set(startKey, id);
let cities = 0;
let frontier = [start];
while (frontier.length > 0) {
const next = [];
for (const coords of frontier) {
const city = this.cityAt(coords);
if (city && city.civ === civ) cities += 1;
for (const neighbour of this._neighbours(coords)) {
const nk = key(neighbour.x, neighbour.y);
if (byTile.has(nk) || !this._landPassable(neighbour, civ)) continue;
byTile.set(nk, id);
next.push(neighbour);
}
}
frontier = next;
}
cityCountByComp.set(id, cities);
}
return { byTile, cityCountByComp, totalCities };
},
_landPassable(coords, civ) {
const tile = this.tiles[key(coords.x, coords.y)];
if (!tile || tile.terrainClass !== "Land") return false;
if (this.unitsAt(coords).some((unit) => unit.civ !== civ)) return false;
const city = this.cityAt(coords);
if (city && city.civ !== civ) return false;
return true;
},
// A city is besieged when it no longer shares a land component with any
// other city of its civ. A lone city is never besieged.
_isCityBesieged(city, info) {
if (info.totalCities < 2) return false;
const component = info.byTile.get(key(city.coords.x, city.coords.y));
const count = component === undefined ? 0 : (info.cityCountByComp.get(component) || 0);
return count <= 1;
},
_unitStatuses(unit, info) {
const city = this.cityAt(unit.coords);
const inOwnCity = city && city.civ === unit.civ;
let encircled;
if (inOwnCity) {
encircled = this._isCityBesieged(city, info);
} else {
const component = info.byTile.get(key(unit.coords.x, unit.coords.y));
const count = component === undefined ? 0 : (info.cityCountByComp.get(component) || 0);
encircled = count === 0;
}
const excludeCity = inOwnCity ? city.id : 0;
if (encircled && this._hasMaritimeRoute(unit.coords, unit.civ, excludeCity)) {
encircled = false;
}
return encircled ? [STATUS_ENCIRCLED] : [];
},
_cityStatuses(city, info) {
const besieged = this._isCityBesieged(city, info) &&
!this._hasMaritimeRoute(city.coords, city.civ, city.id);
return besieged ? [STATUS_BESIEGED] : [];
},
// The maritime exception: with an allied naval unit on an adjacent sea tile,
// a lane is traced across water to a different coastal allied city. Enemy
// units on the water block it.
_hasMaritimeRoute(origin, civ, excludeCityId) {
const visited = new Set();
let frontier = [];
for (const neighbour of this._neighbours(origin)) {
const tile = this.tiles[key(neighbour.x, neighbour.y)];
if (!tile || tile.terrainClass !== "Sea") continue;
if (!this.unitsAt(neighbour).some((unit) => unit.civ === civ && this._isNaval(unit))) continue;
const nk = key(neighbour.x, neighbour.y);
if (visited.has(nk)) continue;
visited.add(nk);
frontier.push(neighbour);
}
while (frontier.length > 0) {
const next = [];
for (const coords of frontier) {
for (const neighbour of this._neighbours(coords)) {
const city = this.cityAt(neighbour);
if (city && city.civ === civ && city.id !== excludeCityId) return true;
}
for (const neighbour of this._neighbours(coords)) {
const tile = this.tiles[key(neighbour.x, neighbour.y)];
if (!tile || tile.terrainClass !== "Sea") continue;
const nk = key(neighbour.x, neighbour.y);
if (visited.has(nk)) continue;
if (this.unitsAt(neighbour).some((unit) => unit.civ !== civ)) continue;
visited.add(nk);
next.push(neighbour);
}
}
frontier = next;
}
return false;
},
_statusHpLossPerDay(def, inCity) {
if (inCity && typeof def.cityHpLossPerDay === "number") return def.cityHpLossPerDay;
return typeof def.hpLossPerDay === "number" ? def.hpLossPerDay : 0;
},
};
+147
View File
@@ -0,0 +1,147 @@
// Territory: which civilisation owns each land tile, how ownership spreads
// from cities, and how it is redrawn when a city changes hands.
import { key, parseKey } from "../hex.js";
import { NEWS_CAPTURE } from "../data/relations.js";
import { serializeCells } from "./helpers.js";
export const territoryMethods = {
civAt(coords) {
const k = key(coords.x, coords.y);
return this.territory.has(k) ? this.territory.get(k) : -1;
},
terrainTypeAt(coords) {
const tile = this.tiles[key(coords.x, coords.y)];
return tile ? tile.terrainType : "Unknown";
},
getTilePopulation(coords) {
return Math.round(this.tilePopulation.get(key(coords.x, coords.y)) || 0);
},
getTileInfo(coords) {
const city = this.cityAt(coords);
return {
coords,
owner: this.civAt(coords),
terrainType: this.terrainTypeAt(coords),
population: this.getTilePopulation(coords),
improvements: city ? city.improvements : [],
};
},
_recomputeTerritory() {
this.territory = new Map();
this._gdpPerCapitaCache = new Map();
const ownerByCell = new Map();
let frontier = [];
for (const city of this.cities) {
const k = key(city.coords.x, city.coords.y);
ownerByCell.set(k, city.civ);
this.territory.set(k, city.civ);
frontier.push(city.coords);
}
while (frontier.length > 0) {
const next = [];
for (const coords of frontier) {
const owner = ownerByCell.get(key(coords.x, coords.y));
for (const neighbour of this._neighbours(coords)) {
const nk = key(neighbour.x, neighbour.y);
if (ownerByCell.has(nk) || !this._isLand(neighbour)) continue;
ownerByCell.set(nk, owner);
this.territory.set(nk, owner);
next.push(neighbour);
}
}
frontier = next;
}
this._territoryByCiv = new Map();
for (const [k, owner] of this.territory) {
if (!this._territoryByCiv.has(owner)) this._territoryByCiv.set(owner, []);
this._territoryByCiv.get(owner).push(parseKey(k));
}
this._serializedTerritory = serializeCells(this.territory);
},
// Rebuilds the owner index, the serialised territory and the GDP-per-capita
// cache after `this.territory` changes outside a full recompute.
_reindexTerritory() {
this._territoryByCiv = new Map();
for (const [k, owner] of this.territory) {
if (!this._territoryByCiv.has(owner)) this._territoryByCiv.set(owner, []);
this._territoryByCiv.get(owner).push(parseKey(k));
}
this._serializedTerritory = serializeCells(this.territory);
this._gdpPerCapitaCache = new Map();
},
// Transfers `city` to `newCiv` and, with it, every tile of the old owner's
// territory that is strictly closer (through land) to the captured city than
// to any of their remaining cities. Ties stay with the old owner.
_captureCity(city, newCiv) {
const oldCiv = city.civ;
if (oldCiv === newCiv) return false;
const region = (this._territoryByCiv.get(oldCiv) || []).slice();
const claim = this._nearestCityOwner(city, oldCiv, newCiv);
city.civ = newCiv;
for (const coords of region) {
const k = key(coords.x, coords.y);
if (claim.get(k) === newCiv) this.territory.set(k, newCiv);
}
// The captured city's own tile always changes hands, ties or not.
this.territory.set(key(city.coords.x, city.coords.y), newCiv);
this.training.delete(city.id);
this._reindexTerritory();
this._rebuildCityIndex();
this._visibilityDirty = true;
this._addNews({ type: NEWS_CAPTURE, city: city.name, from: oldCiv, to: newCiv });
this._emitChanged();
return true;
},
// Multi-source BFS over land from the captured city and every remaining city
// of the old owner. Each tile records the civ of the nearest city; a tile
// reached at the same distance from both sides is credited to the old owner.
_nearestCityOwner(captured, oldCiv, newCiv) {
const claim = new Map();
const visited = new Set();
let frontier = [];
const sources = [{ coords: captured.coords, civ: newCiv }];
for (const city of this.cities) {
if (city !== captured && city.civ === oldCiv) {
sources.push({ coords: city.coords, civ: oldCiv });
}
}
for (const source of sources) {
const k = key(source.coords.x, source.coords.y);
if (visited.has(k)) continue;
visited.add(k);
claim.set(k, source.civ);
frontier.push(source.coords);
}
while (frontier.length > 0) {
const nextFrontier = [];
const nextClaim = new Map();
for (const coords of frontier) {
const civ = claim.get(key(coords.x, coords.y));
for (const neighbour of this._neighbours(coords)) {
const nk = key(neighbour.x, neighbour.y);
if (visited.has(nk) || !this._isLand(neighbour)) continue;
if (nextClaim.has(nk)) {
if (nextClaim.get(nk) === newCiv && civ === oldCiv) nextClaim.set(nk, oldCiv);
continue;
}
nextClaim.set(nk, civ);
nextFrontier.push(neighbour);
}
}
for (const [k, civ] of nextClaim) {
claim.set(k, civ);
visited.add(k);
}
frontier = nextFrontier;
}
return claim;
},
};
+94
View File
@@ -0,0 +1,94 @@
// Fog of war and the grid walkers. Explored cells stay known; visible cells
// are the ones currently watched by territory and units.
import { key } from "../hex.js";
import { UNIT_VISION, TERRITORY_VISION } from "./constants.js";
import { serializeSet } from "./helpers.js";
export const visibilityMethods = {
isExplored(civ, coords) {
const set = this.explored.get(civ);
return set ? set.has(key(coords.x, coords.y)) : false;
},
isVisible(civ, coords) {
const set = this.visible.get(civ);
return set ? set.has(key(coords.x, coords.y)) : false;
},
canEnter(civ, proto, coords) {
if (!proto || !this.isExplored(civ, coords)) return false;
const tile = this.tiles[key(coords.x, coords.y)];
if (!tile) return false;
return proto.traversableTerrains.includes(tile.terrainClass);
},
_refreshVisibility() {
this.visible = new Map();
for (let i = 0; i < this.civilisations.length; i++) this.visible.set(i, new Set());
for (const [civ, cells] of this._territoryByCiv) {
for (const coords of cells) this._reveal(civ, coords, TERRITORY_VISION);
}
for (const unit of this.units) {
this._reveal(unit.civ, unit.coords, UNIT_VISION);
}
},
_revealAll() {
for (let i = 0; i < this.civilisations.length; i++) {
this.explored.set(i, new Set(Object.keys(this.tiles)));
}
},
_reveal(civ, center, radius) {
if (!this.explored.has(civ)) this.explored.set(civ, new Set());
if (!this.visible.has(civ)) this.visible.set(civ, new Set());
const exploredCells = this.explored.get(civ);
const visibleCells = this.visible.get(civ);
let discovered = false;
for (const coords of this._cellsInRadius(center, radius)) {
const k = key(coords.x, coords.y);
if (!exploredCells.has(k)) {
exploredCells.add(k);
discovered = true;
}
visibleCells.add(k);
}
if (discovered) this._serializedExplored.delete(civ);
},
_cachedExplored(civ) {
if (!this._serializedExplored.has(civ)) {
this._serializedExplored.set(civ, serializeSet(this.explored.get(civ) || new Set()));
}
return this._serializedExplored.get(civ);
},
_cellsInRadius(center, radius) {
const result = [center];
const visited = new Set([key(center.x, center.y)]);
let frontier = [center];
for (let step = 0; step < radius; step++) {
const next = [];
for (const coords of frontier) {
for (const neighbour of this._neighbours(coords)) {
const k = key(neighbour.x, neighbour.y);
if (visited.has(k)) continue;
visited.add(k);
next.push(neighbour);
result.push(neighbour);
}
}
frontier = next;
}
return result;
},
_neighbours(coords) {
const k = key(coords.x, coords.y);
if (this._neighbourCache.has(k)) return this._neighbourCache.get(k);
const result = this.topology.neighbours(coords.x, coords.y);
this._neighbourCache.set(k, result);
return result;
},
};
+192
View File
@@ -0,0 +1,192 @@
// World generation and settlement placement: laying out capitals and cities,
// spawning their starting units, seeding population and budgets, and the small
// terrain predicates the rest of the model leans on.
import { ECONOMY } from "../data/economy.js";
import { Random } from "../rng.js";
import { key, parseKey } from "../hex.js";
export const worldMethods = {
_placeSettlements() {
const capitals = this._placeCapitals(this.civilisations.length);
for (const site of this._placeCities(capitals)) this._spawnCity(site);
this._rebuildCityIndex();
for (let i = 0; i < Math.min(this.civilisations.length, capitals.length); i++) {
this._spawnUnit(capitals[i], i, this.protoUnits[0]);
}
this._recomputeTerritory();
this._generatePopulation();
this._initBudgets();
this._balanceStartingEconomy();
},
_placeCapitals(count) {
const rng = new Random(this.seed);
return this._generator.pickCapitals(count, rng);
},
_placeCities(capitals) {
return this._generator.pickCities(capitals, this.mapConfig.citiesPerCiv, this.mapConfig.citySpacing);
},
_spawnCity(site) {
const civIndex = site.civIndex;
if (civIndex < 0 || civIndex >= this.civilisations.length) return;
const civ = this.civilisations[civIndex];
const coords = site.coords;
const isCapital = site.isCapital;
const description = this._cityDescriptionFor(civ, isCapital, coords);
const city = {
id: this._nextCityId,
civ: civIndex,
coords,
name: description.name,
isCapital,
population: description.population,
improvements: description.improvements.slice(),
buildings: {},
statuses: [],
};
this.cities.push(city);
this._cityById.set(this._nextCityId, city);
this._nextCityId += 1;
},
_cityDescriptionFor(civ, _isCapital, center) {
return this._generatedCityDescription(civ, center);
},
_rebuildCityIndex() {
this._cityByCoords = new Map();
this._cityAdjacent = new Map();
for (const city of this.cities) {
this._cityByCoords.set(key(city.coords.x, city.coords.y), city);
const owner = city.civ;
if (!this._cityAdjacent.has(owner)) this._cityAdjacent.set(owner, new Set());
const adjacent = this._cityAdjacent.get(owner);
for (const neighbour of this._neighbours(city.coords)) {
adjacent.add(key(neighbour.x, neighbour.y));
}
}
},
_generatedCityDescription(civ, center) {
const index = this.civilisations.indexOf(civ);
const name = this._nextCityName(civ, index);
const population = 500000 +
(Math.abs((center.x * 92837111) ^ (center.y * 689287499)) % 3500000);
return { name, population, improvements: [] };
},
// Picks the civilisation's next city name, skipping any a city elsewhere has
// already taken, and falls back to a numbered name so the whole world never
// has two cities called the same thing.
_nextCityName(civ, index) {
const start = this._cityNamesUsed.get(index) || 0;
for (let i = start; i < civ.cityNames.length; i++) {
const candidate = civ.cityNames[i];
if (this._usedCityNames.has(candidate)) continue;
this._cityNamesUsed.set(index, i + 1);
this._usedCityNames.add(candidate);
return candidate;
}
let suffix = Math.max(start, civ.cityNames.length) + 1;
let candidate = `${civ.name} ${suffix}`;
while (this._usedCityNames.has(candidate)) {
suffix += 1;
candidate = `${civ.name} ${suffix}`;
}
this._cityNamesUsed.set(index, suffix);
this._usedCityNames.add(candidate);
return candidate;
},
_spawnUnit(center, civIndex, proto) {
let index = this.protoUnits.indexOf(proto);
if (index < 0) {
this.protoUnits.push(proto);
index = this.protoUnits.length - 1;
}
const unit = {
id: this._nextUnitId,
civ: civIndex,
proto: index,
coords: center,
hp: proto.maxHp,
maxHp: proto.maxHp,
path: [],
pathIndex: 0,
progressHours: 0,
moveSpeed: null,
waypoints: [],
invader: false,
statuses: [],
};
this.units.push(unit);
this._unitById.set(this._nextUnitId, unit);
this._nextUnitId += 1;
return unit;
},
_generatePopulation() {
this.tilePopulation = new Map();
for (const k in this.tiles) {
if (this._isLand(parseKey(k))) this.tilePopulation.set(k, this._basePopulation(parseKey(k)));
}
for (const city of this.cities) {
this.tilePopulation.set(key(city.coords.x, city.coords.y), city.population);
}
},
_basePopulation(coords) {
return Math.round(ECONOMY.basePopulation * this.terrainStats.populationMultiplier(coords));
},
_initBudgets() {
for (let i = 0; i < this.civilisations.length; i++) {
this.budgets.set(i, ECONOMY.startingBudget);
}
},
// Map generation should not hand one civilisation a richer start than
// another. Terrain makes the raw territories unequal, so after the world is
// populated we scale each civ's population to the common mean and then its
// GDP per capita to the mean of the raw per-capita figures. Total population
// and total GDP are preserved; from here on the factors are constants and
// terrain, buildings, technology and war move the numbers normally.
_balanceStartingEconomy() {
const count = this.civilisations.length;
this._populationBalance = new Map();
this._perCapitaBalance = new Map();
if (count <= 1) return;
const bases = [];
let totalPopulation = 0;
let totalGdp = 0;
for (let i = 0; i < count; i++) {
const base = this._basePlayerAggregates(i);
bases.push(base);
totalPopulation += base.population;
totalGdp += base.gdp;
}
if (totalPopulation <= 0) return;
const targetPopulation = totalPopulation / count;
const targetPerCapita = totalGdp / totalPopulation;
for (let i = 0; i < count; i++) {
const base = bases[i];
if (base.population <= 0) continue;
const perCapita = base.gdp / base.population;
this._populationBalance.set(i, targetPopulation / base.population);
this._perCapitaBalance.set(i, perCapita > 0 ? targetPerCapita / perCapita : 1);
}
},
_isLand(coords) {
const tile = this.tiles[key(coords.x, coords.y)];
return !!tile && tile.terrainClass === "Land";
},
_isNextToCity(coords, owner) {
const adjacent = this._cityAdjacent.get(owner);
return adjacent ? adjacent.has(key(coords.x, coords.y)) : false;
},
};
+767
View File
@@ -0,0 +1,767 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
import { smallState, defaultState, mapLayers } from "./framework/helpers.js";
import { key } from "../shared/hex.js";
import { PROTO_UNITS, CIVILISATIONS, STATUS_BATTLE, BATTLE } from "../shared/data.js";
import { MapView } from "../client/js/map_view.js";
const DAY = 24;
export class CombatTest extends TestCase {
test_military_units_carry_combat_stats() {
const infantry = PROTO_UNITS.find((u) => u.id === "modern_infantry");
const worker = PROTO_UNITS.find((u) => u.id === "worker");
this.assertTrue(infantry.military, "infantry is military");
this.assertGreater(infantry.attack, 0, "infantry attacks");
this.assertGreater(infantry.defense, 0, "infantry defends");
this.assertFalse(worker.military, "worker is not military");
}
test_an_attacker_charges_onto_the_defender_tile() {
const state = smallState();
const { attacker, defender, a, b } = engagement(state);
this.assertEqual(attacker.coords, b, "the attacker joins the defender's tile");
this.assertEqual(defender.coords, b, "the defender holds its tile");
this.assertSize(state.unitsAt(b), 2, "the two stacks share the tile");
this.assertEqual(state._combatTarget(attacker), defender);
this.assertEqual(state._combatTarget(defender), attacker);
this.assertNotEqual(a, b);
const battles = state.snapshot(0).battles;
this.assertSize(battles, 1, "one battlefield is reported");
this.assertEqual(battles[0].slice(0, 2), [b.x, b.y], "it is on the contested tile");
this.assertEqual(battles[0][3], 0, "the charging civ is the attacker");
this.assertEqual(battles[0][2], 1, "the occupant defends");
}
test_pathfinding_rejects_friendly_and_non_military_targets() {
const state = smallState();
const pair = findEmptyLandPair(state);
const mover = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
// Friendly units may stack on the same tile.
const friend = state._spawnUnit(pair.b, 0, state.protoUnits[0]);
this.assertTrue(state._canUnitEnter(mover, pair.b), "a friendly unit does not block");
this.assertTrue(state.requestMove(mover.id, pair.b), "a unit can join a friendly stack");
state.advanceMovement(1000);
this.assertEqual(state.unitsAt(pair.b).length, 2, "both units share the tile");
// An enemy worker blocks too: military only fights military.
const other = findAnotherEmptyLandPair(state, pair);
this.assertNotNull(other, "found a second empty pair");
const enemyWorker = state._spawnUnit(other.b, 1, state.protoUnits[1]);
const secondMover = state._spawnUnit(other.a, 0, state.protoUnits[0]);
this.assertFalse(state.requestMove(secondMover.id, other.b), "no move onto a worker");
this.assertFalse(state._canUnitEnter(secondMover, other.b));
this.assertTrue(state.isMilitary(friend));
this.assertFalse(state.isMilitary(enemyWorker));
}
test_hostile_tiles_are_only_entered_as_the_goal() {
const state = smallState();
const pair = findEmptyLandPair(state);
const mover = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
state._spawnUnit(pair.b, 1, state.protoUnits[0]);
this.assertFalse(
state._canUnitEnter(mover, pair.b, false),
"an enemy unit is avoided in transit"
);
this.assertTrue(
state._canUnitEnter(mover, pair.b, true),
"an enemy unit is allowed as the goal"
);
const city = state.cities.find((c) => c.civ === 1 && !state.unitAt(c.coords));
this.assertNotNull(city, "found an undefended enemy city");
this.assertFalse(
state._canUnitEnter(mover, city.coords, false),
"an enemy city is avoided in transit"
);
this.assertTrue(
state._canUnitEnter(mover, city.coords, true),
"an enemy city is allowed as the goal"
);
}
test_charging_does_not_tick_damage_before_the_daily_round() {
const state = smallState();
const { attacker, defender, b } = engagement(state);
const attackerHp = attacker.hp;
const defenderHp = defender.hp;
state.advanceMovement(48);
this.assertEqual(attacker.coords, b, "the attacker is on the battlefield");
this.assertEqual(defender.coords, b, "the defender cannot slip away");
this.assertEqual(attacker.hp, attackerHp, "no damage before the daily round");
this.assertEqual(defender.hp, defenderHp, "no damage before the daily round");
}
test_fighting_units_carry_the_battle_status() {
const state = smallState();
const { attacker, defender } = engagement(state);
const units = state.snapshot(0).units;
const attackerData = units.find((u) => u.id === attacker.id);
const defenderData = units.find((u) => u.id === defender.id);
this.assertTrue(attackerData.statuses.includes(STATUS_BATTLE), "the attacker is in battle");
this.assertTrue(defenderData.statuses.includes(STATUS_BATTLE), "the defender is in battle");
}
test_battle_triples_the_fighting_unit_upkeep() {
const state = smallState();
const { attacker } = engagement(state);
const proto = state.protoUnits[attacker.proto];
const idleUpkeep = state.getPlayerUpkeep(0) - proto.upkeep * 2;
const idleUnits = state.budgetBreakdown(0).unitUpkeep.total - proto.upkeep * 2;
this.assertApprox(
state.getPlayerUpkeep(0) - idleUpkeep,
proto.upkeep * 2,
1e-9,
"the battle premium is twice the base upkeep"
);
this.assertApprox(
state.budgetBreakdown(0).unitUpkeep.total - idleUnits,
proto.upkeep * 2,
1e-9,
"the budget breakdown reflects the premium"
);
}
test_a_blocked_move_with_no_route_is_abandoned() {
const state = smallState();
const pair = findEmptyLandPair(state);
const mover = state._spawnUnit(pair.a, 0, state.protoUnits[1]);
state._spawnUnit(pair.b, 1, state.protoUnits[0]);
mover.path = [{ x: pair.a.x, y: pair.a.y }, { x: pair.b.x, y: pair.b.y }];
mover.pathIndex = 0;
mover.progressHours = 0;
state.advanceMovement(48);
this.assertEqual(mover.coords, pair.a, "the blocked worker stays put");
this.assertEmpty(mover.path, "the unreachable move is abandoned");
this.assertNull(state._combatTarget(mover), "the worker has no fight");
}
test_a_blocked_move_recalculates_around_an_obstacle() {
const state = smallState();
const spot = findDetour(state);
this.assertNotNull(spot, "found a start/goal pair with a detour");
const mover = state._spawnUnit(spot.start, 0, state.protoUnits[0]);
state._spawnUnit(spot.mid, 0, state.protoUnits[1]);
mover.path = [spot.start, spot.mid, spot.goal];
mover.pathIndex = 0;
mover.progressHours = 0;
state.advanceMovement(1000);
this.assertEqual(mover.coords, spot.goal, "the unit reaches the goal by another route");
this.assertEmpty(mover.path);
}
test_combat_deals_one_round_per_day_with_a_defensive_bonus() {
const state = smallState();
const { attacker, defender } = engagement(state);
for (let hour = 0; hour < DAY - 1; hour++) state.tickHour();
this.assertEqual(attacker.hp, attacker.maxHp, "no round before the day is up");
state.tickHour();
this.assertEqual(state.totalHours, DAY);
const toAttacker = Math.max(
1,
state._unitAttack(defender) - state._effectiveDefense(attacker, false)
);
const toDefender = Math.max(
1,
state._unitAttack(attacker) - state._effectiveDefense(defender, true)
);
this.assertEqual(attacker.hp, attacker.maxHp - toAttacker, "attacker takes the counterblow");
this.assertEqual(defender.hp, defender.maxHp - toDefender, "defender gets the 100% bonus");
}
test_terrain_defense_bonus_reduces_the_damage_taken() {
const flat = combatScenario(null);
const forest = combatScenario("Forest");
const hills = combatScenario("Hills");
const mountain = combatScenario("Mountain");
this.assertApprox(flat.defenderDamage, 300 - 40 * 2, 1e-9);
this.assertApprox(forest.defenderDamage, 300 - 40 * 2 * 1.33, 1e-9);
this.assertApprox(hills.defenderDamage, 300 - 40 * 2 * 1.33, 1e-9);
this.assertApprox(mountain.defenderDamage, 300 - 40 * 2 * 2, 1e-9);
this.assertGreater(flat.defenderDamage, forest.defenderDamage, "forest defends better");
this.assertGreater(forest.defenderDamage, mountain.defenderDamage, "mountain defends best");
}
test_a_city_adds_its_own_defense_bonus() {
const city = combatScenario(null, { city: true });
const flat = combatScenario(null);
this.assertApprox(city.defenderDamage, 300 - 40 * 2 * 1.5, 1e-9);
this.assertLess(city.defenderDamage, flat.defenderDamage, "a city defends better than open land");
}
test_defeated_defender_disappears_and_attacker_holds_the_tile() {
const state = smallState();
const { attacker, defender, b } = engagement(state);
defender.hp = 1;
state._resolveCombatRound();
this.assertNull(state.findUnit(defender.id), "the vanquished unit is gone");
this.assertGreater(attacker.hp, 0, "the attacker survives");
this.assertEqual(attacker.coords, b, "the attacker already holds the tile");
}
test_attacker_disappears_when_the_defender_holds() {
const state = smallState();
const { attacker, defender } = engagement(state);
for (let round = 0; round < 40 && state.findUnit(attacker.id); round++) {
state._resolveCombatRound();
}
this.assertNull(state.findUnit(attacker.id), "the attacker is destroyed");
this.assertNotNull(state.findUnit(defender.id), "the defender holds");
this.assertGreater(defender.hp, 0);
}
test_mutual_destruction_removes_both_units() {
const state = smallState();
const { attacker, defender } = engagement(state);
attacker.hp = 1;
defender.hp = 1;
state._resolveCombatRound();
this.assertNull(state.findUnit(attacker.id));
this.assertNull(state.findUnit(defender.id));
}
test_units_ordered_into_each_other_fight_on_one_tile() {
const state = smallState();
const { attacker, defender, a, b } = engagement(state);
// Pull them apart again and order both to charge.
attacker.coords = a;
defender.coords = b;
attacker.hp = attacker.maxHp;
defender.hp = defender.maxHp;
attacker.path = [{ x: a.x, y: a.y }, { x: b.x, y: b.y }];
attacker.pathIndex = 0;
attacker.progressHours = 0;
defender.path = [{ x: b.x, y: b.y }, { x: a.x, y: a.y }];
defender.pathIndex = 0;
defender.progressHours = 0;
state.advanceMovement(1000);
this.assertEqual(state._combatPairs().length, 1, "the exchange is a single pair");
const toAttacker = Math.max(
1,
state._unitAttack(defender) - state._effectiveDefense(attacker, false)
);
const toDefender = Math.max(
1,
state._unitAttack(attacker) - state._effectiveDefense(defender, true)
);
state._resolveCombatRound();
this.assertEqual(attacker.hp, attacker.maxHp - toAttacker, "one counterblow, not two");
this.assertEqual(defender.hp, defender.maxHp - toDefender, "one blow, not two");
}
test_non_military_units_never_fight() {
const state = smallState();
const pair = findEmptyLandPair(state);
const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[1]);
const defender = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
// A worker can never path into the enemy, so give it the route by hand.
attacker.path = [{ x: pair.a.x, y: pair.a.y }, { x: pair.b.x, y: pair.b.y }];
attacker.pathIndex = 0;
attacker.progressHours = 0;
this.assertNull(state._combatTarget(attacker), "a worker does not attack");
state.advanceMovement(48);
this.assertEqual(attacker.coords, pair.a, "a worker is blocked by the enemy");
this.assertEqual(defender.hp, defender.maxHp, "no damage from a worker");
}
test_a_military_unit_captures_an_undefended_city() {
const state = smallState();
let city = null;
let launch = null;
for (const candidate of state.cities) {
if (candidate.civ !== 1 || state.unitAt(candidate.coords)) continue;
const neighbour = emptyLandNeighbour(state, candidate.coords);
if (!neighbour) continue;
city = candidate;
launch = neighbour;
break;
}
this.assertNotNull(city, "found an undefended enemy city with a staging tile");
const attacker = state._spawnUnit(launch, 0, state.protoUnits[0]);
state.training.set(city.id, [{ protoIndex: 0, capacity: 1, elapsedHours: 0, totalHours: 10 }]);
this.assertTrue(state.requestMove(attacker.id, city.coords));
state.advanceMovement(1000);
this.assertEqual(attacker.coords, city.coords, "the attacker enters the city");
this.assertEqual(city.civ, 0, "the city changes hands");
this.assertEqual(state.civAt(city.coords), 0, "the city tile changes hands");
this.assertNull(state.getTraining(city.id), "the old owner's queue is cancelled");
}
test_a_defended_city_falls_only_after_its_defender_dies() {
const state = smallState();
let city = null;
let launch = null;
for (const candidate of state.cities) {
if (candidate.civ !== 1) continue;
const defender = state.unitAt(candidate.coords);
if (!defender || !state.isMilitary(defender)) continue;
const neighbour = emptyLandNeighbour(state, candidate.coords);
if (!neighbour) continue;
city = candidate;
launch = neighbour;
break;
}
this.assertNotNull(city, "found a defended enemy city with a staging tile");
const defender = state.unitAt(city.coords);
const attacker = state._spawnUnit(launch, 0, state.protoUnits[0]);
this.assertTrue(state.requestMove(attacker.id, city.coords));
state.advanceMovement(100);
this.assertEqual(attacker.coords, city.coords, "the attacker charges into the city");
this.assertEqual(city.civ, 1, "the city has not fallen while it is defended");
this.assertEqual(state._combatTarget(attacker), defender);
defender.hp = 1;
state._resolveCombatRound();
this.assertNull(state.findUnit(defender.id), "the defender dies");
this.assertEqual(city.civ, 0, "the city is captured once the defender is gone");
}
test_non_military_units_cannot_enter_an_enemy_city() {
const state = smallState();
const city = state.cities.find((c) => c.civ === 1);
const worker = state.units.find((u) => u.civ === 0);
const workerProto = state.protoUnits[1];
const proxy = { ...worker, proto: state.protoUnits.indexOf(workerProto) };
this.assertFalse(
state._canUnitEnter(proxy, city.coords),
"a worker cannot walk into an enemy city"
);
}
test_capture_takes_only_the_closest_territory() {
const state = smallState();
const cities = state.cities.filter((c) => c.civ === 1);
this.assertGreaterOrEqual(cities.length, 2, "civ 1 has two cities");
const captured = cities[0];
const before = (state._territoryByCiv.get(1) || []).map((c) => ({ x: c.x, y: c.y }));
const distances = new Map();
for (const city of cities) distances.set(city.id, landDistances(state, city.coords));
const shouldFlip = new Set();
for (const coords of before) {
const k = key(coords.x, coords.y);
const capturedDist = distances.get(captured.id).get(k);
let stays = capturedDist === undefined;
if (!stays) {
for (const other of cities) {
if (other === captured) continue;
const otherDist = distances.get(other.id).get(k);
if (otherDist !== undefined && otherDist <= capturedDist) {
stays = true;
break;
}
}
}
if (!stays) shouldFlip.add(k);
}
this.assertTrue(state._captureCity(captured, 0), "the city is captured");
this.assertEqual(captured.civ, 0);
for (const coords of before) {
const k = key(coords.x, coords.y);
if (shouldFlip.has(k)) this.assertEqual(state.civAt(coords), 0, `tile ${k} is captured`);
else this.assertEqual(state.civAt(coords), 1, `tile ${k} stays with its owner`);
}
this.assertEqual(state._territoryByCiv.get(1).length, before.length - shouldFlip.size);
}
test_dead_units_leave_the_snapshot() {
const state = smallState();
const { defender } = engagement(state);
const before = state.snapshot(0).units.length;
defender.hp = 1;
state._resolveCombatRound();
this.assertEqual(state.snapshot(0).units.length, before - 1, "the dead unit leaves the snapshot");
this.assertNull(state.findUnit(defender.id));
}
test_artillery_and_infantry_trade_blows_on_one_tile() {
const state = smallState();
const pair = findEmptyLandPair(state);
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
this.assertTrue(state.requestMove(artillery.id, pair.b), "attack order accepted");
state.advanceMovement(1000);
this.assertEqual(artillery.coords, pair.b, "the artillery charges onto the infantry");
this.assertEqual(state._combatTarget(artillery), infantry);
const artilleryHp = artillery.hp;
const infantryHp = infantry.hp;
state._resolveCombatRound();
this.assertLess(artillery.hp, artilleryHp, "the thin-skinned artillery is hit");
this.assertLess(infantry.hp, infantryHp, "the infantry is hit back");
}
test_melee_charges_an_artillery_and_hits_it() {
const state = smallState();
const pair = findEmptyLandPair(state);
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
this.assertTrue(state.requestMove(infantry.id, pair.a), "the infantry charges in");
state.advanceMovement(1000);
this.assertEqual(state._combatTarget(infantry), artillery);
const artilleryHp = artillery.hp;
state._resolveCombatRound();
this.assertLess(artillery.hp, artilleryHp, "an adjacent artillery has almost no protection");
}
test_a_battle_removes_a_share_of_population_and_gdp_each_day() {
const state = smallState();
const { b } = joinBattleOn(state, ownedLandPair(state));
const k = key(b.x, b.y);
const population = state.tilePopulation.get(k);
const perCapita = state.getTileGdpPerCapita(b);
this.assertGreater(perCapita, 0, "the battlefield has a taxable economy");
state._tickBattleTiles();
this.assertApprox(
state.tilePopulation.get(k),
population * (1 - BATTLE.populationLossPerDay),
1e-6,
"the tile loses population"
);
this.assertApprox(
state.getTileGdpPerCapita(b),
perCapita * (1 - BATTLE.gdpPerCapitaLossPerDay),
1e-6,
"the tile loses GDP per capita"
);
}
test_gdp_per_capita_recovers_only_after_thirty_days() {
const state = smallState();
const { b } = joinBattleOn(state, ownedLandPair(state));
const k = key(b.x, b.y);
const perCapita = state.getTileGdpPerCapita(b);
state._tickBattleTiles();
const deficit = state.tileBattleGdpDeficit.get(k);
this.assertApprox(deficit, perCapita * BATTLE.gdpPerCapitaLossPerDay, 1e-6);
const start = state.tileBattleRecoveryStart.get(k);
this.assertEqual(start, BATTLE.recoveryDelayDays * DAY, "recovery waits thirty days");
state.totalHours = start - 1;
state._tickGdpRecovery();
this.assertApprox(state.tileBattleGdpDeficit.get(k), deficit, 1e-9, "nothing recovers early");
state.totalHours = start;
state._tickGdpRecovery();
this.assertApprox(
state.tileBattleGdpDeficit.get(k),
deficit - BATTLE.recoveryPerDay,
1e-9,
"recovery begins at $100 a day"
);
for (let day = 0; day < Math.ceil(deficit / BATTLE.recoveryPerDay) + 1; day++) {
state.totalHours += DAY;
state._tickGdpRecovery();
}
this.assertFalse(state.tileBattleGdpDeficit.has(k), "the wound heals completely");
this.assertFalse(state.tileBattleRecoveryStart.has(k), "and the clock is cleared");
}
test_a_full_day_of_fighting_scars_the_tile() {
const state = smallState();
const { b } = joinBattleOn(state, ownedLandPair(state));
const k = key(b.x, b.y);
const before = state.getTileGdpPerCapita(b);
this.assertFalse(state.tileBattleGdpDeficit.has(k), "the tile is untouched at first");
for (let hour = 0; hour < DAY; hour++) state.tickHour();
this.assertTrue(state.tileBattleGdpDeficit.has(k), "the daily tick records the scar");
this.assertLess(state.getTileGdpPerCapita(b), before, "and the tile is worth less");
this.assertEqual(state.tileBattleRecoveryStart.get(k), DAY + BATTLE.recoveryDelayDays * DAY);
}
test_a_unit_in_combat_cannot_pillage() {
const state = smallState();
const tile = (state._territoryByCiv.get(1) || []).find(
(coords) => state._isLand(coords) && !state.cityAt(coords)
);
this.assertNotNull(tile, "found an enemy-owned land tile");
const raider = state._spawnUnit(tile, 0, state.protoUnits[0]);
const defender = state._spawnUnit(tile, 1, state.protoUnits[0]);
this.assertEqual(state.civAt(tile), 1, "the tile belongs to the defender");
this.assertFalse(state.canPillage([raider.id]), "a unit in combat cannot pillage");
state._destroyUnit(defender);
this.assertTrue(state.canPillage([raider.id]), "once the fight is over it can");
}
async test_client_prediction_mirrors_occupancy() {
const state = defaultState();
const pair = findEmptyLandPair(state);
const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
state._spawnUnit(pair.b, 1, state.protoUnits[0]);
const env = await setupDom();
try {
const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$));
map.$viewport.width = () => 800;
map.$viewport.height = () => 600;
map.setCatalogue(CIVILISATIONS, PROTO_UNITS);
map.applySnapshot(state.snapshot(0));
const military = PROTO_UNITS.find((u) => u.id === "modern_infantry");
const worker = PROTO_UNITS.find((u) => u.id === "worker");
this.assertTrue(
map._canEnter(0, military, pair.b, attacker.id, true),
"military may target an enemy military unit as the goal"
);
this.assertFalse(
map._canEnter(0, military, pair.b, attacker.id, false),
"an enemy military unit is avoided in transit"
);
this.assertFalse(
map._canEnter(0, worker, pair.b, 0, true),
"a worker is blocked by an enemy"
);
this.assertTrue(
map._canEnter(0, military, pair.a, 999),
"a friendly unit does not block; units stack"
);
this.assertTrue(
map._canEnter(0, military, pair.a, attacker.id),
"a unit ignores itself"
);
} finally {
teardownDom(env);
}
}
async test_client_prediction_charges_onto_the_target() {
const state = defaultState();
const pair = findEmptyLandPair(state);
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
state._spawnUnit(pair.b, 1, state.protoUnits[0]);
const env = await setupDom();
try {
const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$));
map.$viewport.width = () => 800;
map.$viewport.height = () => 600;
map.setCatalogue(CIVILISATIONS, PROTO_UNITS);
map.applySnapshot(state.snapshot(0));
const data = state.snapshot(0).units.find((u) => u.id === artillery.id);
const route = await map.predictMove(data, pair.b);
this.assertNotNull(route, "a route is predicted");
const last = route[route.length - 1];
this.assertEqual(last, pair.b, "the predicted route enters the target tile");
} finally {
teardownDom(env);
}
}
async test_client_animates_the_charge_onto_the_enemy() {
const state = defaultState();
const pair = findEmptyLandPair(state);
const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
state._spawnUnit(pair.b, 1, state.protoUnits[0]);
state.requestMove(attacker.id, pair.b);
const env = await setupDom();
try {
const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$));
map.$viewport.width = () => 800;
map.$viewport.height = () => 600;
map.setCatalogue(CIVILISATIONS, PROTO_UNITS);
map.applySnapshot(state.snapshot(0));
const motion = map._unitMotion.get(attacker.id);
this.assertNotNull(motion, "the attacker has a motion");
const last = motion.path[motion.path.length - 1];
this.assertEqual(last, pair.b, "the animation charges onto the defender");
} finally {
teardownDom(env);
}
}
async test_client_draws_half_size_stacks_with_the_battle_icon_between_them() {
const state = defaultState();
const { b } = engagement(state);
const env = await setupDom();
try {
const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$));
map.$viewport.width = () => 800;
map.$viewport.height = () => 600;
map.setCatalogue(CIVILISATIONS, PROTO_UNITS);
map.applySnapshot(state.snapshot(0));
const units = env.$("#layer-entities .unit.in-battle");
this.assertEqual(units.length, 2, "both stacks are drawn half-size");
const marker = env.$("#layer-entities .battle-marker");
this.assertEqual(marker.length, 1, "one battle marker is drawn");
this.assertTrue(
(marker.find("img").attr("src") || "").endsWith("icon_battle.svg"),
"the marker uses the battle icon"
);
const lefts = units.map((_, el) => parseFloat(el.style.left)).get();
const tops = units.map((_, el) => parseFloat(el.style.top)).get();
const markerLeft = parseFloat(marker[0].style.left);
const markerTop = parseFloat(marker[0].style.top);
this.assertLess(Math.min(...lefts), markerLeft, "one stack sits left of the icon");
this.assertGreater(Math.max(...lefts), markerLeft, "the other sits right of it");
this.assertApprox(markerTop, tops[0], 1e-6, "the marker is on the tile");
this.assertTrue(map._battles.has(key(b.x, b.y)), "the tile is registered as a battlefield");
} finally {
teardownDom(env);
}
}
}
// Spawns an attacker (civ 0) and a defender (civ 1) beside each other, then
// orders the attacker in. Returns the two units and both tiles, with the fight
// already joined on `b`.
function engagement(state) {
return joinBattleOn(state, findEmptyLandPair(state));
}
function joinBattleOn(state, pair) {
const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
const defender = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
state.requestMove(attacker.id, pair.b);
state.advanceMovement(1000);
return { attacker, defender, a: pair.a, b: pair.b };
}
// Two adjacent empty land tiles that both belong to a civilisation, so the
// battlefield has a population and a GDP per capita to scar.
function ownedLandPair(state) {
for (const cell of state.landCells) {
if (state.civAt(cell) < 0) continue;
if (state.unitAt(cell) || state.cityAt(cell)) continue;
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
if (!state._isLand(neighbour)) continue;
if (state.civAt(neighbour) < 0) continue;
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } };
}
}
return findEmptyLandPair(state);
}
// Two units planted on one tile of the given terrain, resolved for one round.
// A `city` option adds a city under them; the attacker's stats are boosted so
// the damage numbers are easy to read.
function combatScenario(defenderType, options = {}) {
const state = smallState();
const attackerProto = { ...state.protoUnits[0], attack: 300 };
const tile = findTerrainTile(state, defenderType);
if (!tile) return null;
if (options.city) {
state._spawnCity({ civIndex: 1, coords: { x: tile.x, y: tile.y }, isCapital: false });
state._rebuildCityIndex();
}
const attacker = state._spawnUnit(tile, 0, attackerProto);
const defender = state._spawnUnit(tile, 1, state.protoUnits[0]);
const attackerHp = attacker.hp;
const defenderHp = defender.hp;
state._resolveCombatRound();
return {
state,
attacker,
defender,
attackerDamage: attackerHp - attacker.hp,
defenderDamage: defenderHp - defender.hp,
};
}
function findTerrainTile(state, terrainType) {
for (const cell of state.landCells) {
if (state.unitAt(cell) || state.cityAt(cell)) continue;
if (terrainType && state.tiles[key(cell.x, cell.y)].terrainType !== terrainType) continue;
return { x: cell.x, y: cell.y };
}
return null;
}
function findEmptyLandPair(state) {
let fallback = null;
for (const cell of state.landCells) {
if (state.unitAt(cell) || state.cityAt(cell)) continue;
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
if (!state._isLand(neighbour)) continue;
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
const pair = { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } };
if (!fallback) fallback = pair;
const ownA = state.civAt(cell) < 0 || state.civAt(cell) === 0;
const ownB = state.civAt(neighbour) < 0 || state.civAt(neighbour) === 0;
if (ownA && ownB) return pair;
}
}
return fallback;
}
function findAnotherEmptyLandPair(state, first) {
for (const cell of state.landCells) {
if (state.unitAt(cell) || state.cityAt(cell)) continue;
if (sameCoords(cell, first.a) || sameCoords(cell, first.b)) continue;
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
if (!state._isLand(neighbour)) continue;
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
if (sameCoords(neighbour, first.a) || sameCoords(neighbour, first.b)) continue;
return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } };
}
}
return null;
}
// A start tile, an adjacent direct step and a goal two tiles away, with a
// distinct land neighbour of the start that also touches the goal: the detour
// a blocked unit can take.
function findDetour(state) {
for (const start of state.landCells) {
if (state.unitAt(start) || state.cityAt(start)) continue;
for (const mid of state.topology.neighbours(start.x, start.y)) {
if (!state._isLand(mid) || state.unitAt(mid) || state.cityAt(mid)) continue;
for (const goal of state.topology.neighbours(mid.x, mid.y)) {
if (sameCoords(goal, start) || !state._isLand(goal)) continue;
if (state.unitAt(goal) || state.cityAt(goal)) continue;
for (const alt of state.topology.neighbours(start.x, start.y)) {
if (sameCoords(alt, mid) || !state._isLand(alt)) continue;
if (state.unitAt(alt) || state.cityAt(alt)) continue;
if (state.topology.tileDistance(alt, goal) === 1) {
return { start, mid, goal, alt };
}
}
}
}
}
return null;
}
function emptyLandNeighbour(state, coords) {
for (const neighbour of state.topology.neighbours(coords.x, coords.y)) {
if (!state._isLand(neighbour)) continue;
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
return { x: neighbour.x, y: neighbour.y };
}
return null;
}
function landDistances(state, start) {
const dist = new Map();
dist.set(key(start.x, start.y), 0);
let frontier = [start];
while (frontier.length > 0) {
const next = [];
for (const coords of frontier) {
const distance = dist.get(key(coords.x, coords.y));
for (const neighbour of state.topology.neighbours(coords.x, coords.y)) {
const nk = key(neighbour.x, neighbour.y);
if (dist.has(nk) || !state._isLand(neighbour)) continue;
dist.set(nk, distance + 1);
next.push(neighbour);
}
}
frontier = next;
}
return dist;
}
function sameCoords(a, b) {
return a.x === b.x && a.y === b.y;
}
+142
View File
@@ -0,0 +1,142 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
import { smallState } from "./framework/helpers.js";
import { NationModal } from "../client/js/modals.js";
import { GOVERNMENTS, TECHNOLOGIES } from "../shared/data.js";
function tileOwnedBy(state, owner) {
for (const [k, tileOwner] of state.territory) {
if (tileOwner !== owner) continue;
const i = k.indexOf(",");
const coords = { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) };
if (state.unitAt(coords) || state.cityAt(coords)) continue;
return coords;
}
return null;
}
function findEmptyLandPair(state) {
for (const cell of state.landCells) {
if (state.unitAt(cell) || state.cityAt(cell)) continue;
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
if (!state._isLand(neighbour)) continue;
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } };
}
}
return null;
}
export class ConflictsStateTest extends TestCase {
test_declaring_war_opens_a_dated_conflict() {
const state = smallState();
for (let i = 0; i < 7; i++) state.advanceHour();
this.assertTrue(state.requestDeclareWar(0, 1));
this.assertSize(state.conflicts, 1, "one conflict is recorded");
const conflict = state.snapshot(0).conflicts[0];
this.assertEqual(conflict.a, 0);
this.assertEqual(conflict.b, 1);
this.assertEqual(conflict.startHours, 7);
this.assertTrue(conflict.started.includes("07:00"), `the start hour is kept: ${conflict.started}`);
this.assertSize(conflict.sides, 2);
this.assertEqual(conflict.sides[0], { civ: 0, military: 0, civilians: 0 });
this.assertEqual(conflict.sides[1], { civ: 1, military: 0, civilians: 0 });
}
test_military_deaths_are_tallied_per_side() {
const state = smallState();
state.requestDeclareWar(0, 1);
const pair = findEmptyLandPair(state);
const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
const defender = state._spawnUnit(pair.a, 1, state.protoUnits[0]);
attacker.hp = attacker.maxHp;
defender.hp = 1;
state._resolveCombatRound();
const conflict = state.conflicts.values().next().value;
this.assertEqual(conflict.military.get(1), 1, "the fallen defender's civ is credited the loss");
this.assertFalse(conflict.military.has(0), "the surviving attacker lost no unit");
}
test_pillaging_counts_civilian_casualties() {
const state = smallState();
state.requestDeclareWar(0, 1);
const coords = tileOwnedBy(state, 1);
this.assertNotNull(coords, "found an enemy tile");
const k = `${coords.x},${coords.y}`;
state.tilePopulation.set(k, 100000);
const unit = state._spawnUnit(coords, 0, state.protoUnits[0]);
unit.hp = 100;
this.assertTrue(state.requestPillage([unit.id]));
const conflict = state.conflicts.values().next().value;
this.assertEqual(conflict.civilians.get(1), 10000, "the civilians driven off are counted");
}
test_conflicts_are_shared_by_every_viewer() {
const state = smallState();
state.requestDeclareWar(0, 1);
this.assertEqual(state.snapshot(0).conflicts, state.snapshot(1).conflicts);
}
}
export class ConflictsModalTest extends TestCase {
async test_conflicts_tab_lists_and_expands() {
const env = await setupDom();
try {
const modal = new NationModal();
const civs = [{ name: "France" }, { name: "Britain" }, { name: "Poland" }];
const conflicts = [
{
a: 0,
b: 1,
started: "1 January 2000, 00:00",
startHours: 0,
sides: [
{ civ: 0, military: 12, civilians: 3400 },
{ civ: 1, military: 7, civilians: 900 },
],
},
];
modal.show(
GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, {},
{ civs, localCiv: 0, relations: [[0, 1, "war"]] },
conflicts
);
env.$("#modal-nation .tab[data-tab=conflicts]").click();
this.assertTrue(env.$("#tab-conflicts").hasClass("active"));
this.assertTrue(env.$("#conflicts-sub").text().includes("1 ongoing conflict"));
const rows = env.$("#conflicts-list .conflict-card");
this.assertSize(rows, 1);
this.assertTrue(rows.eq(0).find(".name").text().includes("France vs Britain"));
const detail = rows.eq(0).find(".conflict-detail");
this.assertTrue(detail.hasClass("hidden"), "details start collapsed");
rows.eq(0).click();
this.assertFalse(detail.hasClass("hidden"), "clicking a conflict reveals its details");
const text = detail.text();
this.assertTrue(text.includes("Conflict started 1 January 2000, 00:00"));
this.assertTrue(text.includes("France — Military: 12 · Civilians: 3 400"));
this.assertTrue(text.includes("Britain — Military: 7 · Civilians: 900"));
modal.close();
} finally {
teardownDom(env);
}
}
async test_conflicts_tab_reports_peace() {
const env = await setupDom();
try {
const modal = new NationModal();
modal.show(GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, {}, null, []);
this.assertTrue(env.$("#conflicts-sub").text().includes("No ongoing conflicts"));
this.assertTrue(env.$("#conflicts-list").text().includes("at peace"));
modal.close();
} finally {
teardownDom(env);
}
}
}
+248
View File
@@ -0,0 +1,248 @@
import { TestCase } from "./framework/test_case.js";
import { GameServer } from "../server/game_server.js";
import { smallState, SEED } from "./framework/helpers.js";
function parseKey(k) {
const i = k.indexOf(",");
return { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) };
}
// A tile owned by a civilisation other than `mover`, plus an adjacent land tile
// the mover can stage a unit on. Both tiles are free of units and cities.
function foreignTerritoryWithStaging(state, mover) {
for (const [k, owner] of state.territory) {
if (owner === mover) continue;
const target = parseKey(k);
if (state.unitAt(target) || state.cityAt(target)) continue;
for (const neighbour of state.topology.neighbours(target.x, target.y)) {
if (!state._isLand(neighbour)) continue;
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
if (state.civAt(neighbour) === owner) continue;
return { staging: neighbour, target };
}
}
return null;
}
function ownLandWithStaging(state, civ) {
for (const cell of state.landCells) {
if (state.civAt(cell) !== civ) continue;
if (state.unitAt(cell) || state.cityAt(cell)) continue;
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
if (!state._isLand(neighbour)) continue;
if (state.civAt(neighbour) !== civ) continue;
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
return { staging: { x: cell.x, y: cell.y }, target: { x: neighbour.x, y: neighbour.y } };
}
}
return null;
}
function fakeNetwork() {
return {
peers: new Set(),
sent: [],
getPeerIds() {
return Array.from(this.peers);
},
send(peerId, message) {
this.sent.push({ peerId, message });
},
};
}
function makeServer(civIds = ["france", "britain"]) {
const network = fakeNetwork();
const server = new GameServer(network);
server.configureGame({ seed: SEED, player_civ: 0, civilisations: civIds });
return { server, network };
}
export class DiplomacyStateTest extends TestCase {
test_everyone_starts_at_peace() {
const state = smallState(["france", "britain", "poland"]);
this.assertFalse(state.isAtWar(0, 1));
this.assertFalse(state.isAtWar(1, 2));
this.assertEqual(state.getRelation(0, 1), "peace");
this.assertSize(state.wars, 0);
this.assertEmpty(state.news);
}
test_declaring_war_sets_the_relation() {
const state = smallState();
this.assertTrue(state.requestDeclareWar(0, 1));
this.assertTrue(state.isAtWar(0, 1));
this.assertTrue(state.isAtWar(1, 0), "the relation is symmetric");
this.assertEqual(state.getRelation(1, 0), "war");
}
test_declaring_war_twice_is_rejected() {
const state = smallState();
this.assertTrue(state.requestDeclareWar(0, 1));
this.assertFalse(state.requestDeclareWar(0, 1), "already at war");
this.assertFalse(state.requestDeclareWar(1, 0), "the other direction is the same war");
}
test_declaring_war_rejects_invalid_targets() {
const state = smallState();
this.assertFalse(state.requestDeclareWar(0, 0), "cannot fight yourself");
this.assertFalse(state.requestDeclareWar(0, 99), "unknown target");
this.assertFalse(state.requestDeclareWar(-1, 1), "unknown attacker");
}
test_declaration_is_newsworthy() {
const state = smallState();
for (let i = 0; i < 5; i++) state.advanceHour();
state.requestDeclareWar(0, 1);
this.assertSize(state.news, 1);
const entry = state.news[0];
this.assertEqual(entry.type, "war");
this.assertEqual(entry.actor, 0);
this.assertEqual(entry.target, 1);
this.assertEqual(entry.totalHours, 5);
this.assertTrue(entry.date.includes("05:00"), `date has the hour: ${entry.date}`);
}
test_the_news_feed_is_capped() {
const state = smallState();
for (let i = 0; i < 250; i++) state._addNews({ type: "war", actor: 0, target: 1 });
this.assertEqual(state.news.length, 100);
this.assertEqual(state.news[0].id, 151);
this.assertEqual(state.news[state.news.length - 1].id, 250);
}
test_moving_a_military_unit_into_peaceful_territory_declares_war() {
const state = smallState();
const site = foreignTerritoryWithStaging(state, 0);
this.assertNotNull(site, "found foreign territory with a staging tile");
const unit = state._spawnUnit(site.staging, 0, state.protoUnits[0]);
this.assertTrue(state.requestMove(unit.id, site.target));
this.assertTrue(state.isAtWar(0, 1), "the march declared war");
this.assertSize(state.news.filter((e) => e.type === "war"), 1);
}
test_peaceful_movement_adds_no_news_when_already_at_war() {
const state = smallState();
state.requestDeclareWar(0, 1);
const site = foreignTerritoryWithStaging(state, 0);
const unit = state._spawnUnit(site.staging, 0, state.protoUnits[0]);
this.assertTrue(state.requestMove(unit.id, site.target));
this.assertSize(state.news, 1, "only the original declaration is news");
this.assertSize(state.news.filter((e) => e.type === "war"), 1);
}
test_civilian_movement_does_not_declare_war() {
const state = smallState();
const site = foreignTerritoryWithStaging(state, 0);
const worker = state._spawnUnit(site.staging, 0, state.protoUnits[1]);
this.assertFalse(state.isMilitary(worker), "the unit is a civilian");
this.assertTrue(state.requestMove(worker.id, site.target));
this.assertFalse(state.isAtWar(0, 1), "civilians cross without a war");
this.assertEmpty(state.news);
}
test_capturing_a_city_is_newsworthy() {
const state = smallState();
const city = state.cities.find((c) => c.civ === 1);
this.assertTrue(state._captureCity(city, 0));
const capture = state.news.find((e) => e.type === "capture");
this.assertNotNull(capture);
this.assertEqual(capture.city, city.name);
this.assertEqual(capture.from, 1);
this.assertEqual(capture.to, 0);
this.assertHas(capture, "date");
}
test_snapshot_carries_diplomacy_and_news() {
const state = smallState();
state.requestDeclareWar(0, 1);
const snapshot = state.snapshot(0);
this.assertHas(snapshot, "diplomacy");
this.assertHas(snapshot, "news");
const relation = snapshot.diplomacy.find((e) => e[0] === 0 && e[1] === 1);
this.assertEqual(relation[2], "war");
this.assertSize(snapshot.news, 1);
}
test_diplomacy_and_news_are_shared_by_every_viewer() {
const state = smallState();
state.requestDeclareWar(0, 1);
const first = state.snapshot(0);
const second = state.snapshot(1);
this.assertEqual(first.diplomacy, second.diplomacy);
this.assertEqual(first.news, second.news);
}
test_snapshot_carries_every_nations_economy() {
const state = smallState(["france", "britain", "poland"]);
const stats = state.snapshot(0).civStats;
this.assertSize(stats, 3, "one entry per nation");
for (let civ = 0; civ < 3; civ++) {
this.assertEqual(stats[civ].population, state.getPlayerPopulation(civ));
this.assertApprox(stats[civ].gdp, state.getPlayerGdp(civ), 1e-6);
const expected = stats[civ].population > 0
? stats[civ].gdp / stats[civ].population
: 0;
this.assertApprox(stats[civ].gdpPerCapita, expected, 1e-9);
}
this.assertEqual(state.snapshot(0).civStats, state.snapshot(1).civStats, "shared by every viewer");
}
test_declaration_notifies_listeners() {
const state = smallState();
let changes = 0;
state.onChanged(() => { changes += 1; });
state.requestDeclareWar(0, 1);
this.assertGreater(changes, 0, "the declaration is published");
}
}
export class DiplomacyServerTest extends TestCase {
test_declare_war_order_is_accepted() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
this.assertTrue(server.handleOrder(1, { type: "declare_war", target: 1 }));
this.assertTrue(server.state.isAtWar(0, 1));
this.assertSize(server.state.news, 1);
}
test_declare_war_order_rejects_bad_targets() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
this.assertFalse(server.handleOrder(1, { type: "declare_war", target: 0 }));
this.assertFalse(server.handleOrder(1, { type: "declare_war", target: 99 }));
}
test_declare_war_order_is_rejected_for_spectators() {
const { server } = makeServer();
this.assertFalse(server.handleOrder(7, { type: "declare_war", target: 1 }));
}
test_move_order_into_peaceful_territory_declares_war() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const site = foreignTerritoryWithStaging(server.state, 0);
const unit = server.state._spawnUnit(site.staging, 0, server.state.protoUnits[0]);
this.assertTrue(server.handleOrder(1, {
type: "move",
unit: unit.id,
coords: [site.target.x, site.target.y],
}));
this.assertTrue(server.state.isAtWar(0, 1));
}
test_move_order_into_own_territory_is_not_news() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const site = ownLandWithStaging(server.state, 0);
this.assertNotNull(site, "found two adjacent friendly land tiles");
const unit = server.state._spawnUnit(site.staging, 0, server.state.protoUnits[0]);
this.assertTrue(server.handleOrder(1, {
type: "move",
unit: unit.id,
coords: [site.target.x, site.target.y],
}));
this.assertFalse(server.state.isAtWar(0, 1));
this.assertEmpty(server.state.news);
}
}
+63
View File
@@ -0,0 +1,63 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
import { GameFeed } from "../client/js/feed.js";
const tick = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export class GameFeedTest extends TestCase {
async test_lines_stack_with_the_newest_at_the_bottom() {
const env = await setupDom();
try {
const feed = new GameFeed(env.$("#game-feed"), { ttl: -1 });
feed.push("first");
feed.push("second");
const lines = env.$("#game-feed .feed-line");
this.assertSize(lines, 2);
this.assertEqual(lines.eq(0).text(), "first", "older lines stay on top");
this.assertEqual(lines.eq(1).text(), "second", "newest line is at the bottom");
feed.clear();
} finally {
teardownDom(env);
}
}
async test_alerts_are_marked() {
const env = await setupDom();
try {
const feed = new GameFeed(env.$("#game-feed"), { ttl: -1 });
feed.push("you are at war", { alert: true });
this.assertSize(env.$("#game-feed .feed-line.feed-alert"), 1);
feed.clear();
} finally {
teardownDom(env);
}
}
async test_lines_retire_on_their_own() {
const env = await setupDom();
try {
const feed = new GameFeed(env.$("#game-feed"), { ttl: 5, alertTtl: 5, fade: 0 });
feed.push("short lived");
this.assertSize(env.$("#game-feed .feed-line"), 1);
await tick(40);
this.assertSize(env.$("#game-feed .feed-line"), 0, "the line disappeared");
} finally {
teardownDom(env);
}
}
async test_clear_cancels_pending_retirements() {
const env = await setupDom();
try {
const feed = new GameFeed(env.$("#game-feed"), { ttl: 5, fade: 0 });
feed.push("one");
feed.push("two");
feed.clear();
this.assertSize(env.$("#game-feed .feed-line"), 0);
await tick(30);
this.assertSize(env.$("#game-feed .feed-line"), 0, "nothing reappears");
} finally {
teardownDom(env);
}
}
}
+140
View File
@@ -0,0 +1,140 @@
import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js";
// Right-click attack and Ctrl-right-click scheduled movement, exercised on the
// authoritative GameState.
export class AttackOrderTest extends TestCase {
test_attack_is_rejected_without_a_target() {
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
const goal = neutralLine(state, 1)[0];
this.assertFalse(state.requestAttack([unit.id], goal), "an empty tile is not an attack");
this.assertFalse(state.requestAttack([99999], goal), "unknown units are rejected");
}
test_attack_marches_onto_the_enemy_and_joins_battle() {
const state = smallState();
const pair = emptyLandPair(state);
const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
const defender = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
this.assertTrue(state.requestAttack([attacker.id], pair.b), "the enemy stack is a target");
state.advanceMovement(1000);
this.assertEqual(attacker.coords, pair.b, "the attacker charges onto the enemy tile");
this.assertEqual(state._combatTarget(attacker), defender, "the two stacks are fighting");
}
test_attack_targets_an_enemy_city() {
const state = smallState();
const city = state.cities.find((c) => c.civ === 1);
const launch = emptyLandNeighbour(state, city.coords);
this.assertNotNull(launch, "found a staging tile by the enemy city");
const attacker = state._spawnUnit(launch, 0, state.protoUnits[0]);
this.assertTrue(state.requestAttack([attacker.id], city.coords), "the enemy city is a target");
}
test_attack_becomes_a_move_when_the_enemy_has_left() {
const state = smallState();
const pair = emptyLandPair(state);
const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
const defender = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
this.assertTrue(state.requestAttack([attacker.id], pair.b));
// The defender slips away before the attacker arrives.
state._destroyUnit(defender);
state.advanceMovement(1000);
this.assertEqual(attacker.coords, pair.b, "the order is simply a move to the tile");
this.assertNull(state._combatTarget(attacker), "there is no fight to join");
}
}
export class ScheduledOrderTest extends TestCase {
test_scheduled_move_visits_each_destination_in_turn() {
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
const [first, second] = neutralLine(state, 2);
this.assertTrue(state.requestScheduledMove([unit.id], [first, second]));
this.assertSize(unit.waypoints, 1, "the second destination stays queued");
this.assertEqual(unit.path[unit.path.length - 1], first, "the first leg heads to the first stop");
state.advanceMovement(1000);
this.assertEqual(unit.coords, second, "the unit carries on to the second stop");
this.assertEmpty(unit.waypoints);
}
test_scheduled_move_can_append_a_destination() {
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
const [first, second] = neutralLine(state, 2);
this.assertTrue(state.requestScheduledMove([unit.id], [first]));
this.assertTrue(state.requestScheduledMove([unit.id], [second], true));
this.assertSize(unit.waypoints, 1, "the appended stop joins the queue");
state.advanceMovement(1000);
this.assertEqual(unit.coords, second);
}
test_each_leg_is_routed_from_the_previous_destination() {
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
const [first, second] = neutralLine(state, 2);
state.requestScheduledMove([unit.id], [first, second]);
// Place the unit on the first stop with no partial step; the next tick must
// plan a fresh route from there rather than reuse the original one.
unit.coords = first;
unit.path = [first];
unit.pathIndex = 0;
unit.progressHours = 0;
state.advanceMovement(1000);
this.assertEqual(unit.coords, second, "a new path is computed from the stop");
}
test_replacing_a_schedule_drops_the_old_queue() {
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
const [first, second] = neutralLine(state, 2);
state.requestScheduledMove([unit.id], [first, second]);
this.assertTrue(state.requestScheduledMove([unit.id], [first]));
this.assertEmpty(unit.waypoints, "a plain scheduled move replaces the queue");
}
}
// `steps` empty land tiles in a line, returned without the start tile.
function neutralLine(state, steps) {
for (const start of state.landCells) {
if (state.unitAt(start) || state.cityAt(start)) continue;
const path = [start];
let current = start;
for (let i = 0; i < steps; i++) {
const next = state.topology.neighbours(current.x, current.y).find((n) =>
state._isLand(n) &&
!state.unitAt(n) &&
!state.cityAt(n) &&
!path.some((p) => p.x === n.x && p.y === n.y)
);
if (!next) break;
path.push(next);
current = next;
}
if (path.length === steps + 1) return path.slice(1);
}
throw new Error(`no empty line of ${steps} tiles`);
}
function emptyLandPair(state) {
for (const cell of state.landCells) {
if (state.unitAt(cell) || state.cityAt(cell)) continue;
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
if (!state._isLand(neighbour)) continue;
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } };
}
}
throw new Error("no empty land pair");
}
function emptyLandNeighbour(state, coords) {
for (const neighbour of state.topology.neighbours(coords.x, coords.y)) {
if (!state._isLand(neighbour)) continue;
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
return { x: neighbour.x, y: neighbour.y };
}
return null;
}
+224
View File
@@ -0,0 +1,224 @@
import { TestCase } from "./framework/test_case.js";
import { smallState, cityOf, seaTile } from "./framework/helpers.js";
import {
STATUS_ENCIRCLED,
BUILDINGS,
CIVILISATIONS,
PROTO_UNITS,
GOVERNMENTS,
TECHNOLOGIES,
TERRAIN_TILES,
MAP_CONFIG,
ECONOMY,
BATTLE,
effectValue,
pillageSteps,
} from "../shared/data.js";
import { groupDigits } from "../shared/text_format.js";
// Regression and edge coverage for the order, economy, combat and catalogue
// code. These lock in the behavioural guarantees the modular refactor must
// preserve.
function emptyLand(state, skip = 0) {
let seen = 0;
for (const cell of state.landCells) {
if (state.unitAt(cell) || state.cityAt(cell)) continue;
if (seen++ < skip) continue;
return cell;
}
throw new Error("no empty land tile");
}
// A land tile that is empty and has at least one empty land neighbour.
function boundaryLand(state) {
for (const cell of state.landCells) {
if (state.unitAt(cell) || state.cityAt(cell)) continue;
const neighbour = state.topology.neighbours(cell.x, cell.y).find(
(n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n)
);
if (neighbour) return { a: cell, b: neighbour };
}
throw new Error("no empty land pair");
}
export class OrderStateRegressionTest extends TestCase {
test_rejected_move_keeps_the_existing_route() {
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
this.assertTrue(state.requestGroupMove([unit.id], emptyLand(state)), "the first move is accepted");
const before = unit.path.map((p) => ({ x: p.x, y: p.y }));
this.assertFalse(state.requestGroupMove([unit.id], seaTile(state)), "the sea is not a destination");
this.assertEqual(unit.path, before, "a rejected order leaves the route alone");
this.assertTrue(state._hasRoute(unit), "the unit keeps marching");
}
test_rejected_scheduled_move_keeps_the_existing_queue() {
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
const first = emptyLand(state, 0);
const second = emptyLand(state, 1);
this.assertTrue(state.requestScheduledMove([unit.id], [first, second]));
const queued = unit.waypoints.map((p) => ({ x: p.x, y: p.y }));
this.assertFalse(state.requestScheduledMove([unit.id], [seaTile(state)]));
this.assertEqual(unit.waypoints, queued, "the scheduled stops survive a rejected order");
}
test_accepted_move_replaces_a_previous_queue() {
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
state.requestScheduledMove([unit.id], [emptyLand(state, 0), emptyLand(state, 1)]);
this.assertNotEmpty(unit.waypoints, "a stop is queued");
this.assertTrue(state.requestGroupMove([unit.id], emptyLand(state, 5)));
this.assertEmpty(unit.waypoints, "a fresh move drops the old schedule");
}
test_move_to_the_units_own_tile_is_rejected() {
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
this.assertFalse(
state.requestGroupMove([unit.id], { x: unit.coords.x, y: unit.coords.y }),
"staying put is not an order"
);
}
}
export class EconomyOrderEdgesTest extends TestCase {
test_a_free_unit_is_trained_immediately() {
const state = smallState();
const city = cityOf(state, 0);
const worker = state.protoUnits.findIndex((p) => p.id === "worker");
const before = state.units.length;
this.assertTrue(state.requestTrain(city.id, worker), "a free unit is accepted");
this.assertGreater(state.units.length, before, "the unit appears at once");
this.assertNull(state.getTraining(city.id), "nothing is left in the queue");
}
test_building_stops_at_the_maximum_level() {
const state = smallState();
const city = cityOf(state, 0);
const proto = state.protoBuildings[0];
state.budgets.set(0, 1e15);
for (let i = 0; i < proto.maxLevel; i++) state.requestBuild(city.id, 0);
this.assertEqual(state.getCityBuildingLevel(city, 0), proto.maxLevel);
this.assertFalse(state.requestBuild(city.id, 0), "cannot build past the cap");
}
test_demolishing_removes_one_level_and_refunds() {
const state = smallState();
const city = cityOf(state, 0);
state.budgets.set(0, 1e15);
state.requestBuild(city.id, 0);
const budget = state.getBudget(0);
this.assertTrue(state.requestDemolish(city.id, 0));
this.assertEqual(state.getCityBuildingLevel(city, 0), 0);
this.assertGreater(state.getBudget(0), budget, "the refund raises the budget");
}
test_cancelling_an_out_of_range_queue_entry_fails() {
const state = smallState();
const city = cityOf(state, 0);
const infantry = state.protoUnits.findIndex((p) => p.id === "modern_infantry");
state.requestTrain(city.id, infantry);
this.assertFalse(state.cancelTrain(city.id, -1), "negative index");
this.assertFalse(state.cancelTrain(city.id, 99), "past the end");
this.assertTrue(state.cancelTrain(city.id, 0), "the real entry cancels");
this.assertNull(state.getTraining(city.id), "the emptied queue is dropped");
}
test_tile_gdp_is_population_times_per_capita() {
const state = smallState();
const owned = state._territoryByCiv.get(0)[0];
const expected = state.getTilePopulation(owned) * state.getTileGdpPerCapita(owned);
this.assertApprox(state.getTileGdp(owned), expected, 1e-6);
}
test_battle_triples_unit_upkeep() {
const state = smallState();
const pair = boundaryLand(state);
const before = state.getPlayerUpkeep(0);
state._spawnUnit(pair.a, 0, state.protoUnits[0]);
state._spawnUnit(pair.b, 1, state.protoUnits[0]);
// The two hostile units are only "in battle" once they share a tile.
state.units[state.units.length - 1].coords = pair.a;
const after = state.getPlayerUpkeep(0);
this.assertGreater(after, before, "a unit in battle costs more to keep");
}
test_buildings_in_the_catalogue_have_a_cost_curve() {
for (const proto of BUILDINGS) {
this.assertTrue(proto.costGdpFraction > 0, `${proto.id} cost fraction`);
this.assertTrue(proto.costAlpha > 1, `${proto.id} cost growth`);
this.assertTrue(proto.maxLevel >= 1, `${proto.id} max level`);
}
}
}
export class AttritionTest extends TestCase {
test_daily_attrition_destroys_a_unit_at_zero_hit_points() {
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
unit.statuses = [STATUS_ENCIRCLED];
unit.hp = 1;
state._applyDailyStatusEffects();
this.assertEqual(unit.hp, 0, "the hit points are floored at zero");
this.assertFalse(state.units.includes(unit), "the unit is removed from the world");
this.assertNull(state.findUnit(unit.id), "and from the id index");
}
test_a_healthy_encircled_unit_survives_one_day() {
const state = smallState();
const unit = state.units.find((u) => u.civ === 0);
unit.statuses = [STATUS_ENCIRCLED];
unit.hp = unit.maxHp;
state._applyDailyStatusEffects();
this.assertGreater(unit.hp, 0, "attrition is a fraction, not a wipe");
this.assertTrue(state.units.includes(unit));
}
}
export class CasualtyRecordingTest extends TestCase {
test_a_lost_unit_counts_against_an_adjacent_enemy() {
const state = smallState();
state._declareWar(0, 1);
const pair = boundaryLand(state);
const fallen = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
state._spawnUnit(pair.b, 1, state.protoUnits[0]);
state._recordMilitaryCasualties([fallen]);
const conflict = state.conflicts.get("0:1");
this.assertNotNull(conflict, "the war is on record");
this.assertEqual(conflict.military.get(0) || 0, 1, "the neighbour's kill is counted");
}
}
export class CatalogueSplitTest extends TestCase {
test_the_barrel_reexports_every_slice() {
this.assertNotEmpty(CIVILISATIONS);
this.assertNotEmpty(PROTO_UNITS);
this.assertNotEmpty(BUILDINGS);
this.assertNotEmpty(GOVERNMENTS);
this.assertNotEmpty(TECHNOLOGIES);
this.assertNotEmpty(TERRAIN_TILES);
this.assertEqual(typeof MAP_CONFIG.mapSize.x, "number");
this.assertEqual(typeof ECONOMY.budgetGdpRate, "number");
this.assertEqual(typeof BATTLE.recoveryPerDay, "number");
}
test_pillaging_with_no_units_changes_nothing() {
const result = pillageSteps(5000, 0);
this.assertEmpty(result.steps);
this.assertEqual(result.gdpMultiplier, 1);
}
test_effect_value_is_zero_for_a_non_positive_level() {
this.assertEqual(effectValue({ baseValue: 5, exponent: 1 }, -3), 0);
}
}
export class TextFormatRegressionTest extends TestCase {
test_negative_fraction_does_not_render_negative_zero() {
this.assertEqual(groupDigits(-0.4), "0");
this.assertEqual(groupDigits(-0.999), "0");
}
}
+269
View File
@@ -0,0 +1,269 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
import { smallState, defaultState, mapLayers, SEED } from "./framework/helpers.js";
import { parseKey } from "../shared/hex.js";
import { CIVILISATIONS, PROTO_UNITS, statusById, STATUS_ENCIRCLED, STATUS_BESIEGED } from "../shared/data.js";
import { MapView } from "../client/js/map_view.js";
const DAY = 24;
export class SiegeTest extends TestCase {
test_the_status_catalogue_defines_attrition() {
const encircled = statusById(STATUS_ENCIRCLED);
const besieged = statusById(STATUS_BESIEGED);
this.assertNotNull(encircled);
this.assertNotNull(besieged);
this.assertApprox(encircled.hpLossPerDay, 0.10);
this.assertApprox(encircled.cityHpLossPerDay, 0.02);
this.assertEqual(encircled.icon, "encirclement.svg");
this.assertEqual(besieged.icon, "encirclement.svg");
this.assertNull(statusById("nope"));
}
test_a_supplied_unit_carries_no_status() {
const state = smallState();
state._refreshStatuses();
for (const unit of state.units) {
this.assertEmpty(unit.statuses, "the starting unit is supplied");
}
}
// The supply check only runs when a hostile is adjacent: the daily refresh
// never calls into the search otherwise.
test_the_check_only_triggers_with_an_adjacent_hostile() {
const state = smallState();
const unit = state.units[0];
const neighbours = state.topology.neighbours(unit.coords.x, unit.coords.y).filter(
(n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n)
);
this.assertGreaterOrEqual(neighbours.length, 2, "the capital has open land around it");
let calls = 0;
const original = state._unitStatuses.bind(state);
state._unitStatuses = (...args) => {
calls += 1;
return original(...args);
};
state._refreshStatuses();
this.assertEqual(calls, 0, "no hostile, no supply search");
state._spawnUnit(neighbours[0], 0, state.protoUnits[0]);
state._refreshStatuses();
this.assertEqual(calls, 0, "a friendly neighbour does not trigger it");
state._spawnUnit(neighbours[1], 1, state.protoUnits[0]);
state._refreshStatuses();
this.assertGreater(calls, 0, "a hostile neighbour triggers the check");
this.assertEmpty(unit.statuses, "the unit is still supplied");
}
test_a_cut_off_unit_with_a_hostile_neighbour_is_encircled() {
const state = smallState();
const tile = findSurroundableTile(state);
this.assertNotNull(tile, "found an interior tile to surround");
const unit = state._spawnUnit(tile, 0, state.protoUnits[0]);
surroundTile(state, tile, 1);
state._refreshStatuses();
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED]);
}
test_a_friendly_neighbour_does_not_trigger_encirclement() {
const state = smallState();
const unit = state.units[0];
const neighbour = state.topology.neighbours(unit.coords.x, unit.coords.y).find(
(n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n)
);
this.assertNotNull(neighbour, "found an open land neighbour");
state._spawnUnit(neighbour, 0, state.protoUnits[0]);
this.assertFalse(state._hasAdjacentHostile(unit.coords, unit.civ));
state._refreshStatuses();
this.assertEmpty(unit.statuses);
}
test_an_encircled_unit_loses_ten_percent_a_day() {
const state = smallState();
const tile = findSurroundableTile(state);
const unit = state._spawnUnit(tile, 0, state.protoUnits[0]);
surroundTile(state, tile, 1);
state._refreshStatuses();
const before = unit.hp;
state._applyDailyStatusEffects();
this.assertApprox(unit.hp, before - unit.maxHp * 0.10);
}
test_a_garrisoned_unit_loses_only_two_percent_a_day() {
const state = smallState();
const city = findBesiegableCity(state, 0);
this.assertNotNull(city, "found a city to besiege");
const unit = state._spawnUnit(city.coords, 0, state.protoUnits[0]);
surroundTile(state, city.coords, 1);
state._refreshStatuses();
this.assertEqual(city.statuses, [STATUS_BESIEGED]);
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED]);
const before = unit.hp;
state._applyDailyStatusEffects();
this.assertApprox(unit.hp, before - unit.maxHp * 0.02);
}
test_a_lone_city_can_never_be_besieged() {
const state = smallState(["france"], SEED, { citiesPerCiv: 1 });
const city = state.cities[0];
this.assertNotNull(city);
surroundTile(state, city.coords, 1);
state._refreshStatuses();
this.assertEmpty(city.statuses, "a city with no sibling is not besieged");
}
test_status_is_refreshed_once_a_day_not_every_snapshot() {
const state = smallState();
const tile = findSurroundableTile(state);
const unit = state._spawnUnit(tile, 0, state.protoUnits[0]);
const blockers = surroundTile(state, tile, 1);
state._refreshStatuses();
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED]);
// Breaking the ring does not change the cached status until the next day.
state._destroyUnit(blockers[0]);
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "still the cached status");
this.assertEqual(
state.snapshot(0).units.find((u) => u.id === unit.id).statuses,
[STATUS_ENCIRCLED],
"the snapshot reads the cached status"
);
for (let hour = 0; hour < DAY; hour++) state.tickHour();
this.assertEmpty(unit.statuses, "the daily refresh freed the unit");
}
test_an_allied_naval_unit_lifts_a_siege_across_water() {
const state = smallState();
const spot = findCoastalPair(state);
this.assertNotNull(spot, "found a sea tile between two land tiles");
const unit = state._spawnUnit(spot.l1, 0, state.protoUnits[0]);
surroundTile(state, spot.l1, 1);
state._refreshStatuses();
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "cut off by land");
state._spawnCity({ civIndex: 0, coords: { x: spot.l2.x, y: spot.l2.y }, isCapital: false });
state._rebuildCityIndex();
state._refreshStatuses();
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "no lane without a ship");
state._spawnUnit({ x: spot.s.x, y: spot.s.y }, 0, navalProto());
state._refreshStatuses();
this.assertEmpty(unit.statuses, "the friendly navy reopens the lane");
}
test_an_enemy_naval_unit_does_not_open_a_lane() {
const state = smallState();
const spot = findCoastalPair(state);
const unit = state._spawnUnit(spot.l1, 0, state.protoUnits[0]);
surroundTile(state, spot.l1, 1);
state._spawnCity({ civIndex: 0, coords: { x: spot.l2.x, y: spot.l2.y }, isCapital: false });
state._rebuildCityIndex();
state._spawnUnit({ x: spot.s.x, y: spot.s.y }, 1, navalProto());
state._refreshStatuses();
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "an enemy fleet is no supply line");
}
async test_the_client_draws_status_icons_over_units_and_cities() {
const state = defaultState();
const city = findBesiegableCity(state, 0);
this.assertNotNull(city, "found a city to besiege");
const unit = state._spawnUnit(city.coords, 0, state.protoUnits[0]);
surroundTile(state, city.coords, 1);
state._refreshStatuses();
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED]);
const env = await setupDom();
try {
const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$));
map.$viewport.width = () => 800;
map.$viewport.height = () => 600;
map.setCatalogue(CIVILISATIONS, PROTO_UNITS);
map.applySnapshot(state.snapshot(0));
const unitIcon = env.$("#layer-entities .unit .unit-statuses .status-icon");
this.assertGreater(unitIcon.length, 0, "the encircled unit shows an icon");
this.assertTrue(unitIcon.first().attr("src").endsWith("encirclement.svg"));
const cityIcon = env.$("#layer-entities .city .city-statuses .status-icon");
this.assertGreater(cityIcon.length, 0, "the besieged city shows an icon");
} finally {
teardownDom(env);
}
}
}
function navalProto() {
return {
id: "test_naval",
name: "Test naval",
maxHp: 100,
cost: 0,
upkeep: 0,
moveable: true,
speed: 0.5,
military: true,
attack: 10,
defense: 10,
range: 1,
traversableTerrains: ["Sea"],
icon: "person.png",
};
}
function findSurroundableTile(state) {
for (const cell of state.landCells) {
if (state.unitAt(cell) || state.cityAt(cell)) continue;
const neighbours = state.topology.neighbours(cell.x, cell.y);
const clear = neighbours.every(
(n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n)
);
if (clear) return { x: cell.x, y: cell.y };
}
return null;
}
function surroundTile(state, coords, civ) {
const placed = [];
for (const neighbour of state.topology.neighbours(coords.x, coords.y)) {
if (!state._isLand(neighbour)) continue;
placed.push(state._spawnUnit(neighbour, civ, state.protoUnits[0]));
}
return placed;
}
function findBesiegableCity(state, civ) {
for (const city of state.cities) {
if (city.civ !== civ) continue;
if (state.unitAt(city.coords)) continue;
const land = state.topology.neighbours(city.coords.x, city.coords.y).filter((n) => state._isLand(n));
if (land.length === 0) continue;
if (land.some((n) => state.unitAt(n) || state.cityAt(n))) continue;
return city;
}
return null;
}
// A sea tile with two non-adjacent land neighbours: l1 for the cut-off unit,
// l2 for the coastal destination city.
function findCoastalPair(state) {
for (const k in state.tiles) {
if (state.tiles[k].terrainClass !== "Sea") continue;
const s = parseKey(k);
const land = state.topology.neighbours(s.x, s.y).filter(
(n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n)
);
for (let i = 0; i < land.length; i++) {
for (let j = i + 1; j < land.length; j++) {
if (state.topology.tileDistance(land[i], land[j]) <= 1) continue;
const neighbours = state.topology.neighbours(land[i].x, land[i].y).filter((n) => state._isLand(n));
if (neighbours.some((n) => state.unitAt(n) || state.cityAt(n))) continue;
return { s: { x: s.x, y: s.y }, l1: { x: land[i].x, y: land[i].y }, l2: { x: land[j].x, y: land[j].y } };
}
}
}
return null;
}
+141
View File
@@ -0,0 +1,141 @@
import { TestCase } from "./framework/test_case.js";
import { smallState } from "./framework/helpers.js";
function tileOwnedBy(state, owner) {
for (const [k, tileOwner] of state.territory) {
if (tileOwner !== owner) continue;
const i = k.indexOf(",");
const coords = { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) };
if (state.unitAt(coords) || state.cityAt(coords)) continue;
return coords;
}
return null;
}
function emptyLandPair(state) {
let fallback = null;
for (const cell of state.landCells) {
if (state.unitAt(cell) || state.cityAt(cell)) continue;
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
if (!state._isLand(neighbour)) continue;
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
const pair = { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } };
if (!fallback) fallback = pair;
const ownA = state.civAt(cell) < 0 || state.civAt(cell) === 0;
const ownB = state.civAt(neighbour) < 0 || state.civAt(neighbour) === 0;
if (ownA && ownB) return pair;
}
}
return fallback;
}
export class StackingTest extends TestCase {
test_friendly_units_can_share_a_tile() {
const state = smallState();
const pair = emptyLandPair(state);
const first = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
state._spawnUnit(pair.a, 0, state.protoUnits[0]);
this.assertEqual(state.unitsAt(pair.a).length, 2, "both units stack");
this.assertTrue(state._canUnitEnter(first, pair.a), "a unit may join its own stack");
}
test_a_stack_moves_at_its_slowest_speed() {
const state = smallState();
const pair = emptyLandPair(state);
const infantry = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
this.assertTrue(state.requestGroupMove([infantry.id, artillery.id], pair.b));
this.assertApprox(infantry.moveSpeed, 0.25, 1e-9, "pinned to the slowest member");
this.assertApprox(artillery.moveSpeed, 0.25, 1e-9);
state.advanceMovement(1000);
this.assertEqual(infantry.coords, pair.b);
this.assertEqual(artillery.coords, pair.b);
this.assertEqual(state.unitsAt(pair.b).length, 2, "the stack stays together");
}
test_a_lone_unit_keeps_its_own_speed() {
const state = smallState();
const pair = emptyLandPair(state);
const infantry = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
state.requestMove(infantry.id, pair.b);
this.assertNull(infantry.moveSpeed, "a solo move is not slowed by neighbours");
}
test_garrisoned_units_heal_one_hp_per_hour() {
const state = smallState();
const city = state.cities.find((c) => c.civ === 0);
const unit = state._spawnUnit(city.coords, 0, state.protoUnits[0]);
unit.hp = 100;
state.tickHour();
this.assertEqual(unit.hp, 101, "heals inside a friendly city");
for (let i = 0; i < 1000; i++) state.tickHour();
this.assertEqual(unit.hp, unit.maxHp, "never exceeds maximum");
}
test_units_in_the_field_do_not_heal() {
const state = smallState();
const pair = emptyLandPair(state);
const unit = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
unit.hp = 100;
state.tickHour();
this.assertEqual(unit.hp, 100);
}
}
export class PillageTest extends TestCase {
test_pillaging_removes_population_and_heals() {
const state = smallState();
const coords = tileOwnedBy(state, 1);
this.assertNotNull(coords, "found an enemy tile");
const k = `${coords.x},${coords.y}`;
state.tilePopulation.set(k, 100000);
const unit = state._spawnUnit(coords, 0, state.protoUnits[0]);
unit.hp = 100;
const gdpBefore = state.getTileGdpPerCapita(coords);
this.assertTrue(state.requestPillage([unit.id]));
this.assertApprox(state.tilePopulation.get(k), 90000, 1);
this.assertApprox(state.tileGdpPenalty.get(k), 0.7, 1e-9);
this.assertApprox(state.getTileGdpPerCapita(coords), gdpBefore * 0.7, 1e-6);
this.assertApprox(unit.hp, 110, 1e-9, "heals 1 HP per 1000 people removed");
}
test_a_stack_pillages_once_per_unit_weakest_first() {
const state = smallState();
const coords = tileOwnedBy(state, 1);
const k = `${coords.x},${coords.y}`;
state.tilePopulation.set(k, 100000);
const strong = state._spawnUnit(coords, 0, state.protoUnits[0]);
strong.hp = strong.maxHp - 100;
const weak = state._spawnUnit(coords, 0, state.protoUnits[0]);
weak.hp = strong.maxHp / 2;
this.assertTrue(state.requestPillage([strong.id, weak.id]));
this.assertApprox(state.tilePopulation.get(k), 81000, 1, "90% of 90% remains");
this.assertApprox(state.tileGdpPenalty.get(k), 0.49, 1e-9, "70% of 70% remains");
this.assertApprox(weak.hp, strong.maxHp / 2 + 10, 1e-9, "the weakest pillages first");
this.assertApprox(strong.hp, strong.maxHp - 100 + 9, 1e-9, "the second heals for the 9000 it removed");
}
test_pillaging_needs_enemy_territory() {
const state = smallState();
const city = state.cities.find((c) => c.civ === 0);
const unit = state._spawnUnit(city.coords, 0, state.protoUnits[0]);
this.assertFalse(state.canPillage([unit.id]), "cannot pillage your own land");
const pair = emptyLandPair(state);
const neutral = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
if (state.civAt(pair.a) < 0) {
this.assertFalse(state.canPillage([neutral.id]), "cannot pillage unclaimed land");
}
}
test_pillaging_rejects_an_enemy_unit() {
const state = smallState();
const coords = tileOwnedBy(state, 1);
const enemy = state._spawnUnit(coords, 1, state.protoUnits[0]);
this.assertFalse(state.requestPillage([enemy.id]));
}
}