Split shared data, game state and UI into modules
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -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 |
@@ -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 |
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
];
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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];
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||