Visual improvements
This commit is contained in:
+32
-5
@@ -144,7 +144,6 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
background-repeat: no-repeat;
|
||||
background-size: 441px 56px;
|
||||
}
|
||||
.hex.territory { pointer-events: none; }
|
||||
.hex.fog { pointer-events: none; }
|
||||
.fog-unexplored { background: #04040a; }
|
||||
.fog-explored { background: rgba(4, 6, 18, 0.62); }
|
||||
@@ -192,6 +191,14 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
display: block;
|
||||
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9));
|
||||
}
|
||||
.unit .status-icon {
|
||||
width: calc(var(--unit-size, 38px) / 3);
|
||||
height: calc(var(--unit-size, 38px) / 3);
|
||||
}
|
||||
.city .status-icon {
|
||||
width: calc(var(--city-size, 26px) / 3);
|
||||
height: calc(var(--city-size, 26px) / 3);
|
||||
}
|
||||
.unit .body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -219,12 +226,13 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.unit .health .fill { height: 100%; background: #46d36a; }
|
||||
|
||||
.city {
|
||||
--city-size: 26px;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
.city .marker {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
width: var(--city-size);
|
||||
height: var(--city-size);
|
||||
margin: 0 auto;
|
||||
border-radius: 5px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.8);
|
||||
@@ -238,8 +246,8 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.city-label-layer { pointer-events: none; }
|
||||
.city-label {
|
||||
position: absolute;
|
||||
transform: translate(-50%, 0);
|
||||
margin-top: 15px;
|
||||
transform: translate(-50%, -100%);
|
||||
margin-top: -21px;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
@@ -247,6 +255,11 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
.city-label.capital::before {
|
||||
content: "\2605";
|
||||
color: #ffd54a;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.path-seg {
|
||||
position: absolute;
|
||||
@@ -376,6 +389,8 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.7);
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal-card-small { width: 420px; }
|
||||
.modal-card-small .row { padding-bottom: 18px; }
|
||||
.modal-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -436,6 +451,18 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.progress { height: 14px; margin-top: 8px; background: rgba(0, 0, 0, 0.5); border-radius: 7px; overflow: hidden; }
|
||||
#train-progress, #buildings-progress { height: 100%; width: 0; background: linear-gradient(180deg, #7fc4ff, #2f7fd0); }
|
||||
|
||||
.news-item {
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 9px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.news-item .news-text { font-weight: 600; }
|
||||
.news-item .news-date { color: var(--muted); font-size: 12px; margin-top: 3px; }
|
||||
.news-empty { color: var(--muted); padding: 8px 0; }
|
||||
#news-button { flex: 0 0 auto; }
|
||||
|
||||
.banner {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
|
||||
+33
-2
@@ -82,7 +82,6 @@
|
||||
<div id="map-world">
|
||||
<div class="layer" id="layer-overview"></div>
|
||||
<div class="layer" id="layer-terrain"></div>
|
||||
<div class="layer" id="layer-territory"></div>
|
||||
<div class="layer" id="layer-borders"></div>
|
||||
<div class="layer" id="layer-fog"></div>
|
||||
<div class="layer" id="layer-paths"></div>
|
||||
@@ -96,6 +95,7 @@
|
||||
<button id="civ-flag" class="flag-button" title="Government and technology">
|
||||
<img id="civ-flag-img" alt="" />
|
||||
</button>
|
||||
<button id="news-button" class="aero" title="News">News</button>
|
||||
<div class="top-text">
|
||||
<div id="civ-name" class="civ-name">—</div>
|
||||
<div id="player-name" class="player-name"></div>
|
||||
@@ -167,7 +167,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nation modal: government, technology and budget tabs -->
|
||||
<!-- Generic confirmation modal -->
|
||||
<div id="modal-confirm" class="modal hidden">
|
||||
<div class="modal-card modal-card-small">
|
||||
<div class="modal-head">
|
||||
<span id="confirm-title">Confirm</span>
|
||||
<button class="modal-close aero">✕</button>
|
||||
</div>
|
||||
<div id="confirm-message" class="modal-body"></div>
|
||||
<div class="row">
|
||||
<button id="confirm-cancel" class="aero">Cancel</button>
|
||||
<button id="confirm-ok" class="aero primary">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- News feed modal -->
|
||||
<div id="modal-news" class="modal hidden">
|
||||
<div class="modal-card">
|
||||
<div class="modal-head">
|
||||
<span id="news-title">News</span>
|
||||
<button class="modal-close aero">✕</button>
|
||||
</div>
|
||||
<div id="news-list" class="modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nation modal: government, technology, diplomacy and budget tabs -->
|
||||
<div id="modal-nation" class="modal hidden">
|
||||
<div class="modal-card">
|
||||
<div class="modal-head">
|
||||
@@ -177,6 +203,7 @@
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="government">Government</button>
|
||||
<button class="tab" data-tab="technology">Technology</button>
|
||||
<button class="tab" data-tab="diplomacy">Diplomacy</button>
|
||||
<button class="tab" data-tab="budget">Budget</button>
|
||||
</div>
|
||||
<div class="tab-page active" id="tab-government">
|
||||
@@ -187,6 +214,10 @@
|
||||
<div id="technology-sub" class="modal-sub"></div>
|
||||
<div id="technology-list" class="modal-body"></div>
|
||||
</div>
|
||||
<div class="tab-page" id="tab-diplomacy">
|
||||
<div id="diplomacy-sub" class="modal-sub"></div>
|
||||
<div id="diplomacy-list" class="modal-body"></div>
|
||||
</div>
|
||||
<div class="tab-page" id="tab-budget">
|
||||
<div id="budget-sub" class="modal-sub"></div>
|
||||
<div id="budget-list" class="modal-body"></div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+30
-42
@@ -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) {
|
||||
|
||||
+142
-3
@@ -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 = $("<div class='row-card'></div>");
|
||||
const $info = $("<div class='info'></div>");
|
||||
const $name = $("<div class='name'></div>").text(civs[index].name);
|
||||
const atWar = wars.has(index);
|
||||
const $status = $("<div class='desc'></div>").text(atWar ? "At war" : "At peace");
|
||||
$info.append($name, $status);
|
||||
const $actions = $("<div class='actions'></div>");
|
||||
const $action = $("<button class='aero'></button>");
|
||||
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($("<div class='news-empty'></div>").text("No news yet."));
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const $item = $("<div class='news-item'></div>");
|
||||
$item.append($("<div class='news-text'></div>").text(describeNews(entry, civs)));
|
||||
$item.append($("<div class='news-date'></div>").text(entry.date || ""));
|
||||
this.$list.append($item);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.$modal.addClass("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-1
@@ -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);
|
||||
|
||||
+103
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -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,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user