diff --git a/client/js/game_screen.js b/client/js/game_screen.js
index ffac7ef..5cc0f4d 100644
--- a/client/js/game_screen.js
+++ b/client/js/game_screen.js
@@ -4,7 +4,7 @@
// same cost model as the server.
import { MapView } from "./map_view.js";
-import { TrainModal, BuildingsModal, NationModal } from "./modals.js";
+import { TrainModal, BuildingsModal, NationModal, ConfirmModal, NewsModal } from "./modals.js";
import { compact, groupDigits, improvements as fmtImprovements } from "../../shared/text_format.js";
import {
CIVILISATIONS,
@@ -33,6 +33,8 @@ export class GameScreen {
this._population = new Map();
this._territoryOwner = new Map();
this._researched = new Set();
+ this._wars = new Set();
+ this._news = [];
this._entered = false;
this._lastFrame = 0;
this._fpsFrames = 0;
@@ -44,7 +46,6 @@ export class GameScreen {
{
overview: $("#layer-overview"),
terrain: $("#layer-terrain"),
- territory: $("#layer-territory"),
borders: $("#layer-borders"),
fog: $("#layer-fog"),
paths: $("#layer-paths"),
@@ -55,6 +56,8 @@ export class GameScreen {
this.trainModal = new TrainModal();
this.buildingsModal = new BuildingsModal();
this.nationModal = new NationModal();
+ this.confirmModal = new ConfirmModal();
+ this.newsModal = new NewsModal();
this.$statTooltip = $("#stat-tooltip");
this._hoveredStat = null;
@@ -111,6 +114,14 @@ export class GameScreen {
this._researched = new Set(
(state.researched[this.localCiv] || []).map((index) => Number(index))
);
+ this._wars = new Set();
+ for (const entry of state.diplomacy || []) {
+ if (entry[2] !== "war") continue;
+ const a = entry[0];
+ const b = entry[1];
+ this._wars.add(a < b ? `${a}:${b}` : `${b}:${a}`);
+ }
+ this._news = state.news || [];
this.map.setCatalogue(this.civilisations, this.protoUnits);
this.map.applySnapshot(state);
@@ -120,6 +131,7 @@ export class GameScreen {
this._updateTrainModal();
this._updateBuildingsModal();
this._updateNationModal();
+ this._updateNewsModal();
}
// ---------------------------------------------------------------- HUD ----
@@ -128,6 +140,7 @@ export class GameScreen {
$("#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 });
@@ -139,6 +152,8 @@ export class GameScreen {
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)))
@@ -200,11 +215,57 @@ export class GameScreen {
this.map.onMoveOrdered = (unitId, coords) => {
const unit = (this.snapshot.units || []).find((u) => u.id === unitId);
if (!unit || unit.civ !== this.localCiv) return;
- this.sendOrder({ type: "move", unit: unitId, coords: [coords.x, coords.y] });
- this.map.predictMove(unit, coords);
+ const targets = this._unalliedTerritoryOnRoute(unit, coords);
+ if (targets.length > 0) {
+ const names = targets.map((index) => this._civName(index)).join(", ");
+ this.confirmModal.show(
+ "Declare war?",
+ `Marching into ${names} territory will declare war on ${names}. Continue?`,
+ () => this._sendMove(unit, coords)
+ );
+ return;
+ }
+ this._sendMove(unit, coords);
};
}
+ _sendMove(unit, coords) {
+ this.sendOrder({ type: "move", unit: unit.id, coords: [coords.x, coords.y] });
+ this.map.predictMove(unit, coords);
+ }
+
+ // The civilisations, other than our own, whose territory the predicted route
+ // would enter while we are at peace with them. Only military units can start
+ // a war this way; civilians wander in without one.
+ _unalliedTerritoryOnRoute(unit, coords) {
+ if (!this._isMilitaryUnit(unit)) return [];
+ const route = this.map.planMove(unit, coords);
+ if (!route || route.length < 2) return [];
+ const targets = [];
+ 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;
+ }
+
+ _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}`;
+ }
+
_updateTopPanel(state) {
const civ = this.civilisations[this.localCiv];
if (civ) {
@@ -408,7 +469,8 @@ export class GameScreen {
stats.gdp || 0,
stats.budget || 0,
stats.research || 0,
- this._finances(stats)
+ this._finances(stats),
+ this._diplomacyInfo()
);
}
@@ -421,10 +483,28 @@ export class GameScreen {
stats.gdp || 0,
stats.budget || 0,
stats.research || 0,
- this._finances(stats)
+ this._finances(stats),
+ this._diplomacyInfo()
);
}
+ _diplomacyInfo() {
+ return {
+ civs: this.civilisations,
+ localCiv: this.localCiv,
+ relations: (this.snapshot && this.snapshot.diplomacy) || [],
+ };
+ }
+
+ _openNewsModal() {
+ this.newsModal.show(this._news, this.civilisations);
+ }
+
+ _updateNewsModal() {
+ if (!this.snapshot || !this.newsModal.isShowing()) return;
+ this.newsModal.refresh(this._news, this.civilisations);
+ }
+
_finances(stats) {
return stats.breakdown || null;
}
@@ -451,6 +531,8 @@ export class GameScreen {
this.trainModal.close();
this.buildingsModal.close();
this.nationModal.close();
+ this.confirmModal.close();
+ this.newsModal.close();
break;
default:
return;
diff --git a/client/js/map_view.js b/client/js/map_view.js
index 00a3f62..5908b31 100644
--- a/client/js/map_view.js
+++ b/client/js/map_view.js
@@ -39,7 +39,6 @@ export class MapView {
this.$viewport = $viewport;
this.$world = $world;
this.$terrain = layers.terrain;
- this.$territory = layers.territory;
this.$borders = layers.borders;
this.$fog = layers.fog;
this.$paths = layers.paths;
@@ -99,7 +98,6 @@ export class MapView {
this._worldMinX = 0;
this._worldMinY = 0;
this._terrainWrap = [];
- this._territoryWrap = [];
this._borderWrap = [];
this._fogWrap = [];
this._wrapCameraX = null;
@@ -151,11 +149,9 @@ export class MapView {
this._territorySignature = "";
this._fogSignature = "";
- this._territoryWrap = [];
this._borderWrap = [];
this._fogWrap = [];
this._wrapCameraX = null;
- this.$territory.empty();
this.$borders.empty();
this.$fog.empty();
this.$entities.empty();
@@ -225,31 +221,9 @@ export class MapView {
this._territorySignature = signature;
this.territory = new Map();
- this._territoryWrap = [];
- const territoryFragment = document.createDocumentFragment();
for (const entry of entries) {
- const x = entry[0];
- const y = entry[1];
- const owner = entry[2];
- this.territory.set(key(x, y), owner);
- if (owner < 0 || !this.civilisations[owner]) continue;
- const local = mapToLocal(x, y);
- const div = document.createElement("div");
- div.className = "hex territory detail-layer";
- div.style.left = `${local.x - HEX_W / 2}px`;
- div.style.top = `${local.y - HEX_H / 2}px`;
- div.style.background = this.civilisations[owner].primaryColour;
- div.style.opacity = "0.28";
- territoryFragment.appendChild(div);
- this._territoryWrap.push({
- el: div,
- centerX: local.x,
- shift: null,
- baseTransform: "",
- });
+ this.territory.set(key(entry[0], entry[1]), entry[2]);
}
- this.$territory.empty()[0].appendChild(territoryFragment);
- this.$territory.addClass("detail-layer");
this._renderBorders();
this._drawOverview();
@@ -257,7 +231,7 @@ export class MapView {
}
// Draws a thick tinted segment along every edge shared with a different owner
- // (or the open sea), matching the territory overlay. Each segment is
+ // (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.
_renderBorders() {
@@ -344,7 +318,8 @@ export class MapView {
view.style.top = `${local.y}px`;
const owned = data.civ === this.localCiv;
const label = view._label;
- label.textContent = data.isCapital ? `${data.name} (capital)` : data.name;
+ 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");
@@ -618,7 +593,7 @@ export class MapView {
return { x: point.x + this._wrapShift(point.x), y: point.y };
}
- // Shifts the static layers (terrain, territory, borders, fog) so they cover
+ // Shifts the static layers (terrain, borders, fog) so they cover
// the visible period. Only elements whose period index changed are touched.
_applyWrap(force = false) {
if (force) this._wrapCameraX = null;
@@ -627,7 +602,7 @@ export class MapView {
if (this.lod) return;
if (this._wrapCameraX === this.camera.x) return;
this._wrapCameraX = this.camera.x;
- for (const list of [this._terrainWrap, this._territoryWrap, this._borderWrap, this._fogWrap]) {
+ for (const list of [this._terrainWrap, this._borderWrap, this._fogWrap]) {
for (const item of list) {
const shift = this._wrapShift(item.centerX);
if (shift === item.shift) continue;
@@ -937,8 +912,30 @@ export class MapView {
// -------------------------------------------------------- move preview --
// Mirrors GameState.find_path closely enough that a click feels instant; the
- // next snapshot confirms or corrects the route.
+ // 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) {
+ const plan = this._planMove(unitData, goal);
+ return plan ? plan.route : null;
+ }
+
predictMove(unitData, goal) {
+ const plan = this._planMove(unitData, goal);
+ if (!plan) return null;
+ this._unitMotion.set(unitData.id, {
+ path: plan.route,
+ segmentHours: plan.segmentHours,
+ pathHours: 0,
+ coords: plan.start,
+ predicting: true,
+ predictAge: 0,
+ });
+ this.animate(0);
+ return plan.route;
+ }
+
+ _planMove(unitData, goal) {
const proto = this.protoUnits[unitData.proto];
if (!proto || !proto.moveable || !this.topology) return null;
const civ = unitData.civ;
@@ -1010,16 +1007,7 @@ export class MapView {
for (let i = 1; i < route.length; i++) {
segmentHours.push(this._stepHours(proto, civ, route[i]));
}
- this._unitMotion.set(unitData.id, {
- path: route,
- segmentHours,
- pathHours: 0,
- coords: start,
- predicting: true,
- predictAge: 0,
- });
- this.animate(0);
- return route;
+ return { route, segmentHours, start };
}
_isMilitaryData(data) {
diff --git a/client/js/modals.js b/client/js/modals.js
index 6af3dc6..d36600f 100644
--- a/client/js/modals.js
+++ b/client/js/modals.js
@@ -197,12 +197,15 @@ export class NationModal {
this.$technologyList = $("#technology-list");
this.$budgetSub = $("#budget-sub");
this.$budgetList = $("#budget-list");
+ this.$diplomacySub = $("#diplomacy-sub");
+ this.$diplomacyList = $("#diplomacy-list");
this.governments = [];
this.technologies = [];
this.currentGovernment = 0;
this.researched = new Set();
this.governmentRows = [];
this.technologyRows = [];
+ this.diplomacy = null;
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."),
@@ -211,6 +214,7 @@ export class NationModal {
};
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");
@@ -225,12 +229,13 @@ export class NationModal {
return !this.$modal.hasClass("hidden");
}
- show(governments, currentGovernment, technologies, researched, gdp, budget, research, finances) {
+ show(governments, currentGovernment, technologies, researched, gdp, budget, research, finances, diplomacy = null) {
this.governments = governments;
this.technologies = technologies;
+ this.diplomacy = diplomacy;
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);
+ this.refresh(currentGovernment, researched, gdp, budget, research, finances, diplomacy);
this.$modal.removeClass("hidden");
}
@@ -283,9 +288,10 @@ export class NationModal {
return { proto, index, kind, $name, $effects, $cost, $action };
}
- refresh(currentGovernment, researched, gdp, budget, research, finances = {}) {
+ refresh(currentGovernment, researched, gdp, budget, research, finances = {}, diplomacy = null) {
this.currentGovernment = currentGovernment;
this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number));
+ if (diplomacy !== null) this.diplomacy = diplomacy;
this.$governmentSub.text(
`Government — ${this.governments[currentGovernment] ? this.governments[currentGovernment].name : "None"} · Budget: €${groupDigits(Math.round(budget))}`
);
@@ -367,9 +373,142 @@ export class NationModal {
row.$action.text(`Research ${groupDigits(Math.round(proto.cost))}`).prop("disabled", research < proto.cost);
}
}
+
+ this._renderDiplomacy();
+ }
+
+ // Lists every other nation with its relation to the player and a button to
+ // declare war. `diplomacy` is { civs, localCiv, relations }, where relations
+ // is the snapshot's [a, b, status] matrix.
+ _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"}.`
+ );
+ }
+ for (let index = 0; index < civs.length; index++) {
+ if (index === localCiv) continue;
+ const $card = $("
");
+ const $info = $("
");
+ const $name = $("
").text(civs[index].name);
+ const atWar = wars.has(index);
+ const $status = $("
").text(atWar ? "At war" : "At peace");
+ $info.append($name, $status);
+ const $actions = $("
");
+ const $action = $("
");
+ if (atWar) {
+ $action.text("At war").prop("disabled", true);
+ } else {
+ $action.text("Declare war");
+ $action.on("click", () => this.onDeclareWar(index));
+ }
+ $actions.append($action);
+ $card.append($info, $actions);
+ this.$diplomacyList.append($card);
+ }
}
close() {
this.$modal.addClass("hidden");
}
}
+
+// A generic yes/no prompt. `show` takes the callbacks 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;
+ }
+}
+
+// The news feed: a reverse-chronological list of newsworthy events. Civ indices
+// are resolved to names through the catalogue the game screen passes in.
+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($("
").text("No news yet."));
+ return;
+ }
+ for (const entry of entries) {
+ const $item = $("
");
+ $item.append($("
").text(describeNews(entry, civs)));
+ $item.append($("
").text(entry.date || ""));
+ this.$list.append($item);
+ }
+ }
+
+ close() {
+ this.$modal.addClass("hidden");
+ }
+}
+
+function describeNews(entry, civs) {
+ const name = (index) => (civs[index] ? civs[index].name : `Nation ${index + 1}`);
+ switch (entry.type) {
+ case "war":
+ return `${name(entry.actor)} declared war on ${name(entry.target)}`;
+ case "capture":
+ return `${name(entry.to)} captured ${entry.city} from ${name(entry.from)}`;
+ default:
+ return entry.type;
+ }
+}
diff --git a/server/game_server.js b/server/game_server.js
index bb94b49..53f8a7c 100644
--- a/server/game_server.js
+++ b/server/game_server.js
@@ -114,6 +114,8 @@ export class GameServer extends EventEmitter {
return this._handleGovernment(civ, order);
case "technology":
return this._handleTechnology(civ, order);
+ case "declare_war":
+ return this._handleDeclareWar(civ, order);
default:
return false;
}
@@ -151,6 +153,11 @@ export class GameServer extends EventEmitter {
return this.state.requestResearchTechnology(civ, Number(order.technology));
}
+ _handleDeclareWar(civ, order) {
+ const target = Number(order.target !== undefined ? order.target : order.civ);
+ return this.state.requestDeclareWar(civ, target);
+ }
+
onPeerJoined(peerId, playerName = "") {
if (!this._configured) return;
this._assignCiv(peerId, playerName);
diff --git a/shared/data.js b/shared/data.js
index daaab5d..e242865 100644
--- a/shared/data.js
+++ b/shared/data.js
@@ -7,6 +7,18 @@ export const EFFECT_RESEARCH = "research";
export const EFFECT_CULTURE = "culture";
export const EFFECT_BUDGET_INCOME = "budget_income";
+// 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";
+
function effect(stat, baseValue, exponent = 1.0) {
return { stat, baseValue, exponent };
}
diff --git a/shared/game_state.js b/shared/game_state.js
index b924032..74fad06 100644
--- a/shared/game_state.js
+++ b/shared/game_state.js
@@ -23,9 +23,18 @@ import {
EFFECT_CULTURE,
STATUS_ENCIRCLED,
STATUS_BESIEGED,
+ RELATION_PEACE,
+ RELATION_WAR,
+ NEWS_WAR,
+ NEWS_CAPTURE,
statusById,
} from "./data.js";
import { buildingBuildCost, buildingRefund, buildingUpkeep, prerequisitesMet, defenseMultiplier } from "./rules.js";
+import { dateString } from "./game_clock.js";
+
+// 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;
@@ -65,6 +74,10 @@ export class GameState {
this.researched = new Map();
this.explored = new Map();
this.visible = new Map();
+ // Diplomatic relations, stored as a set of canonical "a:b" keys of pairs
+ // at war. Every other pair is at peace by default.
+ this.wars = new Set();
+ this.news = [];
this.totalHours = 0;
@@ -75,6 +88,7 @@ export class GameState {
this._maxStepLength = 1.0;
this._nextUnitId = 1;
this._nextCityId = 1;
+ this._nextNewsId = 1;
this._cityNamesUsed = new Map();
this._unitById = new Map();
this._cityById = new Map();
@@ -127,6 +141,9 @@ export class GameState {
this.culture = new Map();
this.government = new Map();
this.researched = new Map();
+ this.wars = new Set();
+ this.news = [];
+ this._nextNewsId = 1;
this._nextUnitId = 1;
this._nextCityId = 1;
this._unitById = new Map();
@@ -234,6 +251,59 @@ export class GameState {
return true;
}
+ // ---------------------------------------------------------- diplomacy --
+
+ 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._addNews({ type: NEWS_WAR, actor, target });
+ return true;
+ }
+
+ 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;
+ }
+
requestMove(unitId, goal) {
const unit = this.findUnit(unitId);
if (!unit) return false;
@@ -251,6 +321,16 @@ export class GameState {
unit.attackTarget = null;
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;
@@ -1200,6 +1280,7 @@ export class GameState {
this._reindexTerritory();
this._rebuildCityIndex();
this._visibilityDirty = true;
+ this._addNews({ type: NEWS_CAPTURE, city: city.name, from: oldCiv, to: newCiv });
this._emitChanged();
return true;
}
@@ -1460,6 +1541,8 @@ export class GameState {
culture: this._serializeCivFloats(this.culture),
government: this._serializeCivInts(this.government),
researched: this._serializeResearched(),
+ diplomacy: this._serializeDiplomacy(),
+ news: this._serializeNews(),
training: Object.fromEntries(
Array.from(this.training.entries()).map(([cityId, entry]) => [
cityId,
@@ -1580,6 +1663,28 @@ export class GameState {
}
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;
+ }
+
+ _serializeNews() {
+ return this.news.map((entry) => ({ ...entry }));
+ }
+}
+
+// Canonical key for an unordered pair of civilisations.
+function warKey(a, b) {
+ return a < b ? `${a}:${b}` : `${b}:${a}`;
}
function effectAt(e, level) {
diff --git a/tests/framework/helpers.js b/tests/framework/helpers.js
index ce31792..4775280 100644
--- a/tests/framework/helpers.js
+++ b/tests/framework/helpers.js
@@ -55,7 +55,6 @@ export function mapLayers($) {
return {
overview: $("#layer-overview"),
terrain: $("#layer-terrain"),
- territory: $("#layer-territory"),
borders: $("#layer-borders"),
fog: $("#layer-fog"),
paths: $("#layer-paths"),
diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js
index 617462c..4f5d5dc 100644
--- a/tests/game_screen_test.js
+++ b/tests/game_screen_test.js
@@ -297,4 +297,115 @@ export class GameScreenTest extends TestCase {
teardownDom(env);
}
}
+
+ async test_move_into_peaceful_territory_prompts_for_war() {
+ const env = await setupDom();
+ try {
+ const network = stubNetwork();
+ const screen = new GameScreen(network, makeConfig());
+ screen.enter();
+ const state = defaultState();
+ screen.onState(state.snapshot(0));
+ const unit = state.units.find((u) => u.civ === 0);
+ const goal = { x: 900, y: 900 };
+ screen.map.territory.set("900,900", 1);
+ screen.map.planMove = () => [{ x: 1, y: 1 }, goal];
+ let predicted = null;
+ screen.map.predictMove = (u, coords) => {
+ predicted = { u, coords };
+ };
+ screen.map.onMoveOrdered(unit.id, goal);
+ this.assertFalse(env.$("#modal-confirm").hasClass("hidden"), "the war prompt opens");
+ this.assertEmpty(network.orders, "nothing is sent before confirming");
+ this.assertTrue(env.$("#confirm-message").text().includes("Britain"));
+
+ env.$("#confirm-ok").click();
+ this.assertEqual(network.orders[0], { type: "move", unit: unit.id, coords: [900, 900] });
+ this.assertNotNull(predicted, "the confirmed move is predicted locally");
+ screen.leave();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_cancelling_the_war_prompt_sends_nothing() {
+ const env = await setupDom();
+ try {
+ const network = stubNetwork();
+ const screen = new GameScreen(network, makeConfig());
+ screen.enter();
+ const state = defaultState();
+ screen.onState(state.snapshot(0));
+ const unit = state.units.find((u) => u.civ === 0);
+ const goal = { x: 902, y: 902 };
+ screen.map.territory.set("902,902", 1);
+ screen.map.planMove = () => [{ x: 1, y: 1 }, goal];
+ screen.map.onMoveOrdered(unit.id, goal);
+ env.$("#confirm-cancel").click();
+ this.assertEmpty(network.orders, "cancelling keeps the peace");
+ screen.leave();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_move_into_enemy_territory_skips_the_prompt() {
+ const env = await setupDom();
+ try {
+ const network = stubNetwork();
+ const screen = new GameScreen(network, makeConfig());
+ screen.enter();
+ const state = defaultState();
+ state.requestDeclareWar(0, 1);
+ screen.onState(state.snapshot(0));
+ const unit = state.units.find((u) => u.civ === 0);
+ const goal = { x: 901, y: 901 };
+ screen.map.territory.set("901,901", 1);
+ screen.map.planMove = () => [{ x: 1, y: 1 }, goal];
+ screen.map.predictMove = () => null;
+ screen.map.onMoveOrdered(unit.id, goal);
+ this.assertTrue(env.$("#modal-confirm").hasClass("hidden"));
+ this.assertEqual(network.orders[0], { type: "move", unit: unit.id, coords: [901, 901] });
+ screen.leave();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_diplomacy_tab_sends_a_declare_war_order() {
+ const env = await setupDom();
+ try {
+ const network = stubNetwork();
+ const screen = new GameScreen(network, makeConfig());
+ screen.enter();
+ screen.onState(defaultState().snapshot(0));
+ env.$("#civ-flag").click();
+ env.$("#modal-nation .tab[data-tab=diplomacy]").click();
+ const row = env.$("#diplomacy-list .row-card").first();
+ this.assertTrue(row.find(".name").text().length > 0);
+ row.find("button").click();
+ this.assertEqual(network.orders[0].type, "declare_war");
+ this.assertEqual(network.orders[0].target, 1);
+ screen.leave();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_news_feed_shows_declarations() {
+ const env = await setupDom();
+ try {
+ const screen = new GameScreen(stubNetwork(), makeConfig());
+ screen.enter();
+ const state = defaultState();
+ state.requestDeclareWar(0, 1);
+ screen.onState(state.snapshot(0));
+ env.$("#news-button").click();
+ this.assertFalse(env.$("#modal-news").hasClass("hidden"));
+ this.assertTrue(env.$("#news-list").text().includes("declared war on Britain"));
+ screen.leave();
+ } finally {
+ teardownDom(env);
+ }
+ }
}
diff --git a/tests/map_view_test.js b/tests/map_view_test.js
index 83e71f4..3fcbb02 100644
--- a/tests/map_view_test.js
+++ b/tests/map_view_test.js
@@ -22,7 +22,7 @@ export class MapViewTest extends TestCase {
const { env, map, snapshot } = await this.build(state);
try {
this.assertSize(env.$("#layer-terrain .hex"), 64 * 40);
- this.assertSize(env.$("#layer-territory .hex"), snapshot.territory.length);
+ this.assertSize(map.territory, snapshot.territory.length);
this.assertSize(env.$("#layer-fog .hex"), 64 * 40 - snapshot.visible.length);
this.assertGreater(env.$("#layer-borders .border-seg").length, 0);
this.assertSize(env.$("#layer-entities .city"), snapshot.cities.length);
@@ -246,6 +246,27 @@ export class MapViewTest extends TestCase {
}
}
+ async test_plan_move_computes_a_route_without_predicting() {
+ const state = defaultState();
+ const { env, map, snapshot } = await this.build(state);
+ try {
+ const unit = snapshot.units.find((u) => u.civ === 0);
+ const stateUnit = state.units.find((u) => u.id === unit.id);
+ const goal = adjacentLand(state, stateUnit);
+ const before = map._unitMotion.get(unit.id);
+ const route = map.planMove(unit, goal);
+ this.assertNotNull(route, "a route was planned");
+ this.assertGreaterOrEqual(route.length, 2);
+ this.assertEqual(map._unitMotion.get(unit.id), before, "planning does not start a prediction");
+ this.assertFalse(map._unitMotion.get(unit.id).predicting);
+ const applied = map.predictMove(unit, goal);
+ this.assertEqual(applied, route, "predicting follows the planned route");
+ this.assertTrue(map._unitMotion.get(unit.id).predicting);
+ } finally {
+ teardownDom(env);
+ }
+ }
+
async test_pan_moves_the_camera() {
const state = defaultState();
const { env, map } = await this.build(state);
diff --git a/tests/modals_test.js b/tests/modals_test.js
index 6ffb390..1e19662 100644
--- a/tests/modals_test.js
+++ b/tests/modals_test.js
@@ -1,6 +1,6 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
-import { TrainModal, BuildingsModal, NationModal } from "../client/js/modals.js";
+import { TrainModal, BuildingsModal, NationModal, ConfirmModal, NewsModal } from "../client/js/modals.js";
import { BUILDINGS, GOVERNMENTS, TECHNOLOGIES, PROTO_UNITS } from "../shared/data.js";
export class TrainModalTest extends TestCase {
@@ -189,4 +189,106 @@ export class NationModalTest extends TestCase {
teardownDom(env);
}
}
+
+ async test_diplomacy_tab_lists_nations_and_declares_war() {
+ const env = await setupDom();
+ try {
+ const modal = new NationModal();
+ let declared = null;
+ modal.onDeclareWar = (index) => {
+ declared = index;
+ };
+ const civs = [{ name: "France" }, { name: "Britain" }, { name: "Poland" }];
+ const relations = [[0, 1, "peace"], [0, 2, "war"], [1, 2, "peace"]];
+ modal.show(
+ GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, {},
+ { civs, localCiv: 0, relations }
+ );
+ env.$("#modal-nation .tab[data-tab=diplomacy]").click();
+ this.assertTrue(env.$("#tab-diplomacy").hasClass("active"));
+
+ const rows = env.$("#diplomacy-list .row-card");
+ this.assertSize(rows, 2, "every other nation is listed");
+ this.assertTrue(rows.eq(0).find(".name").text() === "Britain");
+ this.assertTrue(rows.eq(0).find(".desc").text().includes("At peace"));
+ this.assertTrue(rows.eq(1).find(".name").text() === "Poland");
+ this.assertTrue(rows.eq(1).find(".desc").text().includes("At war"));
+ this.assertTrue(rows.eq(1).find("button").prop("disabled"), "already at war");
+ this.assertTrue(env.$("#diplomacy-sub").text().includes("at war with 1"));
+
+ rows.eq(0).find("button").click();
+ this.assertEqual(declared, 1);
+ modal.close();
+ this.assertTrue(env.$("#modal-nation").hasClass("hidden"));
+ } finally {
+ teardownDom(env);
+ }
+ }
+}
+
+export class ConfirmModalTest extends TestCase {
+ async test_confirm_runs_the_callback_and_cancel_does_not() {
+ const env = await setupDom();
+ try {
+ const modal = new ConfirmModal();
+ let confirmed = 0;
+ modal.show("Declare war?", "Marching in will declare war.", () => { confirmed += 1; });
+ this.assertTrue(modal.isShowing());
+ this.assertEqual(env.$("#confirm-title").text(), "Declare war?");
+ this.assertTrue(env.$("#confirm-message").text().includes("declare war"));
+
+ env.$("#confirm-cancel").click();
+ this.assertFalse(modal.isShowing());
+ this.assertEqual(confirmed, 0, "cancelling runs nothing");
+
+ modal.show("Again", "Sure?", () => { confirmed += 1; });
+ env.$("#confirm-ok").click();
+ this.assertEqual(confirmed, 1, "confirming runs the callback once");
+ this.assertFalse(modal.isShowing());
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_close_button_cancels() {
+ const env = await setupDom();
+ try {
+ const modal = new ConfirmModal();
+ let confirmed = 0;
+ modal.show("T", "M", () => { confirmed += 1; });
+ env.$("#modal-confirm .modal-close").click();
+ this.assertEqual(confirmed, 0);
+ this.assertFalse(modal.isShowing());
+ } finally {
+ teardownDom(env);
+ }
+ }
+}
+
+export class NewsModalTest extends TestCase {
+ async test_lists_events_newest_first_with_dates() {
+ const env = await setupDom();
+ try {
+ const modal = new NewsModal();
+ const civs = [{ name: "France" }, { name: "Britain" }];
+ const news = [
+ { id: 1, type: "war", actor: 0, target: 1, date: "1 January 2000, 00:00" },
+ { id: 2, type: "capture", city: "Lyon", from: 1, to: 0, date: "2 January 2000, 05:00" },
+ ];
+ modal.show(news, civs);
+ const items = env.$("#news-list .news-item");
+ this.assertSize(items, 2);
+ this.assertTrue(items.eq(0).find(".news-text").text().includes("captured Lyon"));
+ this.assertTrue(items.eq(0).find(".news-date").text().includes("05:00"));
+ this.assertTrue(items.eq(1).find(".news-text").text().includes("France declared war on Britain"));
+
+ modal.refresh([], civs);
+ this.assertEqual(env.$("#news-list .news-item").length, 0);
+ this.assertTrue(env.$("#news-list").text().includes("No news"));
+ modal.close();
+ this.assertTrue(env.$("#modal-news").hasClass("hidden"));
+ } finally {
+ teardownDom(env);
+ }
+ }
}
diff --git a/tests/run_tests.js b/tests/run_tests.js
index 149a0c9..5fa955f 100644
--- a/tests/run_tests.js
+++ b/tests/run_tests.js
@@ -21,12 +21,13 @@ import { GameStateTest } from "./game_state_test.js";
import { GameStateBuildingsTest } from "./game_state_buildings_test.js";
import { GameStateGovernmentTest } from "./game_state_government_test.js";
import { GameStateTechnologyTest } from "./game_state_technology_test.js";
+import { DiplomacyStateTest, DiplomacyServerTest } from "./diplomacy_test.js";
import { GameServerTest, GameServerOrdersTest, GameServerLoginTest, GameServerLobbyTest } from "./game_server_test.js";
import { ServerStaticTest, ServerLoopTest, ServerProtocolTest } from "./server_test.js";
import { WebSocketProtocolTest } from "./websocket_test.js";
import { NetTest } from "./net_test.js";
import { MapViewTest } from "./map_view_test.js";
-import { TrainModalTest, BuildingsModalTest, NationModalTest } from "./modals_test.js";
+import { TrainModalTest, BuildingsModalTest, NationModalTest, ConfirmModalTest, NewsModalTest } from "./modals_test.js";
import { GameScreenTest } from "./game_screen_test.js";
import { AppTest } from "./app_test.js";
@@ -49,6 +50,8 @@ const SUITES = [
GameStateBuildingsTest,
GameStateGovernmentTest,
GameStateTechnologyTest,
+ DiplomacyStateTest,
+ DiplomacyServerTest,
GameServerTest,
GameServerOrdersTest,
GameServerLoginTest,
@@ -62,6 +65,8 @@ const SUITES = [
TrainModalTest,
BuildingsModalTest,
NationModalTest,
+ ConfirmModalTest,
+ NewsModalTest,
GameScreenTest,
AppTest,
];