Visual improvements
This commit is contained in:
+88
-2
@@ -28,7 +28,7 @@ html, body {
|
||||
|
||||
/* ---------------------------------------------------------------- menus -- */
|
||||
|
||||
#screen-menu, #screen-new, #screen-join {
|
||||
#screen-menu, #screen-new, #screen-join, #screen-choose {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -36,7 +36,7 @@ html, body {
|
||||
radial-gradient(circle at 50% 20%, rgba(60, 90, 140, 0.35), transparent 60%),
|
||||
linear-gradient(180deg, #0b1220, #05070c);
|
||||
}
|
||||
#screen-menu.active, #screen-new.active, #screen-join.active { display: flex; }
|
||||
#screen-menu.active, #screen-new.active, #screen-join.active, #screen-choose.active { display: flex; }
|
||||
|
||||
.menu-card {
|
||||
width: 380px;
|
||||
@@ -104,6 +104,36 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.game-list li:hover { background: rgba(110, 175, 255, 0.15); }
|
||||
.game-list li.selected { background: rgba(110, 175, 255, 0.3); }
|
||||
|
||||
/* Flag + name pickers used on the new-game and join screens. */
|
||||
.civ-list {
|
||||
list-style: none;
|
||||
margin: 6px 0 14px;
|
||||
padding: 0;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(150, 190, 240, 0.15);
|
||||
}
|
||||
.civ-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.civ-list li:hover { background: rgba(110, 175, 255, 0.15); }
|
||||
.civ-list li.selected { background: rgba(110, 175, 255, 0.3); }
|
||||
.civ-list img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- world map -- */
|
||||
|
||||
#screen-game.active { display: block; }
|
||||
@@ -211,6 +241,21 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
justify-content: center;
|
||||
}
|
||||
.unit .body img { width: 78%; height: 78%; image-rendering: auto; }
|
||||
/* Owner's flag pinned to the top-left quarter of the unit. */
|
||||
.unit .unit-flag {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
object-fit: cover;
|
||||
border: 1px solid rgba(0, 0, 0, 0.65);
|
||||
border-radius: 6px 0 4px 0;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
display: block;
|
||||
}
|
||||
.unit.selected .body { box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.9), 0 2px 6px rgba(0,0,0,0.6); }
|
||||
.unit .health {
|
||||
position: absolute;
|
||||
@@ -350,6 +395,47 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
}
|
||||
.unit-stat:hover { color: var(--text); }
|
||||
|
||||
/* Chat-like feed of short-lived news in the lower-left corner. */
|
||||
.game-feed {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
bottom: 14px;
|
||||
width: 320px;
|
||||
max-width: calc(50vw - 30px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
pointer-events: none;
|
||||
z-index: 40;
|
||||
}
|
||||
.feed-line {
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
padding: 6px 10px;
|
||||
border-radius: 7px;
|
||||
background: rgba(8, 12, 20, 0.86);
|
||||
border: 1px solid rgba(150, 190, 240, 0.35);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||
animation: feed-in 0.18s ease;
|
||||
}
|
||||
.feed-line.leaving { opacity: 0; transform: translateY(8px); }
|
||||
.feed-line.feed-alert {
|
||||
background: rgba(80, 14, 14, 0.92);
|
||||
border-color: #ff6a6a;
|
||||
color: #ffd9d9;
|
||||
font-weight: 600;
|
||||
}
|
||||
@keyframes feed-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
z-index: 80;
|
||||
|
||||
+20
-4
@@ -25,19 +25,19 @@
|
||||
<h1>New game</h1>
|
||||
<label class="field">
|
||||
<span>Your civilisation</span>
|
||||
<select id="new-civ"></select>
|
||||
<ul id="new-civs" class="civ-list"></ul>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Nations in the world</span>
|
||||
<input id="new-count" type="number" min="1" max="9" value="4" />
|
||||
<input id="new-count" type="number" min="1" max="9" value="9" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Player name</span>
|
||||
<input id="new-name" type="text" autocomplete="off" />
|
||||
<input id="new-name" type="text" autocomplete="off" value="adrien" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<input id="new-password" type="password" />
|
||||
<input id="new-password" type="password" value="admin" />
|
||||
</label>
|
||||
<div class="row">
|
||||
<button id="new-back" class="aero">Back</button>
|
||||
@@ -76,6 +76,19 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Civilisation picker for players joining an existing game -->
|
||||
<section id="screen-choose" class="screen">
|
||||
<div class="menu-card wide">
|
||||
<h1>Choose your civilisation</h1>
|
||||
<p class="tagline">Pick one of the nations no one has claimed.</p>
|
||||
<ul id="choose-civs" class="civ-list"></ul>
|
||||
<div class="row">
|
||||
<button id="choose-back" class="aero">Back</button>
|
||||
</div>
|
||||
<p id="choose-status" class="status"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- World map -->
|
||||
<section id="screen-game" class="screen">
|
||||
<div id="map-viewport">
|
||||
@@ -140,6 +153,9 @@
|
||||
<div id="stat-tooltip" class="tooltip hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Chat-like feed of short-lived news, pinned to the lower left -->
|
||||
<div id="game-feed" class="game-feed"></div>
|
||||
|
||||
<!-- Training modal -->
|
||||
<div id="modal-train" class="modal hidden">
|
||||
<div class="modal-card">
|
||||
|
||||
+62
-4
@@ -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($("<option></option>").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 $("<li class='civ-option'></li>")
|
||||
.attr("data-civ-id", civ.id)
|
||||
.append($("<img alt=''>").attr("src", `assets/flags/${civ.flag}`))
|
||||
.append($("<span></span>").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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+52
-4
@@ -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) {
|
||||
|
||||
+7
-3
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+55
-3
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
+23
-5
@@ -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) {
|
||||
|
||||
+89
-15
@@ -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,
|
||||
|
||||
+54
-2
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user