diff --git a/client/js/app.js b/client/js/app.js
index 7c0c296..64ccb72 100644
--- a/client/js/app.js
+++ b/client/js/app.js
@@ -19,6 +19,7 @@ let joining = false;
let mode = "join";
let games = [];
let selectedGame = -1;
+let selectedCivId = CIVILISATIONS[0].id;
function showScreen(id) {
$(".screen").removeClass("active");
@@ -39,11 +40,27 @@ $("#menu-join").on("click", () => {
// ---------------------------------------------------------- new game ------
-const $civSelect = $("#new-civ");
+const $civSelect = $("#new-civs");
for (const civ of CIVILISATIONS) {
- $civSelect.append($("
").val(civ.id).text(civ.name));
+ const $item = civOption(civ);
+ $item.on("click", () => {
+ selectedCivId = civ.id;
+ $civSelect.children().removeClass("selected");
+ $item.addClass("selected");
+ });
+ $civSelect.append($item);
}
+$civSelect.children().first().addClass("selected");
$("#new-count").attr("max", CIVILISATIONS.length);
+// A fresh game starts with every civilisation in the world.
+$("#new-count").val(CIVILISATIONS.length);
+
+function civOption(civ) {
+ return $("
")
+ .attr("data-civ-id", civ.id)
+ .append($("
![]()
").attr("src", `assets/flags/${civ.flag}`))
+ .append($("
").text(civ.name));
+}
$("#new-back").on("click", () => {
network.close();
@@ -51,7 +68,7 @@ $("#new-back").on("click", () => {
});
$("#new-start").on("click", () => {
- const civId = $civSelect.val();
+ const civId = selectedCivId;
const count = Math.max(1, Math.min(CIVILISATIONS.length, Number($("#new-count").val()) || 1));
const name = $("#new-name").val().trim();
const password = $("#new-password").val();
@@ -81,7 +98,8 @@ $("#new-start").on("click", () => {
function setNewEnabled(enabled) {
$("#new-start, #new-back").prop("disabled", !enabled);
- $("#new-count, #new-civ, #new-name, #new-password").prop("disabled", !enabled);
+ $("#new-count, #new-name, #new-password").prop("disabled", !enabled);
+ $("#new-civs").css("pointer-events", enabled ? "" : "none");
}
// -------------------------------------------------------------- join ------
@@ -95,6 +113,11 @@ $("#join-back").on("click", () => {
});
$("#join-create").on("click", () => showScreen("screen-new"));
$("#join-go").on("click", joinSelected);
+$("#choose-back").on("click", () => {
+ network.close();
+ showScreen("screen-join");
+ setJoinEnabled(true);
+});
function joinServerUrl() {
return network.normalizeUrl($("#join-server").val());
@@ -176,11 +199,46 @@ network.on("session_started", () => {
status("new-status", "");
startGameScreen(true);
} else {
+ // The server follows up with the unclaimed civilisations to pick from.
status("join-status", "");
+ }
+});
+
+// Offer the unclaimed civilisations; picking one claims it for this player.
+network.on("choose_civ_received", (civs) => {
+ if (mode !== "join") return;
+ if (!civs || civs.length === 0) {
+ status("choose-status", "No unclaimed nation left — joining as a spectator.");
+ startGameScreen(false);
+ return;
+ }
+ renderCivChoices(civs);
+ showScreen("screen-choose");
+});
+
+network.on("civ_assigned", () => {
+ if (mode === "join" && gameScreen === null) {
+ status("choose-status", "");
startGameScreen(false);
}
});
+function renderCivChoices(civs) {
+ const $list = $("#choose-civs").empty();
+ for (const entry of civs) {
+ const civ = CIVILISATIONS.find((c) => c.id === entry.id) || entry;
+ const $item = civOption(civ);
+ $item.on("click", () => {
+ $list.children().removeClass("selected");
+ $item.addClass("selected");
+ config.playerCivId = entry.id;
+ status("choose-status", "Claiming...");
+ network.claimCiv(entry.index);
+ });
+ $list.append($item);
+ }
+}
+
network.on("session_failed", (message) => {
if (mode === "create") {
setNewEnabled(true);
diff --git a/client/js/game_screen.js b/client/js/game_screen.js
index 5cc0f4d..d369222 100644
--- a/client/js/game_screen.js
+++ b/client/js/game_screen.js
@@ -4,7 +4,8 @@
// same cost model as the server.
import { MapView } from "./map_view.js";
-import { TrainModal, BuildingsModal, NationModal, ConfirmModal, NewsModal } from "./modals.js";
+import { TrainModal, BuildingsModal, NationModal, ConfirmModal, NewsModal, describeNews } from "./modals.js";
+import { GameFeed } from "./feed.js";
import { compact, groupDigits, improvements as fmtImprovements } from "../../shared/text_format.js";
import {
CIVILISATIONS,
@@ -13,6 +14,7 @@ import {
BUILDINGS,
GOVERNMENTS,
TECHNOLOGIES,
+ NEWS_WAR,
} from "../../shared/data.js";
import { defenseFactors, unitDefense } from "../../shared/rules.js";
import { dateString } from "../../shared/game_clock.js";
@@ -35,6 +37,7 @@ export class GameScreen {
this._researched = new Set();
this._wars = new Set();
this._news = [];
+ this._seenNews = null;
this._entered = false;
this._lastFrame = 0;
this._fpsFrames = 0;
@@ -58,6 +61,7 @@ export class GameScreen {
this.nationModal = new NationModal();
this.confirmModal = new ConfirmModal();
this.newsModal = new NewsModal();
+ this.feed = new GameFeed($("#game-feed"));
this.$statTooltip = $("#stat-tooltip");
this._hoveredStat = null;
@@ -76,6 +80,8 @@ export class GameScreen {
leave() {
this._entered = false;
this.$screen.removeClass("active");
+ this.feed.clear();
+ this._seenNews = null;
}
// ------------------------------------------------------------- orders ---
@@ -122,6 +128,7 @@ export class GameScreen {
this._wars.add(a < b ? `${a}:${b}` : `${b}:${a}`);
}
this._news = state.news || [];
+ this._updateFeed(this._news);
this.map.setCatalogue(this.civilisations, this.protoUnits);
this.map.applySnapshot(state);
@@ -218,9 +225,10 @@ export class GameScreen {
const targets = this._unalliedTerritoryOnRoute(unit, coords);
if (targets.length > 0) {
const names = targets.map((index) => this._civName(index)).join(", ");
+ const demonyms = targets.map((index) => this._civDemonym(index)).join(", ");
this.confirmModal.show(
"Declare war?",
- `Marching into ${names} territory will declare war on ${names}. Continue?`,
+ `Marching into ${demonyms} territory will declare war on ${names}. Continue?`,
() => this._sendMove(unit, coords)
);
return;
@@ -266,6 +274,11 @@ export class GameScreen {
return civ ? civ.name : `Nation ${index + 1}`;
}
+ _civDemonym(index) {
+ const civ = this.civilisations[index];
+ return civ && civ.demonym ? civ.demonym : this._civName(index);
+ }
+
_updateTopPanel(state) {
const civ = this.civilisations[this.localCiv];
if (civ) {
@@ -505,6 +518,28 @@ export class GameScreen {
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;
}
diff --git a/client/js/map_view.js b/client/js/map_view.js
index 5908b31..f576b4b 100644
--- a/client/js/map_view.js
+++ b/client/js/map_view.js
@@ -367,10 +367,14 @@ export class MapView {
_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;
- seen.add(id);
- this._unitData.set(id, data);
let view = this._unitViews.get(id);
if (!view) {
view = this._createUnitView(data);
@@ -380,6 +384,15 @@ export class MapView {
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", id === this._selectedUnitId);
@@ -416,6 +429,10 @@ export class MapView {
health.appendChild(fill);
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(statuses);
@@ -454,10 +471,41 @@ export class MapView {
// ------------------------------------------------------------- motion ---
+ // A living unit standing on the path blocks dead reckoning: the animation
+ // must stop before it. For an enemy military unit that is the fight itself,
+ // so the mover halts within its own firing range instead — an attacker is not
+ // really moving until the tile it wants is free.
+ _truncateAtOccupant(path, segmentHours, data) {
+ const proto = this.protoUnits[data.proto];
+ const range = proto && typeof proto.range === "number" ? Math.max(1, proto.range) : 1;
+ for (let i = 1; i < path.length; i++) {
+ const occupant = this._unitAtCoords(path[i], data.id);
+ if (!occupant) continue;
+ if (proto && proto.military && this._isMilitaryData(occupant) && occupant.civ !== data.civ) {
+ let stop = i - 1;
+ for (let j = 0; j <= i; j++) {
+ if (this.topology.tileDistance(path[j], path[i]) <= range) {
+ stop = j;
+ break;
+ }
+ }
+ return { path: path.slice(0, stop + 1), segmentHours: segmentHours.slice(0, stop) };
+ }
+ return {
+ path: path.slice(0, i),
+ segmentHours: segmentHours.slice(0, Math.max(0, i - 1)),
+ };
+ }
+ return { path, segmentHours };
+ }
+
_updateUnitMotion(data) {
const id = data.id;
- const path = (data.path || []).map((p) => ({ x: p[0], y: p[1] }));
- const segmentHours = data.segmentHours || [];
+ 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) {
diff --git a/client/js/modals.js b/client/js/modals.js
index d36600f..c6fa8c1 100644
--- a/client/js/modals.js
+++ b/client/js/modals.js
@@ -501,13 +501,17 @@ export class NewsModal {
}
}
-function describeNews(entry, civs) {
+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 `${name(entry.actor)} declared war on ${name(entry.target)}`;
+ return `${people(entry.actor)} declared war on ${name(entry.target)}`;
case "capture":
- return `${name(entry.to)} captured ${entry.city} from ${name(entry.from)}`;
+ return `${people(entry.to)} captured ${entry.city} from ${name(entry.from)}`;
default:
return entry.type;
}
diff --git a/client/js/net.js b/client/js/net.js
index 50ca9b6..e4845ac 100644
--- a/client/js/net.js
+++ b/client/js/net.js
@@ -129,6 +129,10 @@ export class Network {
this._send({ t: "configure_game", setup });
}
+ claimCiv(index) {
+ this._send({ t: "claim_civ", civ: index });
+ }
+
sendOrder(order) {
this._send({ t: "order", order });
}
@@ -176,6 +180,12 @@ export class Network {
case "game_configured":
this._emit("game_configured");
break;
+ case "choose_civ":
+ this._emit("choose_civ_received", message.civs || []);
+ break;
+ case "civ_assigned":
+ this._emit("civ_assigned", message.civ);
+ break;
case "game_state":
this._emit("game_state_received", message.state);
break;
diff --git a/server/game_server.js b/server/game_server.js
index 53f8a7c..b909423 100644
--- a/server/game_server.js
+++ b/server/game_server.js
@@ -160,9 +160,19 @@ export class GameServer extends EventEmitter {
onPeerJoined(peerId, playerName = "") {
if (!this._configured) return;
- this._assignCiv(peerId, playerName);
- const civ = this.peerCiv.has(peerId) ? this.peerCiv.get(peerId) : -1;
- this.network.send(peerId, { t: "game_state", state: this.state.snapshot(civ) });
+ // A returning player keeps the nation they claimed before.
+ if (this._reclaimIdentity(peerId, playerName)) {
+ this.network.send(peerId, { t: "game_state", state: this.state.snapshot(this.peerCiv.get(peerId)) });
+ return;
+ }
+ // Otherwise offer every nation no one has claimed yet and let the player
+ // pick. They stay a spectator (civ -1) until they answer.
+ this.peerCiv.set(peerId, -1);
+ const options = this.availableCivs();
+ this.network.send(peerId, { t: "choose_civ", civs: options });
+ if (options.length === 0) {
+ this.network.send(peerId, { t: "game_state", state: this.state.snapshot(-1) });
+ }
}
onPeerLeft(peerId) {
@@ -170,6 +180,48 @@ export class GameServer extends EventEmitter {
this.peerNames.delete(peerId);
}
+ // Indices of the civilisations no connected peer has claimed.
+ availableCivs() {
+ if (!this._configured || !this.state) return [];
+ const used = new Set(this.peerCiv.values());
+ const options = [];
+ for (let i = 0; i < this.state.civilisations.length; i++) {
+ if (used.has(i)) continue;
+ const civ = this.state.civilisations[i];
+ options.push({ index: i, id: civ.id, name: civ.name });
+ }
+ return options;
+ }
+
+ // Claims one of the offered civilisations for a joining peer. Rejects an
+ // index that is out of range or already taken by someone else.
+ claimCiv(peerId, index) {
+ if (!this._configured || !this.state) return false;
+ const choice = Number(index);
+ if (!Number.isInteger(choice) || choice < 0 || choice >= this.state.civilisations.length) {
+ return false;
+ }
+ if (Array.from(this.peerCiv.values()).includes(choice)) return false;
+ this.peerCiv.set(peerId, choice);
+ const name = this.peerNames.get(peerId);
+ if (name) this.identityCiv.set(name, choice);
+ this.network.send(peerId, { t: "civ_assigned", civ: choice });
+ this._broadcastState();
+ return true;
+ }
+
+ // Re-attaches a known player to the nation they held before, as long as no
+ // one else has claimed it in the meantime.
+ _reclaimIdentity(peerId, playerName) {
+ if (!playerName || !this.identityCiv.has(playerName)) return false;
+ const choice = this.identityCiv.get(playerName);
+ if (choice < 0 || choice >= this.state.civilisations.length) return false;
+ if (Array.from(this.peerCiv.values()).includes(choice)) return false;
+ this.peerCiv.set(peerId, choice);
+ this.network.send(peerId, { t: "civ_assigned", civ: choice });
+ return true;
+ }
+
_assignCiv(peerId, playerName = "") {
if (this._configured === false || this.state == null) return;
const used = Array.from(this.peerCiv.values());
diff --git a/server/server.js b/server/server.js
index 1500757..e5ae17f 100644
--- a/server/server.js
+++ b/server/server.js
@@ -132,6 +132,9 @@ export function startServer({ port = 27015, bind = "127.0.0.1", log = console.lo
peer.send(JSON.stringify({ t: "game_configured" }));
}
break;
+ case "claim_civ":
+ gameServer.claimCiv(connection.id, message.civ);
+ break;
case "order":
gameServer.handleOrder(connection.id, message.order || {});
break;
diff --git a/shared/data.js b/shared/data.js
index e242865..652e01b 100644
--- a/shared/data.js
+++ b/shared/data.js
@@ -32,6 +32,7 @@ export const CIVILISATIONS = [
{
id: "france",
name: "France",
+ demonym: "French",
primaryColour: "#0d0d5c",
accentColour: "#8c8c82",
flag: "flag_france.png",
@@ -40,6 +41,7 @@ export const CIVILISATIONS = [
{
id: "britain",
name: "Britain",
+ demonym: "British",
primaryColour: "#a81818",
accentColour: "#0722ff",
flag: "flag_britain.png",
@@ -48,14 +50,16 @@ export const CIVILISATIONS = [
{
id: "slovenia",
name: "Slovenia",
+ demonym: "Slovenian",
primaryColour: "#005da4",
accentColour: "#ed1c24",
flag: "flag_slovenia.png",
- cityNames: ["Maribor", "Celje", "Kranj", "Koper", "Velenje", "Novo Mesto", "Ptuj", "Kamnik", "Jesenice"],
+ 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",
@@ -64,6 +68,7 @@ export const CIVILISATIONS = [
{
id: "switzerland",
name: "Switzerland",
+ demonym: "Swiss",
primaryColour: "#da291c",
accentColour: "#ffffff",
flag: "flag_switzerland.png",
@@ -72,6 +77,7 @@ export const CIVILISATIONS = [
{
id: "ticino",
name: "Ticino",
+ demonym: "Ticinese",
primaryColour: "#4646e0",
accentColour: "#a6000b",
flag: "flag_ticino.png",
@@ -80,6 +86,7 @@ export const CIVILISATIONS = [
{
id: "galicia",
name: "Galicia",
+ demonym: "Galician",
primaryColour: "#006dad",
accentColour: "#ffffff",
flag: "flag_galicia.png",
@@ -88,6 +95,7 @@ export const CIVILISATIONS = [
{
id: "germany",
name: "Germany",
+ demonym: "German",
primaryColour: "#141414",
accentColour: "#ffce00",
flag: "flag_germany.png",
@@ -96,6 +104,7 @@ export const CIVILISATIONS = [
{
id: "iran",
name: "Iran",
+ demonym: "Iranian",
primaryColour: "#18a645",
accentColour: "#f01c21",
flag: "flag_iran.png",
@@ -164,12 +173,14 @@ export const TRAINABLE_UNIT_IDS = ["modern_infantry", "artillery"];
// 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 once a day, applies their daily 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) and a
-// `cityHpLossPerDay` override for when the unit stands in a city.
+// 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 = [
{
@@ -186,6 +197,13 @@ export const STATUSES = [
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 in range. Upkeep is tripled while the battle rages.",
+ upkeepMultiplier: 3,
+ },
];
export function statusById(id) {
diff --git a/shared/game_state.js b/shared/game_state.js
index 74fad06..ee9c369 100644
--- a/shared/game_state.js
+++ b/shared/game_state.js
@@ -23,6 +23,7 @@ import {
EFFECT_CULTURE,
STATUS_ENCIRCLED,
STATUS_BESIEGED,
+ STATUS_BATTLE,
RELATION_PEACE,
RELATION_WAR,
NEWS_WAR,
@@ -349,19 +350,30 @@ export class GameState {
// resolves, so neither side can slip away mid-fight.
const engaged = this._engagedUnits();
for (const unit of this.units) {
- const path = unit.path;
- if (!path || path.length === 0) continue;
+ if (!unit.path || unit.path.length === 0) continue;
if (engaged.has(unit)) continue;
let hoursLeft = hours;
- while (hoursLeft > 0 && unit.pathIndex < path.length - 1) {
+ while (hoursLeft > 0 && unit.pathIndex < unit.path.length - 1) {
// Stop as soon as the target is in range: a ranged unit holds its
// distance rather than closing to melee.
if (this._combatTarget(unit)) break;
- const next = path[unit.pathIndex + 1];
- // Any other unit on the next tile blocks the step. Military pairs are
- // resolved by the daily combat round instead; the rest simply wait.
+ const next = unit.path[unit.pathIndex + 1];
const occupant = this.unitAt(next);
- if (occupant && occupant !== unit) break;
+ if (occupant && occupant !== unit) {
+ // A hostile military unit in the way is the fight we are marching
+ // into: hold position until it dies rather than treating the tile as
+ // an obstacle. Anyone else 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._isBlockingEnemy(unit, occupant)) break;
+ if (this._repathAround(unit)) {
+ const ahead = this.unitAt(unit.path[unit.pathIndex + 1]);
+ if (ahead && ahead !== unit) break;
+ continue;
+ }
+ this._stopUnit(unit);
+ break;
+ }
const cost = Math.max(this._tileTravelHours(unit, next), MIN_STEP_COST);
const needed = cost - unit.progressHours;
if (hoursLeft >= needed) {
@@ -376,14 +388,39 @@ export class GameState {
hoursLeft = 0;
}
}
- if (unit.pathIndex >= path.length - 1) {
- unit.path = [];
- unit.pathIndex = 0;
- unit.progressHours = 0;
+ if (unit.path && unit.pathIndex >= unit.path.length - 1) {
+ 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;
+ }
+
+ // 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 attack, no partial step.
+ _stopUnit(unit) {
+ unit.path = [];
+ unit.pathIndex = 0;
+ unit.progressHours = 0;
+ unit.attackTarget = null;
+ }
+
_engagedUnits() {
const engaged = new Set();
for (const { attacker, defender } of this._combatPairs()) {
@@ -598,12 +635,43 @@ export class GameState {
return typeof def.hpLossPerDay === "number" ? def.hpLossPerDay : 0;
}
+ // The ids of every unit currently trading blows, on either side of the
+ // exchange. Out-ranged defenders cannot strike back but are still in battle.
+ _battleUnitIds() {
+ const ids = new Set();
+ for (const { attacker, defender } of this._combatPairs()) {
+ ids.add(attacker.id);
+ ids.add(defender.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 = unit.statuses || [];
+ const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
if (statuses.length === 0) continue;
const inCity = !!this.cityAt(unit.coords);
let fraction = 0;
@@ -800,10 +868,13 @@ export class GameState {
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) total += proto.upkeep;
+ if (!proto) continue;
+ const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
+ total += proto.upkeep * this._statusUpkeepMultiplier(statuses);
}
return total;
}
@@ -908,12 +979,14 @@ export class GameState {
_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;
+ entry.amount += proto.upkeep * this._statusUpkeepMultiplier(statuses);
entry.count += 1;
sources.set(unit.proto, entry);
}
@@ -1600,6 +1673,7 @@ export class GameState {
}
_serializeUnits() {
+ const battle = this._battleUnitIds();
return this.units.map((unit) => {
const rawPath = unit.path || [];
const pathIndex = unit.pathIndex;
@@ -1619,7 +1693,7 @@ export class GameState {
coords: [unit.coords.x, unit.coords.y],
hp: unit.hp,
maxHp: unit.maxHp,
- statuses: unit.statuses || [],
+ statuses: this._effectiveStatuses(unit, battle.has(unit.id)),
path,
pathIndex,
progressHours: unit.progressHours,
diff --git a/tests/app_test.js b/tests/app_test.js
index ba0d0a5..40e1d17 100644
--- a/tests/app_test.js
+++ b/tests/app_test.js
@@ -11,7 +11,8 @@ export class AppTest extends TestCase {
try {
FakeWebSocket.reset();
await import(`../client/js/app.js?join=${Date.now()}`);
- this.assertSize(env.$("#new-civ option"), CIVILISATIONS.length);
+ this.assertSize(env.$("#new-civs li"), CIVILISATIONS.length);
+ this.assertTrue(env.$("#new-civs li").first().find("img").length === 1, "civ picker shows flags");
env.$("#menu-join").click();
this.assertTrue(env.$("#screen-join").hasClass("active"));
@@ -34,8 +35,19 @@ export class AppTest extends TestCase {
await flush();
this.assertEqual(joinSocket.lastSent(), { t: "login", name: "Carol", password: "pw" });
joinSocket.simulateMessage({ t: "assigned", playerId: 2, admin: false, name: "Carol" });
- this.assertTrue(env.$("#screen-game").hasClass("active"));
+ this.assertFalse(env.$("#screen-game").hasClass("active"), "no game screen before picking");
+ joinSocket.simulateMessage({
+ t: "choose_civ",
+ civs: [{ index: 1, id: "britain", name: "Britain" }],
+ });
+ this.assertTrue(env.$("#screen-choose").hasClass("active"));
+ this.assertSize(env.$("#choose-civs li"), 1);
+ env.$("#choose-civs li").first().click();
+ this.assertEqual(joinSocket.lastSent(), { t: "claim_civ", civ: 1 });
+
+ joinSocket.simulateMessage({ t: "civ_assigned", civ: 1 });
+ this.assertTrue(env.$("#screen-game").hasClass("active"));
joinSocket.simulateMessage({ t: "game_state", state: defaultState().snapshot(1) });
this.assertEqual(env.$("#civ-name").text(), "Britain");
this.assertFalse(env.$("#stat-pop").text() === "0");
@@ -44,6 +56,46 @@ export class AppTest extends TestCase {
}
}
+ async test_join_without_a_free_nation_becomes_a_spectator() {
+ const env = await setupDom();
+ try {
+ FakeWebSocket.reset();
+ await import(`../client/js/app.js?spectate=${Date.now()}`);
+ env.$("#menu-join").click();
+ const listSocket = FakeWebSocket.last;
+ listSocket.simulateOpen();
+ await flush();
+ listSocket.simulateMessage({ t: "game_list", games: [{ name: "Game 999" }] });
+
+ env.$("#join-name").val("Carol");
+ env.$("#join-password").val("pw");
+ env.$("#join-go").click();
+ const joinSocket = FakeWebSocket.last;
+ joinSocket.simulateOpen();
+ await flush();
+ joinSocket.simulateMessage({ t: "assigned", playerId: 2, admin: false, name: "Carol" });
+ joinSocket.simulateMessage({ t: "choose_civ", civs: [] });
+ this.assertTrue(env.$("#screen-game").hasClass("active"), "no choice means a spectator view");
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_new_game_defaults() {
+ const env = await setupDom();
+ try {
+ FakeWebSocket.reset();
+ await import(`../client/js/app.js?defaults=${Date.now()}`);
+ env.$("#menu-create").click();
+ this.assertEqual(env.$("#new-name").val(), "adrien");
+ this.assertEqual(env.$("#new-password").val(), "admin");
+ this.assertEqual(Number(env.$("#new-count").val()), CIVILISATIONS.length, "all nations by default");
+ this.assertEqual(env.$("#new-civs li.selected").attr("data-civ-id"), CIVILISATIONS[0].id);
+ } finally {
+ teardownDom(env);
+ }
+ }
+
async test_create_flow_sends_setup_and_renders() {
const env = await setupDom();
try {
diff --git a/tests/data_test.js b/tests/data_test.js
index 55fe2c7..075b2df 100644
--- a/tests/data_test.js
+++ b/tests/data_test.js
@@ -11,6 +11,8 @@ import {
governmentById,
technologyById,
effectValue,
+ statusById,
+ STATUS_BATTLE,
} from "../shared/data.js";
export class DataTest extends TestCase {
@@ -35,10 +37,18 @@ export class DataTest extends TestCase {
this.assertTrue(civ.name.length > 0, "name");
this.assertTrue(civ.primaryColour.startsWith("#"), `primary colour of ${civ.id}`);
this.assertTrue(civ.flag.endsWith(".png"), `flag of ${civ.id}`);
+ this.assertTrue(civ.demonym.length > 0, `demonym of ${civ.id}`);
this.assertNotEmpty(civ.cityNames, `city names of ${civ.id}`);
}
}
+ test_battle_status_is_rendered_and_triples_upkeep() {
+ const battle = statusById(STATUS_BATTLE);
+ this.assertNotNull(battle, "the battle status exists");
+ this.assertEqual(battle.icon, "icon_battle.svg");
+ this.assertEqual(battle.upkeepMultiplier, 3);
+ }
+
test_trainable_units_exist_in_the_catalogue() {
this.assertNotEmpty(TRAINABLE_UNIT_IDS);
for (const id of TRAINABLE_UNIT_IDS) {
diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js
index 4f5d5dc..b52fc81 100644
--- a/tests/game_screen_test.js
+++ b/tests/game_screen_test.js
@@ -408,4 +408,44 @@ export class GameScreenTest extends TestCase {
teardownDom(env);
}
}
+
+ async test_new_news_pops_up_in_the_corner_feed() {
+ const env = await setupDom();
+ try {
+ const screen = new GameScreen(stubNetwork(), makeConfig());
+ screen.enter();
+ const state = defaultState();
+ // The first snapshot is history: it must not flood the feed.
+ screen.onState(state.snapshot(0));
+ this.assertSize(env.$("#game-feed .feed-line"), 0);
+
+ state.requestDeclareWar(0, 1);
+ screen.onState(state.snapshot(0));
+ this.assertSize(env.$("#game-feed .feed-line"), 1);
+ this.assertTrue(env.$("#game-feed .feed-line").text().includes("declared war on Britain"));
+ screen.leave();
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_war_declared_on_the_player_is_an_alert() {
+ const env = await setupDom();
+ try {
+ const screen = new GameScreen(stubNetwork(), makeConfig());
+ screen.enter();
+ const state = defaultState();
+ screen.onState(state.snapshot(0));
+
+ state.requestDeclareWar(1, 0);
+ screen.onState(state.snapshot(0));
+ const alert = env.$("#game-feed .feed-line.feed-alert");
+ this.assertSize(alert, 1);
+ this.assertTrue(alert.text().includes("declared war on you"));
+ screen.leave();
+ this.assertSize(env.$("#game-feed .feed-line"), 0, "leaving clears the feed");
+ } finally {
+ teardownDom(env);
+ }
+ }
}
diff --git a/tests/game_server_test.js b/tests/game_server_test.js
index 96222b8..46a4ea9 100644
--- a/tests/game_server_test.js
+++ b/tests/game_server_test.js
@@ -162,16 +162,47 @@ export class GameServerOrdersTest extends TestCase {
}
test_extra_players_become_spectators() {
- const { server } = makeServer();
+ const { server, network } = makeServer();
server.authenticate(1, "Host", "pw");
server.onPeerJoined(1, "Host");
+ this.assertTrue(server.claimCiv(1, 0));
server.authenticate(2, "Bob", "pw");
server.onPeerJoined(2, "Bob");
+ this.assertTrue(server.claimCiv(2, 1));
server.authenticate(3, "Cat", "pw");
server.onPeerJoined(3, "Cat");
this.assertEqual(server.peerCiv.get(1), 0);
this.assertEqual(server.peerCiv.get(2), 1);
this.assertEqual(server.peerCiv.get(3), -1, "a third player has no nation left");
+ this.assertEmpty(server.availableCivs());
+ }
+
+ test_joining_player_is_offered_the_unclaimed_civilisations() {
+ const { server, network } = makeServer();
+ server.authenticate(1, "Host", "pw");
+ server.onPeerJoined(1, "Host");
+ server.claimCiv(1, 0);
+ network.sent.length = 0;
+ server.authenticate(2, "Bob", "pw");
+ server.onPeerJoined(2, "Bob");
+ const choose = network.sent.find((m) => m.peerId === 2 && m.message.t === "choose_civ");
+ this.assertNotNull(choose, "the joiner is offered a choice");
+ this.assertEqual(choose.message.civs.map((c) => c.index), [1]);
+ this.assertEqual(choose.message.civs[0].id, "britain");
+ this.assertEqual(server.peerCiv.get(2), -1, "no nation until the player picks");
+ }
+
+ test_claiming_a_taken_or_unknown_civilisation_is_rejected() {
+ const { server } = makeServer();
+ server.authenticate(1, "Host", "pw");
+ server.onPeerJoined(1, "Host");
+ this.assertTrue(server.claimCiv(1, 0));
+ server.authenticate(2, "Bob", "pw");
+ server.onPeerJoined(2, "Bob");
+ this.assertFalse(server.claimCiv(2, 0), "already claimed");
+ this.assertFalse(server.claimCiv(2, 99), "out of range");
+ this.assertFalse(server.claimCiv(2, -1), "unknown index");
+ this.assertEqual(server.peerCiv.get(2), -1);
}
test_lifecycle_calls_are_safe_before_configuration() {
@@ -249,6 +280,7 @@ export class GameServerLoginTest extends TestCase {
const { server } = makeServer();
server.authenticate(1, "Alice", "pw");
server.onPeerJoined(1, "Alice");
+ this.assertTrue(server.claimCiv(1, 0));
const firstCiv = server.peerCiv.get(1);
server.onPeerLeft(1);
this.assertEmpty(server.peerCiv);
@@ -261,6 +293,7 @@ export class GameServerLoginTest extends TestCase {
const { server } = makeServer();
server.authenticate(1, "Alice", "pw");
server.onPeerJoined(1, "Alice");
+ server.claimCiv(1, 0);
const unit = server.state.units[0];
const goal = adjacentLand(server.state, unit);
this.assertTrue(server.handleOrder(1, { type: "move", unit: unit.id, coords: [goal.x, goal.y] }));
diff --git a/tests/map_view_test.js b/tests/map_view_test.js
index 3fcbb02..48e3923 100644
--- a/tests/map_view_test.js
+++ b/tests/map_view_test.js
@@ -33,6 +33,21 @@ export class MapViewTest extends TestCase {
}
}
+ async test_unit_views_show_their_owners_flag() {
+ const state = defaultState();
+ const { env } = await this.build(state);
+ try {
+ const flags = env.$("#layer-entities .unit .unit-flag");
+ this.assertSize(flags, state.units.length, "every unit carries a flag");
+ const unit = state.units[0];
+ const civ = CIVILISATIONS.find((c) => c.id === state.civilisations[unit.civ].id);
+ const src = env.$("#layer-entities .unit").first().find(".unit-flag").attr("src") || "";
+ this.assertTrue(src.endsWith(civ.flag), `the flag is the owner's: ${src}`);
+ } finally {
+ teardownDom(env);
+ }
+ }
+
async test_centers_on_the_capital() {
const state = defaultState();
const { env, map } = await this.build(state);
diff --git a/tests/modals_test.js b/tests/modals_test.js
index 1e19662..653ac8f 100644
--- a/tests/modals_test.js
+++ b/tests/modals_test.js
@@ -270,7 +270,7 @@ export class NewsModalTest extends TestCase {
const env = await setupDom();
try {
const modal = new NewsModal();
- const civs = [{ name: "France" }, { name: "Britain" }];
+ const civs = [{ name: "France", demonym: "French" }, { name: "Britain", demonym: "British" }];
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" },
@@ -278,9 +278,9 @@ export class NewsModalTest extends TestCase {
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-text").text().includes("The French captured Lyon from Britain"));
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"));
+ this.assertTrue(items.eq(1).find(".news-text").text().includes("The French declared war on Britain"));
modal.refresh([], civs);
this.assertEqual(env.$("#news-list .news-item").length, 0);
diff --git a/tests/run_tests.js b/tests/run_tests.js
index 5fa955f..87f0bb3 100644
--- a/tests/run_tests.js
+++ b/tests/run_tests.js
@@ -29,6 +29,7 @@ import { NetTest } from "./net_test.js";
import { MapViewTest } from "./map_view_test.js";
import { TrainModalTest, BuildingsModalTest, NationModalTest, ConfirmModalTest, NewsModalTest } from "./modals_test.js";
import { GameScreenTest } from "./game_screen_test.js";
+import { GameFeedTest } from "./feed_test.js";
import { AppTest } from "./app_test.js";
const SUITES = [
@@ -68,6 +69,7 @@ const SUITES = [
ConfirmModalTest,
NewsModalTest,
GameScreenTest,
+ GameFeedTest,
AppTest,
];
diff --git a/tests/server_test.js b/tests/server_test.js
index acbaa5d..9595099 100644
--- a/tests/server_test.js
+++ b/tests/server_test.js
@@ -210,8 +210,13 @@ export class ServerProtocolTest extends TestCase {
guest.send({ t: "login", name: "Carol", password: "pw" });
const assigned = await guest.next("assigned");
this.assertFalse(assigned.admin);
+ const choose = await guest.next("choose_civ");
+ this.assertEqual(choose.civs.map((c) => c.index), [1], "the free civilisation is offered");
+ guest.send({ t: "claim_civ", civ: 1 });
+ const civAssigned = await guest.next("civ_assigned");
+ this.assertEqual(civAssigned.civ, 1);
const state = await guest.next("game_state");
- this.assertEqual(state.state.viewer, 1, "the second player is assigned the free civilisation");
+ this.assertEqual(state.state.viewer, 1, "the guest is assigned the free civilisation");
this.assertTrue(state.state.units.some((u) => u.civ === 1), "the guest has a unit");
host.close();