Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f9f72aba7 | ||
|
|
c2f131da5c |
Binary file not shown.
|
After Width: | Height: | Size: 424 B |
Binary file not shown.
|
After Width: | Height: | Size: 428 B |
@@ -890,6 +890,34 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.city-production-label { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||
.city-production-bar { height: 8px; margin-top: 4px; }
|
||||
.city-production-bar > div { height: 100%; background: linear-gradient(180deg, #7fc4ff, #2f7fd0); }
|
||||
.site-material-label { font-size: 11px; color: var(--muted); margin-top: 4px; font-variant-numeric: tabular-nums; }
|
||||
.site-material-label.stalled { color: #ff8a80; }
|
||||
.site-warning { color: #ff5252; font-weight: 700; }
|
||||
.site-actions { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.site-actions .aero { font-size: 11px; padding: 3px 8px; }
|
||||
.gather-spinner {
|
||||
display: inline-block; width: 12px; height: 12px; margin-top: 6px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.25); border-top-color: #7fc4ff;
|
||||
border-radius: 50%; animation: gather-spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes gather-spin { to { transform: rotate(360deg); } }
|
||||
.construction-marker {
|
||||
position: absolute; width: 18px; height: 18px; margin: -9px 0 0 -9px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.construction-marker.stalled {
|
||||
color: #fff; background: #d32f2f; border-radius: 50%;
|
||||
font-weight: 700; font-size: 13px; box-shadow: 0 0 4px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.construction-marker .gather-spinner { margin-top: 0; }
|
||||
.construction-marker-bar {
|
||||
width: 16px; height: 6px; background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 3px; overflow: hidden;
|
||||
}
|
||||
.construction-marker-bar > div {
|
||||
height: 100%; background: linear-gradient(180deg, #7fc4ff, #2f7fd0);
|
||||
}
|
||||
.tile-coords { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; margin-bottom: 2px; }
|
||||
.tile-gdp-head { font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
.tile-gdp-toggle { cursor: pointer; }
|
||||
|
||||
+38
-38
@@ -2,63 +2,63 @@
|
||||
// The pre-commit hook refreshes it so the main menu shows the latest commits.
|
||||
export const DEVLOG = [
|
||||
{
|
||||
"hash": "0a2dbb2",
|
||||
"date": "2026-09-21",
|
||||
"subject": "Worked through the FIXME list",
|
||||
"body": "- The tile panel's City button now opens the city of a selected unit standing on it; double-clicking that unit opens the city too.\n- The stat tooltip moved out of #hud so the unit panel, a sibling with its own z-index, can no longer cover it.\n- The server creates its game at startup and clients only join it: the Create game button and its setup screen are gone, along with the configure_game wire path. Testing mode is a --testing server flag."
|
||||
"hash": "c2f131d",
|
||||
"date": "2026-09-22",
|
||||
"subject": "Turned tile improvements into construction sites with their own budgets",
|
||||
"body": "Every road, railway and tile work now rises as an economic construction site instead of a queue entry. A site gathers its materials over days from reachable regions, refusing any supplier quoting above the price it was budgeted at (a 1% tolerance); a day that buys nothing stalls the site, raising a red mark and letting the nation raise the budget to current prices or cancel it. The upfront budget covers the build cost plus the market value of its steel and high-tech, energy stays a grid draw, and any money left over stays with the finished work as a maintenance reserve.\n\nThat reserve now pays the work's own daily steel and high-tech wear before the treasury is billed, so a well funded work spares the state until it runs dry; the spent money is paid into the nation's regions. Building upgrades run through the same gather-build-maintain machinery, sharing the site's kind flag and target level. The legacy tile-work queue and the market building are gone.\n\nBundles the in-progress chart sizing for the central bank panel."
|
||||
},
|
||||
{
|
||||
"hash": "a97d2db",
|
||||
"date": "2026-09-21",
|
||||
"subject": "Added a devlog to the main menu",
|
||||
"body": "The Devlog button opens the ten latest commits, each showing its date and header and expanding to the full message on click. Since the client has no build step, scripts/generate-devlog.js writes the list into client/js/devlog_data.js, and the pre-commit hook refreshes and stages it so the shipped client stays current."
|
||||
"hash": "6f73e2c",
|
||||
"date": "2026-09-22",
|
||||
"subject": "Reworked the world around a fixed sea share and settled coasts",
|
||||
"body": "World generation now aims at a fixed share of the ice-free tiles (60%) instead of a minimum continent count, bisecting the water level for it rather than sweeping every step, and still joins the ocean into one body. Ice and tundra are the outermost rows only, scaled down on small maps so the fixtures keep usable land.\n\nCities score their site, coastal plains best and landlocked worst, avoiding tundra and desert. Every nation that holds a coast gets a port city on each island it owns, and any landmass left empty is settled by the nearest nation, so no land starts unowned. A coastal city opens with a port already built.\n\nBundles the in-progress work in the tree: the commodity monthly price series and inflation the central bank's chart reads, and the rolling city-to-city migration graph the population map draws as flow arrows."
|
||||
},
|
||||
{
|
||||
"hash": "ec1fbb9",
|
||||
"date": "2026-09-21",
|
||||
"subject": "Made the political country names visible",
|
||||
"body": "An outer SVG clips to its viewport, and a zero-sized one is not painted at all in some browsers, so the country-name layer's width: 0; height: 0 left every name laid out but unseen. Size the label SVG to the world and guard it with a test."
|
||||
"hash": "71dad18",
|
||||
"date": "2026-09-22",
|
||||
"subject": "Let central banks work their reserves and cleared the landlocked famines",
|
||||
"body": "Central banks now redeem the national money foreign banks hold, sell reserves to defend a weak rate, and cover part of strategic imports from reserves. Five repeatable technologies raise each commodity's output per unit of energy. Hostile troops on a road or sea lane sever trade, power and unit supply. Middlemen buy food to resell to neighbours at a markup, and starving regions buy before those topping up their buffers, which clears the landlocked famines. The economic map's trade graph gains a colour legend."
|
||||
},
|
||||
{
|
||||
"hash": "bc18598",
|
||||
"date": "2026-09-21",
|
||||
"subject": "Grew continents from seeded domes and gave every small island a nation",
|
||||
"hash": "3e429cc",
|
||||
"date": "2026-09-22",
|
||||
"subject": "Gave production buildings their own economy and fed island regions",
|
||||
"body": "Production buildings are now independent private-sector agents: they keep what their output sells for, run only to the demand they can actually sell, and spend that cash on steel and high-tech upgrades when their demand grows. Grid power is a paid private flow between plants and their consumers, and regions synthesise the food their own harvest and the reachable market cannot supply.\n\nIsland regions no longer famine: a region with no reachable supplier grows its whole shortfall from energy, and grid power now feeds cities and food before the converters, so a famine never waits on a factory.\n\nBundles the in-progress tree cleanup that was already present in the working tree."
|
||||
},
|
||||
{
|
||||
"hash": "238985b",
|
||||
"date": "2026-09-22",
|
||||
"subject": "Started a famine when a region's food runs out",
|
||||
"body": ""
|
||||
},
|
||||
{
|
||||
"hash": "75bdddd",
|
||||
"date": "2026-09-21",
|
||||
"subject": "Named the actors behind government news",
|
||||
"hash": "b6287d7",
|
||||
"date": "2026-09-22",
|
||||
"subject": "Replaced the GDP tax with national sales and tariff taxes",
|
||||
"body": ""
|
||||
},
|
||||
{
|
||||
"hash": "2005cac",
|
||||
"date": "2026-09-21",
|
||||
"subject": "Added Texas and Alabama",
|
||||
"body": ""
|
||||
"hash": "91dfedf",
|
||||
"date": "2026-09-22",
|
||||
"subject": "Gave regions their own economies and removed the global market",
|
||||
"body": "- Each nation has a currency and each region a private-sector cash basket, borrowing from its central bank at the bank's rate; the government may always pay, going into central-bank debt when its budget runs red.\n- Resources are bought from reachable stores only: the region itself first, then same-nation regions, then foreign ones, paid for out of private cash or, for government orders, the treasury. Drawn national stores are paid to their owning region.\n- A grid in deficit imports power from a reachable surplus grid instead of a market fallback; downgrading a building no longer refunds anything.\n- The monthly budget lists each resource's economic value, expands to a resource-to-source tree and keeps a cash figure beside it.\n- Regions report what their people went short of over the day; starved research buildings and power-starved works raise a private daily notice.\n- The city panel gains a Resources tab: reserves per resource, their market value and the private sector's daily consumption."
|
||||
},
|
||||
{
|
||||
"hash": "f2b52fc",
|
||||
"date": "2026-09-21",
|
||||
"subject": "Added sound and credits, bombardment audio, and hid foreign production",
|
||||
"body": ""
|
||||
"hash": "e7ee5db",
|
||||
"date": "2026-09-22",
|
||||
"subject": "Added the resource economy, its works and their icons",
|
||||
"body": "- Five resources (energy, steel, food, luxury, high-tech) with per-city stores, a world market, delivery and material upkeep.\n- Resource-producing tile works built outside cities; every nation opens with the works its economy needs.\n- Real game-icons.net art replacing the dummy icons for the new resources and works, with the credits updated."
|
||||
},
|
||||
{
|
||||
"hash": "a33dfe8",
|
||||
"hash": "9690c68",
|
||||
"date": "2026-09-21",
|
||||
"subject": "Chose one of two main-menu backgrounds at launch",
|
||||
"body": ""
|
||||
"subject": "Fixed air-strike range and made war-crime popularity a one-off",
|
||||
"body": "- An aircraft now returns to the nearest friendly airport instead of always its original home, so a plane rebased to a forward airfield can strike targets beside it; the browser mirrors the rule.\n- A pillage's popularity cost is an instant shift that drifts back, not a generation-long grudge. Popularity and approval read the base opinion, while the grudge stays as the diplomatic memory.\n- Doubled aircraft range: the fighter's effective reach is now 25 tiles and the bomber's 50, with missionRange raised to match."
|
||||
},
|
||||
{
|
||||
"hash": "a6b49a5",
|
||||
"hash": "f3e94a3",
|
||||
"date": "2026-09-21",
|
||||
"subject": "Tracked automatic refocus for repeatable technologies",
|
||||
"body": ""
|
||||
},
|
||||
{
|
||||
"hash": "36e1e40",
|
||||
"date": "2026-09-21",
|
||||
"subject": "Document the reconstructed feature roadmap",
|
||||
"body": ""
|
||||
"subject": "Added the devlog, fixed the map labels and reworked joining",
|
||||
"body": "- Political map country names were invisible because the label SVG had a zero-sized viewport, which some browsers never paint; size it to the world and guard it with a test.\n- Added a Devlog button showing the ten latest commits, generated into client/js/devlog_data.js by scripts/generate-devlog.js and refreshed by the pre-commit hook.\n- Worked through FIXME.md: the city button and a unit double-click now open the city, and the stat tooltip escapes the HUD so the unit panel cannot cover it.\n- The server creates its game at startup and clients only join it; the Create game screen and the configure_game path are gone, and testing is a --testing server flag.\n- A page on a loopback host signs in as adrien, claims France and gets the free, instant testing buttons, while the server grants free orders only to a loopback socket whose Origin is also loopback."
|
||||
}
|
||||
];
|
||||
|
||||
+108
-21
@@ -966,36 +966,87 @@ export const panelMethods = {
|
||||
this._renderImprovementActions(coords, buildable, current);
|
||||
},
|
||||
|
||||
// The military improvement under construction on this tile, drawn with the
|
||||
// same progress bar as a city's production.
|
||||
// The construction site on this tile. A site gathering materials shows a
|
||||
// spinner and a bought/needed bar per material; once everything is bought it
|
||||
// builds with the same progress bar as a city's production. A stalled site
|
||||
// warns and offers to raise its budget or cancel the work.
|
||||
_renderTileWorks(coords) {
|
||||
const $el = $("#tile-works");
|
||||
if (!$el.length) return;
|
||||
$el.empty();
|
||||
const queue = (this.snapshot.tileWorks || [])[this.localCiv] || [];
|
||||
const work = queue.find(
|
||||
const sites = (this.snapshot.constructionSites || [])[this.localCiv] || [];
|
||||
const site = sites.find(
|
||||
(entry) => entry.coords[0] === coords.x && entry.coords[1] === coords.y
|
||||
);
|
||||
if (work) {
|
||||
const proto = tileImprovementById(work.id);
|
||||
const name = proto ? proto.name : work.id;
|
||||
const total = Math.max(work.totalHours || 0, 0.0001);
|
||||
const elapsed = work.elapsedHours || 0;
|
||||
const pct = Math.max(0, Math.min(100, (elapsed / total) * 100));
|
||||
const remaining = Math.max(0, Math.ceil(total - elapsed));
|
||||
const $bar = $("<div class='progress city-production-bar'></div>");
|
||||
$bar.append($("<div></div>").css("width", `${pct}%`));
|
||||
$el.append(
|
||||
$("<div class='city-production-label'></div>").text(`Building ${name} — ${remaining} h left`),
|
||||
$bar
|
||||
);
|
||||
if (site) {
|
||||
const proto = tileImprovementById(site.id);
|
||||
const name = proto ? proto.name : site.id;
|
||||
const gathering = site.kind === "upgrade"
|
||||
? `Gathering materials to upgrade ${name}`
|
||||
: `Gathering materials for ${name}`;
|
||||
const building = site.kind === "upgrade"
|
||||
? `Upgrading ${name} to level ${site.targetLevel} — `
|
||||
: `Building ${name} — `;
|
||||
if (site.phase === "materials") {
|
||||
const $label = $("<div class='city-production-label'></div>").text(gathering);
|
||||
if (site.stalled) {
|
||||
$label.prepend($("<span class='site-warning' title='No purchase at the locked price'>!</span> "));
|
||||
}
|
||||
$el.append($label);
|
||||
for (const id of ["steel", "hightech"]) {
|
||||
const needed = site.needed[id] || 0;
|
||||
if (!(needed > 0)) continue;
|
||||
const bought = Math.min(site.bought[id] || 0, needed);
|
||||
const pct = Math.max(0, Math.min(100, (bought / needed) * 100));
|
||||
const $bar = $("<div class='progress city-production-bar site-gather'></div>");
|
||||
$bar.append($("<div></div>").css("width", `${pct}%`));
|
||||
$el.append(
|
||||
$("<div class='site-material-label'></div>").text(
|
||||
`${resourceById(id).name} — ${Math.round(bought)}/${Math.round(needed)}`
|
||||
),
|
||||
$bar
|
||||
);
|
||||
}
|
||||
if (!site.stalled) {
|
||||
$el.append($("<span class='gather-spinner' title='Gathering materials'></span>"));
|
||||
$el.append(
|
||||
$("<div class='site-material-label'></div>").text(
|
||||
`Budget ${currencySymbol()}${compact(site.budget || 0)}`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
$el.append(
|
||||
$("<div class='site-material-label stalled'></div>").text(
|
||||
"The market moved above the locked prices."
|
||||
)
|
||||
);
|
||||
const $raise = $("<button type='button' class='aero'></button>")
|
||||
.text("Increase budget")
|
||||
.on("click", () => this._increaseSiteBudget(coords));
|
||||
const $cancel = $("<button type='button' class='aero'></button>")
|
||||
.text("Cancel work")
|
||||
.on("click", () => this._cancelSite(coords));
|
||||
$el.append($("<div class='site-actions'></div>").append($raise, " ", $cancel));
|
||||
}
|
||||
} else {
|
||||
const total = Math.max(site.totalHours || 0, 0.0001);
|
||||
const elapsed = site.elapsedHours || 0;
|
||||
const pct = Math.max(0, Math.min(100, (elapsed / total) * 100));
|
||||
const remaining = Math.max(0, Math.ceil(total - elapsed));
|
||||
const $bar = $("<div class='progress city-production-bar'></div>");
|
||||
$bar.append($("<div></div>").css("width", `${pct}%`));
|
||||
$el.append(
|
||||
$("<div class='city-production-label'></div>").text(`${building}${remaining} h left`),
|
||||
$bar
|
||||
);
|
||||
}
|
||||
}
|
||||
// A production building upgrades itself: show the work its agent has begun.
|
||||
const stats = this.snapshot.viewerStats || {};
|
||||
const building = (stats.productionBuildings || []).find(
|
||||
(entry) => entry.coords[0] === coords.x && entry.coords[1] === coords.y
|
||||
);
|
||||
if (building && building.upgrading) {
|
||||
if (!site && building && building.upgrading) {
|
||||
$el.append(
|
||||
$("<div class='city-production-label'></div>").text(
|
||||
`Upgrading ${building.name} to level ${(building.level || 0) + 1}` +
|
||||
@@ -1003,6 +1054,16 @@ export const panelMethods = {
|
||||
)
|
||||
);
|
||||
}
|
||||
// A finished work may still hold a maintenance reserve that pays its own
|
||||
// upkeep until it runs dry.
|
||||
const works = this.map.tileImprovements.get(`${coords.x},${coords.y}`);
|
||||
if (!site && works && works.cash > 0) {
|
||||
$el.append(
|
||||
$("<div class='city-production-label'></div>").text(
|
||||
`Maintenance reserve ${currencySymbol()}${compact(works.cash)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// The city panel's quick view of what is currently being produced there, so
|
||||
@@ -1134,6 +1195,11 @@ export const panelMethods = {
|
||||
const standing = this.map.tileImprovements.get(`${coords.x},${coords.y}`) || null;
|
||||
const standingProto = standing ? tileImprovementById(standing.id) : null;
|
||||
const isResource = !!(standingProto && standingProto.resource);
|
||||
// A work already under way on this tile blocks another order; the player may
|
||||
// still cancel it from here.
|
||||
const pending = ((this.snapshot.constructionSites || [])[this.localCiv] || []).some(
|
||||
(site) => site.coords[0] === coords.x && site.coords[1] === coords.y
|
||||
);
|
||||
|
||||
// Military tile works. They cost resources and take production time, so the
|
||||
// tooltip names the expected hours alongside the upkeep.
|
||||
@@ -1152,13 +1218,20 @@ export const panelMethods = {
|
||||
const $button = this._improvementButton(improvement, {
|
||||
className: "tile-btn",
|
||||
title,
|
||||
disabled: !!standing || !affordable,
|
||||
disabled: !!standing || !!pending || !affordable,
|
||||
current: built,
|
||||
prices,
|
||||
});
|
||||
$button.on("click", () => this._buildTileImprovement(coords, improvement.id));
|
||||
military.push($button);
|
||||
}
|
||||
if (pending) {
|
||||
military.push(
|
||||
$("<button type='button' class='aero improvement-btn remove'></button>")
|
||||
.text("Cancel work")
|
||||
.on("click", () => this._cancelSite(coords))
|
||||
);
|
||||
}
|
||||
|
||||
// Transportation: roads and railways, bought outright and laid at once.
|
||||
const transport = [];
|
||||
@@ -1174,7 +1247,7 @@ export const panelMethods = {
|
||||
const $button = this._improvementButton(improvement, {
|
||||
className: "transport-btn",
|
||||
title,
|
||||
disabled: isCurrent || !affordable,
|
||||
disabled: isCurrent || !!pending || !affordable,
|
||||
current: isCurrent,
|
||||
prices,
|
||||
});
|
||||
@@ -1232,7 +1305,7 @@ export const panelMethods = {
|
||||
const $button = this._improvementButton(producer, {
|
||||
className: "tile-btn resource-btn",
|
||||
title,
|
||||
disabled: !!standing || !affordable || locked,
|
||||
disabled: !!standing || !!pending || !affordable || locked,
|
||||
current: built,
|
||||
prices,
|
||||
});
|
||||
@@ -1307,6 +1380,20 @@ export const panelMethods = {
|
||||
});
|
||||
},
|
||||
|
||||
_increaseSiteBudget(coords) {
|
||||
this.sendOrder({
|
||||
type: "increase_site_budget",
|
||||
coords: [coords.x, coords.y],
|
||||
});
|
||||
},
|
||||
|
||||
_cancelSite(coords) {
|
||||
this.sendOrder({
|
||||
type: "cancel_site",
|
||||
coords: [coords.x, coords.y],
|
||||
});
|
||||
},
|
||||
|
||||
// A short, readable label for the tile: its owner's demonym, the terrain and
|
||||
// where it sits relative to the city whose region claims it.
|
||||
_tileDescription(coords, owner, tile, regionCity) {
|
||||
|
||||
@@ -28,6 +28,7 @@ export const entityMethods = {
|
||||
this.ensureTerrain(state.seed, state.mapConfig || null);
|
||||
this.setImprovements(state.roads || [], state.railways || [], state.versions && state.versions.improvements);
|
||||
this.setWarfare(state.trenches || [], state.tileImprovements || [], state.versions && state.versions.warfare);
|
||||
this.setConstructionSites(state.constructionSites || []);
|
||||
bootLog.step("Mirrored the transport network");
|
||||
this.localCiv = state.viewer;
|
||||
this._applyTerritory(state.territory, state.versions && state.versions.territory);
|
||||
@@ -89,8 +90,10 @@ export const entityMethods = {
|
||||
this._warfareSignature = signature;
|
||||
this.trenches = new Set(trenches.map((entry) => key(entry[0], entry[1])));
|
||||
this.tileImprovements = new Map();
|
||||
for (const [x, y, id, owner, hp, level] of improvements) {
|
||||
this.tileImprovements.set(key(x, y), { id, owner, hp, level: level || 0 });
|
||||
for (const [x, y, id, owner, hp, level, cash] of improvements) {
|
||||
this.tileImprovements.set(key(x, y), {
|
||||
id, owner, hp, level: level || 0, cash: cash || 0,
|
||||
});
|
||||
}
|
||||
this._renderWarfare();
|
||||
},
|
||||
@@ -106,6 +109,57 @@ export const entityMethods = {
|
||||
}
|
||||
},
|
||||
|
||||
// Mirrors the construction sites from the snapshot and draws one marker per
|
||||
// tile: a spinner while materials are gathered, a red mark when the market
|
||||
// stalled the work, and a progress bar while it builds.
|
||||
setConstructionSites(sites) {
|
||||
const flat = [];
|
||||
for (const list of sites) for (const site of list || []) flat.push(site);
|
||||
const signature = flat
|
||||
.map((site) => `${site.coords[0]},${site.coords[1]}:${site.phase}:${site.stalled ? 1 : 0}:${Math.round(site.elapsedHours || 0)}`)
|
||||
.join("|");
|
||||
if (signature === this._siteSignature) return;
|
||||
this._siteSignature = signature;
|
||||
this._renderConstructionSites(flat);
|
||||
},
|
||||
|
||||
_renderConstructionSites(sites) {
|
||||
const container = this.$entities && this.$entities[0];
|
||||
if (!container) return;
|
||||
if (!this._siteViews) this._siteViews = new Map();
|
||||
for (const view of this._siteViews.values()) view.remove();
|
||||
this._siteViews.clear();
|
||||
for (const site of sites) {
|
||||
const [x, y] = site.coords;
|
||||
const local = mapToLocal(x, y);
|
||||
const el = document.createElement("div");
|
||||
el.className = "construction-marker";
|
||||
if (site.phase === "materials") {
|
||||
if (site.stalled) {
|
||||
el.classList.add("stalled");
|
||||
el.textContent = "!";
|
||||
} else {
|
||||
const spin = document.createElement("div");
|
||||
spin.className = "gather-spinner";
|
||||
el.appendChild(spin);
|
||||
}
|
||||
} else {
|
||||
const total = Math.max(site.totalHours || 0, 0.0001);
|
||||
const pct = Math.max(0, Math.min(100, ((site.elapsedHours || 0) / total) * 100));
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "construction-marker-bar";
|
||||
const fill = document.createElement("div");
|
||||
fill.style.width = `${pct}%`;
|
||||
bar.appendChild(fill);
|
||||
el.appendChild(bar);
|
||||
}
|
||||
el.style.left = `${local.x + this._wrapShift(local.x)}px`;
|
||||
el.style.top = `${local.y * CAMERA_TILT}px`;
|
||||
container.appendChild(el);
|
||||
this._siteViews.set(`${x},${y}`, el);
|
||||
}
|
||||
},
|
||||
|
||||
_addWarfareMarker(container, k, kind, info) {
|
||||
const coords = parseKey(k);
|
||||
const local = mapToLocal(coords.x, coords.y);
|
||||
|
||||
@@ -772,9 +772,21 @@ export class NationModal {
|
||||
)
|
||||
);
|
||||
if (series.length === 0) return;
|
||||
const chart = lineChart(series, {
|
||||
const options = {
|
||||
xLabel: (i) => `${MONTH_NAMES[i % 12].slice(0, 3)} ${2000 + Math.floor(i / 12)}`,
|
||||
});
|
||||
};
|
||||
// The panel scrolls, but the chart must not swallow it: size it to the
|
||||
// panel's width and cap it at half the visible height, so the inflation
|
||||
// table below stays a short scroll away. A hidden panel measures zero, so
|
||||
// leave the defaults there and let the next refresh size it properly.
|
||||
const $panel = $container.closest(".modal-body");
|
||||
const width = $panel.length ? $panel.width() : 0;
|
||||
const height = $panel.length ? $panel.height() : 0;
|
||||
if (width > 0) {
|
||||
options.width = width;
|
||||
options.height = Math.min(width * 0.45, height > 0 ? height / 2 : width * 0.45);
|
||||
}
|
||||
const chart = lineChart(series, options);
|
||||
$container.append(chart);
|
||||
const percent = (value) =>
|
||||
`${value >= 0 ? "+" : "−"}${(Math.abs(value) * 100).toFixed(2)}%`;
|
||||
|
||||
@@ -196,6 +196,10 @@ export class GameServer extends EventEmitter {
|
||||
return this._handleBuildTileImprovement(civ, order);
|
||||
case "remove_tile_improvement":
|
||||
return this._handleRemoveTileImprovement(civ, order);
|
||||
case "increase_site_budget":
|
||||
return this._handleIncreaseSiteBudget(civ, order);
|
||||
case "cancel_site":
|
||||
return this._handleCancelSite(civ, order);
|
||||
case "embark":
|
||||
return this._handleEmbark(civ, order);
|
||||
case "disembark":
|
||||
@@ -349,6 +353,14 @@ export class GameServer extends EventEmitter {
|
||||
return this.state.requestRemoveTileImprovement(civ, coordsOf(order.coords));
|
||||
}
|
||||
|
||||
_handleIncreaseSiteBudget(civ, order) {
|
||||
return this.state.requestIncreaseSiteBudget(civ, coordsOf(order.coords));
|
||||
}
|
||||
|
||||
_handleCancelSite(civ, order) {
|
||||
return this.state.requestCancelSite(civ, coordsOf(order.coords));
|
||||
}
|
||||
|
||||
_handleEmbark(civ, order) {
|
||||
const units = this._ownUnitIds(civ, order);
|
||||
if (units.length === 0) return false;
|
||||
|
||||
@@ -22,16 +22,6 @@ export const BUILDING_MECHANIC = {
|
||||
};
|
||||
|
||||
export const BUILDINGS = [
|
||||
{
|
||||
id: "market",
|
||||
name: "Market",
|
||||
description: "A hub of local trade. Raises the production of the city's region, with diminishing returns.",
|
||||
baseCost: 500_000,
|
||||
costAlpha: 1.2,
|
||||
refundFraction: 0.5,
|
||||
effects: [effect(EFFECT_PRODUCTION, 0.04, 0.75)],
|
||||
icon: "icon_market.svg",
|
||||
},
|
||||
{
|
||||
id: "barracks",
|
||||
name: "Barracks",
|
||||
|
||||
@@ -10,7 +10,7 @@ export const CIVILISATIONS = [
|
||||
primaryColour: "#0d0d5c",
|
||||
accentColour: "#8c8c82",
|
||||
flag: "flag_france.png",
|
||||
cityNames: ["Lyon", "Marseille", "Toulouse", "Nice", "Nantes", "Montpellier", "Strasbourg", "Bordeaux", "Lille"],
|
||||
cityNames: ["Paris", "Lyon", "Marseille", "Toulouse", "Nice", "Nantes", "Montpellier", "Strasbourg", "Bordeaux", "Lille"],
|
||||
},
|
||||
{
|
||||
id: "britain",
|
||||
@@ -20,7 +20,27 @@ export const CIVILISATIONS = [
|
||||
primaryColour: "#a81818",
|
||||
accentColour: "#0722ff",
|
||||
flag: "flag_britain.png",
|
||||
cityNames: ["Birmingham", "Manchester", "Glasgow", "Liverpool", "Leeds", "Sheffield", "Edinburgh", "Bristol", "Cardiff"],
|
||||
cityNames: ["London", "Birmingham", "Manchester", "Glasgow", "Liverpool", "Leeds", "Sheffield", "Edinburgh", "Bristol", "Cardiff"],
|
||||
},
|
||||
{
|
||||
id: "bulgaria",
|
||||
name: "Bulgaria",
|
||||
demonym: "Bulgarian",
|
||||
currency: { code: "BGN", symbol: "лв.", name: "Bulgarian Lev" },
|
||||
primaryColour: "#00966e",
|
||||
accentColour: "#d62612",
|
||||
flag: "flag_bulgaria.png",
|
||||
cityNames: ["Sofia", "Plovdiv", "Varna", "Stara Zagora", "Balchik", "Veliko Tarnovo", "Burgas", "Pleven"],
|
||||
},
|
||||
{
|
||||
id: "northumbria",
|
||||
name: "Northumbria",
|
||||
demonym: "Northumbrian",
|
||||
currency: { code: "NHS", symbol: "sc.", name: "Northumbrian Sceat"},
|
||||
primaryColour: "#5ec6e7",
|
||||
accentColour: "#13233c",
|
||||
flag: "flag_northumberland.png",
|
||||
cityNames: ["Newcastle upon Tyne", "Washington", "New York", "Gateshead", "Morpeth", "Ashington", "Blyth", "Newsham", "Blaydon", "Rothbury", "Longbenton", "Snitter", "Alnwick", "Consettm Tynemouth", "Whickham"]
|
||||
},
|
||||
{
|
||||
id: "slovenia",
|
||||
|
||||
+10
-2
@@ -47,6 +47,7 @@ import { serializationMethods } from "./game_state/serialization.js";
|
||||
import { monthlyMethods } from "./game_state/monthly.js";
|
||||
import { warfareMethods } from "./game_state/warfare.js";
|
||||
import { resourceMethods } from "./game_state/resources.js";
|
||||
import { siteMethods } from "./game_state/sites.js";
|
||||
import { industryMethods } from "./game_state/industry.js";
|
||||
import { budgetMethods } from "./game_state/budget.js";
|
||||
import { moneyMethods } from "./game_state/money.js";
|
||||
@@ -118,7 +119,13 @@ export class GameState {
|
||||
this.tileImprovements = new Map();
|
||||
this.tileImprovementOwner = new Map();
|
||||
this.tileImprovementHp = new Map();
|
||||
this.tileWorks = new Map();
|
||||
// Tile improvements under construction, as economic nodes keyed by tile: the
|
||||
// site gathers materials and builds before the improvement stands. Each
|
||||
// holds its own budget and the prices locked when the work was decided.
|
||||
this.constructionSites = new Map();
|
||||
// Money left over from a finished work, held on its tile as the
|
||||
// improvement's own cash.
|
||||
this.improvementCash = new Map();
|
||||
// Production buildings as independent private-sector agents, keyed by tile:
|
||||
// each keeps its own cash, upgrade level and sales history.
|
||||
this.buildingAgents = new Map();
|
||||
@@ -387,7 +394,7 @@ export class GameState {
|
||||
this._resolveCombatRound();
|
||||
}
|
||||
this._tickBuildings();
|
||||
this._tickTileWorks();
|
||||
this._tickConstructionSites();
|
||||
this._tickIndustry();
|
||||
this._tickEmbark();
|
||||
this._tickCoastalCannons();
|
||||
@@ -418,6 +425,7 @@ Object.assign(
|
||||
monthlyMethods,
|
||||
warfareMethods,
|
||||
resourceMethods,
|
||||
siteMethods,
|
||||
industryMethods,
|
||||
budgetMethods,
|
||||
moneyMethods,
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
// only have one upgrade under way at a time.
|
||||
|
||||
import { key, parseKey } from "../hex.js";
|
||||
import { ECONOMY } from "../data/economy.js";
|
||||
import { RESOURCE_RULES } from "../data/resources.js";
|
||||
import {
|
||||
RESOURCE_BUILDING_UPGRADE,
|
||||
@@ -180,6 +179,9 @@ export const industryMethods = {
|
||||
const proto = id ? tileImprovementById(id) : null;
|
||||
if (!this._isProductionAgent(proto)) continue;
|
||||
const coords = parseKey(k);
|
||||
const site = this.constructionSites ? this.constructionSites.get(k) : null;
|
||||
const upgradingSite = site && site.kind === "upgrade" ? site : null;
|
||||
const job = upgradingSite || agent.upgrading;
|
||||
result.push({
|
||||
coords: [coords.x, coords.y],
|
||||
name: proto.name,
|
||||
@@ -187,9 +189,9 @@ export const industryMethods = {
|
||||
level: agent.level,
|
||||
cash: agent.cash || 0,
|
||||
outputPerDay: resourceBuildingOutputAt(proto, agent.level),
|
||||
upgrading: !!agent.upgrading,
|
||||
upgradeDaysLeft: agent.upgrading
|
||||
? Math.max(0, Math.ceil((agent.upgrading.totalHours - agent.upgrading.elapsedHours) / HOURS_PER_DAY))
|
||||
upgrading: !!job,
|
||||
upgradeDaysLeft: job
|
||||
? Math.max(0, Math.ceil((job.totalHours - job.elapsedHours) / HOURS_PER_DAY))
|
||||
: 0,
|
||||
});
|
||||
}
|
||||
@@ -241,17 +243,11 @@ export const industryMethods = {
|
||||
|
||||
// ------------------------------------------------------- daily tick --
|
||||
|
||||
// One hour of every agent's upgrade, and once a day the demand read that may
|
||||
// start a new one.
|
||||
// One hour of industry, and once a day the demand read that may start a new
|
||||
// upgrade. The upgrade itself is a construction site: it gathers materials,
|
||||
// builds and becomes the next level through the shared site tick.
|
||||
_tickIndustry() {
|
||||
if (!this.buildingAgents || this.buildingAgents.size === 0) return;
|
||||
for (const [k, agent] of this.buildingAgents) {
|
||||
if (!agent.upgrading) continue;
|
||||
agent.upgrading.elapsedHours += 1;
|
||||
if (agent.upgrading.elapsedHours >= agent.upgrading.totalHours) {
|
||||
this._completeBuildingUpgrade(k, agent);
|
||||
}
|
||||
}
|
||||
if (this.totalHours % HOURS_PER_DAY === 0) this._tickBuildingDecisions();
|
||||
},
|
||||
|
||||
@@ -263,7 +259,7 @@ export const industryMethods = {
|
||||
agent.salesToday = 0;
|
||||
}
|
||||
for (const [k, agent] of this.buildingAgents) {
|
||||
if (agent.upgrading) continue;
|
||||
if (this.constructionSites && this.constructionSites.has(k)) continue;
|
||||
if (agent.level >= RESOURCE_BUILDING_UPGRADE.maxLevel) continue;
|
||||
const id = this.tileImprovements.get(k);
|
||||
const proto = id ? tileImprovementById(id) : null;
|
||||
@@ -292,12 +288,13 @@ export const industryMethods = {
|
||||
now >= before;
|
||||
},
|
||||
|
||||
// Commit an agent to its next level: buy the materials from its own stores and
|
||||
// the reachable market with its own cash, then start the work. Only one
|
||||
// upgrade may run at a time.
|
||||
// Commit an agent to its next level. It hands its cash to a construction
|
||||
// site, which gathers the materials, builds, and raises the level on
|
||||
// completion like any other work. Only one upgrade may run at a time.
|
||||
_startBuildingUpgrade(k, proto, agent) {
|
||||
const owner = this.tileImprovementOwner.get(k);
|
||||
if (owner === undefined || owner < 0) return false;
|
||||
if (this.constructionSites && this.constructionSites.has(k)) return false;
|
||||
const level = agent.level;
|
||||
const materials = this._upgradeMaterialCost(proto, level);
|
||||
const quoted = this._upgradeMaterialValue(owner, materials) *
|
||||
@@ -309,28 +306,25 @@ export const industryMethods = {
|
||||
this._buildingMaterialAvailability(owner, coords, "hightech") < materials.hightech) {
|
||||
return false;
|
||||
}
|
||||
if (!this._buyBuildingMaterials(k, coords, owner, materials)) return false;
|
||||
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(owner));
|
||||
const money = resourceBuildingUpgradeMoneyCost(proto, level);
|
||||
const totalHours = capacity > 0
|
||||
? Math.max(1, (money / capacity) * this.constructionSpeedMultiplier(owner))
|
||||
: 1;
|
||||
agent.upgrading = { elapsedHours: 0, totalHours, targetLevel: level + 1 };
|
||||
const budget = Math.max(0, agent.cash || 0);
|
||||
agent.cash = 0;
|
||||
const site = this._openConstructionSite({
|
||||
civ: owner,
|
||||
coords,
|
||||
proto,
|
||||
kind: "upgrade",
|
||||
targetLevel: level + 1,
|
||||
money: budget,
|
||||
buildCost: money,
|
||||
level,
|
||||
});
|
||||
this._kickoffSite(site);
|
||||
this._tileImprovementVersion += 1;
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
_completeBuildingUpgrade(k, agent) {
|
||||
agent.level = agent.upgrading ? agent.upgrading.targetLevel : agent.level + 1;
|
||||
agent.upgrading = null;
|
||||
// The extra output lifts the region's GDP, so the cached production is stale.
|
||||
this._gdpPerCapitaCache.clear();
|
||||
this._touchModifiers();
|
||||
this._tileImprovementVersion += 1;
|
||||
this._emitChanged();
|
||||
},
|
||||
|
||||
// The amount of `id` an agent could lay hands on for an upgrade: its own
|
||||
// store for free, the nation's city stores, and the nearby producers' surplus.
|
||||
_buildingMaterialAvailability(civ, coords, id) {
|
||||
|
||||
+27
-16
@@ -7,7 +7,6 @@ import { GOVERNMENTS } from "../data/governments.js";
|
||||
import { PILLAGE, pillageSteps } from "../data/combat.js";
|
||||
import { transportImprovement } from "../data/improvements.js";
|
||||
import { buildingBuildCost } from "../rules.js";
|
||||
import { tileImprovementResourceCost } from "../resources.js";
|
||||
import { TRAINING_QUEUE_LIMIT } from "./constants.js";
|
||||
|
||||
export const orderMethods = {
|
||||
@@ -137,9 +136,11 @@ export const orderMethods = {
|
||||
|
||||
// Lays a road or railway on a land tile the civilisation controls. A tile
|
||||
// holds at most one transport improvement, so building one replaces whatever
|
||||
// is already there; the full price is still paid. The cost is charged up front
|
||||
// and the tile changes at once, and afterwards the nation pays an hourly
|
||||
// maintenance for every road and railway tile it holds.
|
||||
// is already there. The state opens a construction site with a forecast
|
||||
// budget; the tile's economic node gathers the materials and builds before the
|
||||
// road or railway appears, and afterwards the nation pays an hourly
|
||||
// maintenance for every road and railway tile it holds. The `free` shortcut
|
||||
// used by testing mode raises it at once, without cost.
|
||||
requestBuildImprovement(civ, coords, id, free = false) {
|
||||
if (civ < 0 || civ >= this.civilisations.length) return false;
|
||||
const improvement = transportImprovement(id);
|
||||
@@ -151,19 +152,27 @@ export const orderMethods = {
|
||||
if (this.civAt(coords) !== civ) return false;
|
||||
const current = this.railways.has(k) ? "railway" : this.roads.has(k) ? "road" : null;
|
||||
if (current === improvement.id) return false;
|
||||
if (!free) {
|
||||
if (!this._spendBudget(civ, improvement.buildCost, "tilebuild")) return false;
|
||||
const materials = tileImprovementResourceCost(improvement);
|
||||
if (!this._payConstructionResources(civ, coords, materials, "tilebuild", improvement.name)) {
|
||||
this._refundBudget(civ, improvement.buildCost, "tilebuild");
|
||||
return false;
|
||||
}
|
||||
if (this.constructionSiteAt(coords)) return false;
|
||||
if (free) {
|
||||
this.roads.delete(k);
|
||||
this.railways.delete(k);
|
||||
if (improvement.id === "railway") this.railways.add(k);
|
||||
else this.roads.add(k);
|
||||
this._improvementVersion += 1;
|
||||
this._emitChanged();
|
||||
return true;
|
||||
}
|
||||
this.roads.delete(k);
|
||||
this.railways.delete(k);
|
||||
if (improvement.id === "railway") this.railways.add(k);
|
||||
else this.roads.add(k);
|
||||
this._improvementVersion += 1;
|
||||
const money = this._siteForecastBudget(civ, improvement, improvement.buildCost);
|
||||
this._reserveSiteBudget(civ, money);
|
||||
const site = this._openConstructionSite({
|
||||
civ,
|
||||
coords,
|
||||
proto: improvement,
|
||||
kind: "build",
|
||||
money,
|
||||
buildCost: improvement.buildCost,
|
||||
});
|
||||
this._kickoffSite(site);
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
@@ -179,10 +188,12 @@ export const orderMethods = {
|
||||
const tile = this.tiles[k];
|
||||
if (!tile || tile.terrainClass !== "Land") return false;
|
||||
if (this.civAt(coords) !== civ) return false;
|
||||
if (this.constructionSiteAt(coords)) return this.requestCancelSite(civ, coords);
|
||||
const present = this.railways.has(k) || this.roads.has(k);
|
||||
if (!present) return false;
|
||||
this.roads.delete(k);
|
||||
this.railways.delete(k);
|
||||
this.improvementCash.delete(k);
|
||||
this._improvementVersion += 1;
|
||||
this._emitChanged();
|
||||
return true;
|
||||
|
||||
@@ -995,6 +995,54 @@ export const resourceMethods = {
|
||||
return remaining;
|
||||
},
|
||||
|
||||
// A tile improvement that was raised with money to spare keeps that reserve
|
||||
// for its own upkeep. Each day, before the state is billed, every work with a
|
||||
// reserve covers as much of its own steel and high-tech wear as the money
|
||||
// reaches; the reserve is spent and the nation's `works` and `transport`
|
||||
// demand is cut by what it covered, so a well funded work spares the treasury
|
||||
// until it runs dry. The spent money is paid into the nation's regions, since
|
||||
// it is the private sector the state would otherwise have paid.
|
||||
_applyWorksMaintenance(civ, categories) {
|
||||
if (!this.improvementCash || !categories) return;
|
||||
const steelUnit = this._marketUnitCost("steel");
|
||||
const techUnit = this._marketUnitCost("hightech");
|
||||
let covered = 0;
|
||||
// Spend one work's reserve against its own steel/high-tech wear, trimming
|
||||
// the matching budget category by whatever the reserve could cover.
|
||||
const apply = (k, steel, hightech, category) => {
|
||||
const reserve = this.improvementCash.get(k) || 0;
|
||||
if (!(reserve > 0)) return;
|
||||
const value = steel * steelUnit + hightech * techUnit;
|
||||
if (!(value > 0)) return;
|
||||
const fraction = Math.min(1, reserve / value);
|
||||
const spent = value * fraction;
|
||||
covered += spent;
|
||||
const after = reserve - spent;
|
||||
if (after > 0) this.improvementCash.set(k, after);
|
||||
else this.improvementCash.delete(k);
|
||||
if (category) {
|
||||
category.steel = Math.max(0, (category.steel || 0) - steel * fraction);
|
||||
category.hightech = Math.max(0, (category.hightech || 0) - hightech * fraction);
|
||||
}
|
||||
};
|
||||
for (const [k, id] of this.tileImprovements) {
|
||||
if (this.tileImprovementOwner.get(k) !== civ) continue;
|
||||
const proto = tileImprovementById(id);
|
||||
if (!proto) continue;
|
||||
const costs = protoUpkeep(proto, proto.buildCost || 0);
|
||||
apply(k, costs.steel || 0, costs.hightech || 0, categories.works);
|
||||
}
|
||||
for (const [tiles, id] of [[this.roads, "road"], [this.railways, "railway"]]) {
|
||||
const steel = RESOURCE_RULES.transportUpkeepSteel[id] || 0;
|
||||
for (const k of tiles) {
|
||||
if (this.civAt(parseKey(k)) !== civ) continue;
|
||||
apply(k, steel, 0, categories.transport);
|
||||
}
|
||||
}
|
||||
// The reserves paid the private sector the state would otherwise have paid.
|
||||
this._creditRegions(civ, covered);
|
||||
},
|
||||
|
||||
// Settles one nation's daily upkeep materials: buy the stores from the regions
|
||||
// that hold them, buy the rest from reachable regions, and return the fraction
|
||||
// of the bill the nation could actually settle. With no global market, a bill
|
||||
@@ -1432,6 +1480,9 @@ export const resourceMethods = {
|
||||
// its stores and buys the rest, and an unpaid bill becomes unrest.
|
||||
for (let civ = 0; civ < this.civilisations.length; civ++) {
|
||||
const categories = this.getCivUpkeepBreakdown(civ);
|
||||
// Works that finished with money to spare keep it against their own wear;
|
||||
// that reserve is spent before the state is asked to pay.
|
||||
this._applyWorksMaintenance(civ, categories);
|
||||
const paid = this._settleUpkeep(civ, categories);
|
||||
this._civilianUpkeepShortage(civ, 1 - paid);
|
||||
}
|
||||
@@ -1893,7 +1944,7 @@ export const resourceMethods = {
|
||||
// its national currency, acquired from that nation's central bank. Returns the
|
||||
// amount bought and records each leg on the delivery graph.
|
||||
_procureFromNeighbours(city, id, amount, cityStore, nodes, options = {}) {
|
||||
const { allowSea = true, payer = null, foreignOnly = false } = options;
|
||||
const { allowSea = true, payer = null, foreignOnly = false, maxUnitCost = Infinity } = options;
|
||||
let remaining = amount;
|
||||
const price = this.getResourcePrice(id);
|
||||
const energyPrice = this.getResourcePrice("energy");
|
||||
@@ -1914,6 +1965,8 @@ export const resourceMethods = {
|
||||
);
|
||||
const goods = price * premium;
|
||||
const freight = (supplier.routeEnergyPerTonne || 0) * energyPrice;
|
||||
// A buyer with a locked price refuses a supplier quoting above it.
|
||||
if (goods + freight > maxUnitCost) continue;
|
||||
quotes.push({ supplier, available, foreign, goods, freight, unitCost: goods + freight });
|
||||
}
|
||||
// Home regions before foreign ones; then by delivered price.
|
||||
|
||||
@@ -113,9 +113,9 @@ export const serializationMethods = {
|
||||
queue.map((entry) => ({ ...entry })),
|
||||
])
|
||||
),
|
||||
// The military improvements each nation has under construction, so the
|
||||
// tile panel can show a progress bar like a city's production.
|
||||
tileWorks: this._serializeTileWorks(),
|
||||
// The tile improvements each nation has under construction, so the tile
|
||||
// panel can show the node gathering materials and building.
|
||||
constructionSites: this._serializeConstructionSites(),
|
||||
// Which of the large per-tile collections changed since the last
|
||||
// broadcast, so a viewer can rebuild only those and the server can omit
|
||||
// the rest from the wire (see GameServer._broadcastState).
|
||||
@@ -349,6 +349,7 @@ export const serializationMethods = {
|
||||
this.tileImprovementOwner.get(k) ?? -1,
|
||||
this.tileImprovementHp.get(k) || 0,
|
||||
this.resourceBuildingLevel(k),
|
||||
this.improvementCash.get(k) || 0,
|
||||
]);
|
||||
}
|
||||
return result;
|
||||
@@ -504,20 +505,31 @@ export const serializationMethods = {
|
||||
return result;
|
||||
},
|
||||
|
||||
// The tile improvements under construction, one array per nation, each entry
|
||||
// { coords: [x, y], id, elapsedHours, totalHours }.
|
||||
_serializeTileWorks() {
|
||||
// The construction sites under way, one array per nation, each entry carrying
|
||||
// the phase, the locked prices, what the site still needs and has bought, and
|
||||
// the route to the allied city it hauls from.
|
||||
_serializeConstructionSites() {
|
||||
const result = [];
|
||||
for (let i = 0; i < this.civilisations.length; i++) {
|
||||
const queue = this.tileWorks.get(i) || [];
|
||||
result.push(
|
||||
queue.map((work) => ({
|
||||
coords: [work.coords.x, work.coords.y],
|
||||
id: work.id,
|
||||
elapsedHours: work.elapsedHours,
|
||||
totalHours: work.totalHours,
|
||||
}))
|
||||
);
|
||||
for (let i = 0; i < this.civilisations.length; i++) result.push([]);
|
||||
for (const site of this.constructionSites.values()) {
|
||||
const list = result[site.civ];
|
||||
if (!list) continue;
|
||||
list.push({
|
||||
coords: [site.coords.x, site.coords.y],
|
||||
id: site.id,
|
||||
kind: site.kind,
|
||||
targetLevel: site.targetLevel,
|
||||
phase: site.phase,
|
||||
stalled: site.stalled,
|
||||
budget: site.budget,
|
||||
prices: { ...site.prices },
|
||||
needed: { ...site.needed },
|
||||
bought: { ...site.bought },
|
||||
elapsedHours: site.elapsedHours,
|
||||
totalHours: site.totalHours,
|
||||
cityId: site.cityId,
|
||||
path: site.path.map((cell) => [cell[0], cell[1]]),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
// Construction sites: the economic nodes a tile improvement is raised through.
|
||||
//
|
||||
// Ordering a tile improvement no longer pays for it and raises it at once.
|
||||
// Instead the state hands the site a forecast budget and the tile becomes an
|
||||
// economic node that first gathers the materials it needs -- buying them from
|
||||
// the reachable stores at the prices that stood when the work was decided -- and
|
||||
// only then builds, drawing power from the grid as it goes. Leftover money stays
|
||||
// with the improvement; a bill the site cannot cover is taken from the public
|
||||
// treasury.
|
||||
//
|
||||
// A site stalls when a whole day passes without buying a needed material at the
|
||||
// locked price (the market moved against it). The owner may then raise the
|
||||
// site's budget to the current prices, or cancel the work, losing everything
|
||||
// already spent. Production buildings that finish become private agents and step
|
||||
// through the same phases again when they upgrade.
|
||||
|
||||
import { key } from "../hex.js";
|
||||
import { ECONOMY } from "../data/economy.js";
|
||||
import { tileImprovementById, transportImprovement } from "../data/improvements.js";
|
||||
import {
|
||||
tileImprovementResourceCost,
|
||||
constructionResourceCost,
|
||||
} from "../resources.js";
|
||||
import { TILE_IMPROVEMENT_HP, HOURS_PER_DAY } from "./constants.js";
|
||||
|
||||
// A supplier whose delivered price has risen more than this much over the
|
||||
// locked price is not bought from: the site waits for the market or for its
|
||||
// budget to be raised.
|
||||
const PRICE_TOLERANCE = 1.01;
|
||||
const MATERIAL_IDS = ["steel", "hightech"];
|
||||
const RESOURCE_IDS = ["steel", "hightech", "energy"];
|
||||
|
||||
function emptyResources() {
|
||||
return { steel: 0, hightech: 0, energy: 0 };
|
||||
}
|
||||
|
||||
export const siteMethods = {
|
||||
// --------------------------------------------------------- lookup --
|
||||
|
||||
constructionSiteAt(coords) {
|
||||
if (!this.constructionSites || !coords) return null;
|
||||
return this.constructionSites.get(key(coords.x, coords.y)) || null;
|
||||
},
|
||||
|
||||
// Every construction site a nation owns, newest last, as plain objects.
|
||||
getConstructionSites(civ) {
|
||||
const result = [];
|
||||
if (!this.constructionSites) return result;
|
||||
for (const site of this.constructionSites.values()) {
|
||||
if (site.civ !== civ) continue;
|
||||
result.push(site);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
// How much money an improvement holds after it was raised.
|
||||
improvementCashAt(coords) {
|
||||
if (!this.improvementCash || !coords) return 0;
|
||||
return this.improvementCash.get(key(coords.x, coords.y)) || 0;
|
||||
},
|
||||
|
||||
// The delivered unit prices the site locked in when its work was decided.
|
||||
_lockedUnitPrices() {
|
||||
const prices = {};
|
||||
for (const id of RESOURCE_IDS) prices[id] = this._marketUnitCost(id);
|
||||
return prices;
|
||||
},
|
||||
|
||||
// ------------------------------------------------- A* to a city --
|
||||
|
||||
// The cheapest allied city to haul to, found with A* over the land network so
|
||||
// the route and the city agree. Roads and railways are cheaper to haul along,
|
||||
// which is why a city reachable by rail may beat a nearer one over open land.
|
||||
_siteCityPath(civ, coords) {
|
||||
const cities = this.cities.filter((city) => city.civ === civ);
|
||||
if (cities.length === 0) return { cityId: null, path: [] };
|
||||
const start = { x: coords.x, y: coords.y };
|
||||
const neighbours = (cell) => this._neighbours(cell);
|
||||
const canEnter = (cell) => {
|
||||
const tile = this.tiles[key(cell.x, cell.y)];
|
||||
return !!tile && tile.terrainClass === "Land";
|
||||
};
|
||||
const stepCost = (_from, to) => {
|
||||
const k = key(to.x, to.y);
|
||||
if (this.railways.has(k)) return 0.25;
|
||||
if (this.roads.has(k)) return 0.5;
|
||||
return 1;
|
||||
};
|
||||
const heuristic = (from, to) => this.topology.tileDistance(from, to);
|
||||
let bestCity = null;
|
||||
let bestPath = null;
|
||||
let bestCost = Infinity;
|
||||
for (const city of cities) {
|
||||
const path = this._pathfinder.findPath(
|
||||
start, city.coords, neighbours, canEnter, stepCost, heuristic
|
||||
);
|
||||
if (path.length === 0) continue;
|
||||
let cost = 0;
|
||||
for (let i = 1; i < path.length; i++) cost += stepCost(path[i - 1], path[i]);
|
||||
if (cost < bestCost) {
|
||||
bestCost = cost;
|
||||
bestCity = city;
|
||||
bestPath = path;
|
||||
}
|
||||
}
|
||||
if (!bestCity) return { cityId: null, path: [] };
|
||||
return { cityId: bestCity.id, path: bestPath.map((cell) => [cell.x, cell.y]) };
|
||||
},
|
||||
|
||||
// ---------------------------------------------------- creation --
|
||||
|
||||
// Opens a construction site on a tile. `money` is the forecast budget moved
|
||||
// out of the coffers (or a private agent's cash) into the node; the caller has
|
||||
// already reserved it. `proto` is the improvement being raised and `level` the
|
||||
// production level a fresh build starts from (0) or the level an upgrade
|
||||
// targets minus one.
|
||||
_openConstructionSite({ civ, coords, proto, kind = "build", targetLevel = 0, money, buildCost, level = 0 }) {
|
||||
const k = key(coords.x, coords.y);
|
||||
const materials = kind === "upgrade"
|
||||
? this.constructionResourceCost(proto, level, buildCost)
|
||||
: tileImprovementResourceCost(proto, buildCost);
|
||||
const needed = emptyResources();
|
||||
for (const id of RESOURCE_IDS) needed[id] = Math.max(0, materials[id] || 0);
|
||||
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(civ));
|
||||
const baseHours = kind === "upgrade"
|
||||
? (capacity > 0 ? Math.max(1, buildCost / capacity) : 1)
|
||||
: ECONOMY.trainingHours(this.getPlayerGdp(civ), buildCost);
|
||||
const totalHours = Math.max(1, baseHours * this.constructionSpeedMultiplier(civ));
|
||||
const route = this._siteCityPath(civ, coords);
|
||||
const site = {
|
||||
coords: { x: coords.x, y: coords.y },
|
||||
id: proto.id,
|
||||
civ,
|
||||
kind,
|
||||
targetLevel,
|
||||
phase: "materials",
|
||||
budget: Math.max(0, money || 0),
|
||||
prices: this._lockedUnitPrices(),
|
||||
needed,
|
||||
bought: emptyResources(),
|
||||
elapsedHours: 0,
|
||||
totalHours,
|
||||
cityId: route.cityId,
|
||||
path: route.path,
|
||||
stalled: false,
|
||||
lastBuyDay: -1,
|
||||
};
|
||||
this.constructionSites.set(k, site);
|
||||
this._emitChanged();
|
||||
return site;
|
||||
},
|
||||
|
||||
// Makes a site's first purchase at once, so an order feels immediate and the
|
||||
// day's gathering is not repeated on the next tick.
|
||||
_kickoffSite(site) {
|
||||
site.lastBuyDay = Math.floor(this.totalHours / HOURS_PER_DAY);
|
||||
if (site.phase === "materials") this._gatherSiteMaterials(site);
|
||||
else this._buySiteEnergy(site);
|
||||
},
|
||||
|
||||
// Moves `amount` from a nation's treasury to a site's own account, filing the
|
||||
// expense under tile construction. Unlike a regular spend the money is not
|
||||
// handed to the regions yet: the site still has to buy with it.
|
||||
_reserveSiteBudget(civ, amount, category = "tilebuild") {
|
||||
if (!(amount > 0)) return 0;
|
||||
this.budgets.set(civ, this.getBudget(civ) - amount);
|
||||
this._recordBudgetCash(civ, category, -amount);
|
||||
return amount;
|
||||
},
|
||||
|
||||
// The forecast a work is handed when it is decided: the build cost plus the
|
||||
// market value of the steel and high-tech it will buy. Electricity is a grid
|
||||
// draw billed as the build runs, so it is not part of the forecast.
|
||||
_siteForecastBudget(civ, proto, buildCost, kind = "build", level = 0) {
|
||||
const materials = kind === "upgrade"
|
||||
? this.constructionResourceCost(proto, level, buildCost)
|
||||
: tileImprovementResourceCost(proto, buildCost);
|
||||
let total = buildCost;
|
||||
for (const id of MATERIAL_IDS) total += (materials[id] || 0) * this.getResourcePrice(id);
|
||||
return total;
|
||||
},
|
||||
|
||||
// --------------------------------------------------- daily tick --
|
||||
|
||||
// One hour of every site: the materials phase buys once a day, the
|
||||
// construction phase advances and buys the day's power.
|
||||
_tickConstructionSites() {
|
||||
if (!this.constructionSites || this.constructionSites.size === 0) return;
|
||||
const day = Math.floor(this.totalHours / HOURS_PER_DAY);
|
||||
for (const site of Array.from(this.constructionSites.values())) {
|
||||
const k = key(site.coords.x, site.coords.y);
|
||||
if (!this.constructionSites.has(k)) continue;
|
||||
if (site.phase === "materials") {
|
||||
if (site.lastBuyDay !== day) {
|
||||
site.lastBuyDay = day;
|
||||
this._gatherSiteMaterials(site);
|
||||
}
|
||||
} else {
|
||||
site.elapsedHours += 1;
|
||||
if (site.lastBuyDay !== day) {
|
||||
site.lastBuyDay = day;
|
||||
this._buySiteEnergy(site);
|
||||
}
|
||||
if (site.elapsedHours >= site.totalHours) this._completeConstructionSite(site);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Tries to buy every material the site still needs at its locked prices. A day
|
||||
// that buys nothing while materials are still missing stalls the work, so the
|
||||
// owner is told (and may raise the budget or cancel).
|
||||
_gatherSiteMaterials(site) {
|
||||
let boughtAnything = false;
|
||||
for (const id of MATERIAL_IDS) {
|
||||
const missing = site.needed[id] - site.bought[id];
|
||||
if (!(missing > 1e-9)) continue;
|
||||
const bought = this._siteBuy(site, id, missing);
|
||||
if (bought > 0) {
|
||||
site.bought[id] += bought;
|
||||
boughtAnything = true;
|
||||
}
|
||||
}
|
||||
const stillMissing = MATERIAL_IDS.some((id) => site.needed[id] - site.bought[id] > 1e-9);
|
||||
if (!stillMissing) {
|
||||
site.stalled = false;
|
||||
site.phase = "construction";
|
||||
site.elapsedHours = 0;
|
||||
this._emitChanged();
|
||||
return;
|
||||
}
|
||||
const stalled = !boughtAnything;
|
||||
if (site.stalled !== stalled) {
|
||||
site.stalled = stalled;
|
||||
this._emitChanged();
|
||||
}
|
||||
},
|
||||
|
||||
// Buys `amount` of one resource for the site: first from the allied city the
|
||||
// route hauls to, then from the reachable stores, paying from the site's own
|
||||
// budget first and the public treasury after. A supplier quoting above the
|
||||
// locked price is skipped.
|
||||
_siteBuy(site, id, amount) {
|
||||
if (!(amount > 1e-9)) return 0;
|
||||
if (!site.cityId) return 0;
|
||||
const city = this._cityById.get(site.cityId);
|
||||
if (!city) return 0;
|
||||
const payer = this._sitePayer(site);
|
||||
let bought = 0;
|
||||
// The hub city already holds some of the material; the state buys it from
|
||||
// the region at the market value before reaching further afield.
|
||||
const store = this.getCityResourceStock(city);
|
||||
const take = Math.min(store[id] || 0, amount);
|
||||
if (take > 0) {
|
||||
store[id] -= take;
|
||||
this._payRegionForMaterial(city, id, take, payer);
|
||||
bought += take;
|
||||
}
|
||||
const remaining = amount - bought;
|
||||
if (remaining > 1e-9) {
|
||||
const nodes = this._resourceNodes();
|
||||
const temp = emptyResources();
|
||||
const allowSea = this._cityMarketAccess(city);
|
||||
const cap = site.prices[id] * (1 + PRICE_TOLERANCE);
|
||||
this._procureFromNeighbours(city, id, remaining, temp, nodes, {
|
||||
payer,
|
||||
allowSea,
|
||||
maxUnitCost: cap,
|
||||
});
|
||||
bought += temp[id] || 0;
|
||||
}
|
||||
return bought;
|
||||
},
|
||||
|
||||
// The day's power a site under construction draws, bought the same way.
|
||||
_buySiteEnergy(site) {
|
||||
const missing = site.needed.energy - site.bought.energy;
|
||||
if (!(missing > 1e-9)) return 0;
|
||||
const perDay = Math.min(
|
||||
missing,
|
||||
(site.needed.energy / Math.max(site.totalHours, 1)) * HOURS_PER_DAY
|
||||
);
|
||||
const bought = this._siteBuy(site, "energy", perDay);
|
||||
if (bought > 0) site.bought.energy += bought;
|
||||
return bought;
|
||||
},
|
||||
|
||||
// A payer that empties the site's own account first, then draws on the
|
||||
// nation's treasury, so an overrun is public money.
|
||||
_sitePayer(site) {
|
||||
const payer = { total: 0, overflow: 0 };
|
||||
payer.charge = (cost) => {
|
||||
if (!(cost > 0)) return;
|
||||
const fromBudget = Math.min(site.budget, cost);
|
||||
site.budget -= fromBudget;
|
||||
const rest = cost - fromBudget;
|
||||
if (rest > 0) {
|
||||
this.budgets.set(site.civ, this.getBudget(site.civ) - rest);
|
||||
this._recordBudgetCash(site.civ, "tilebuild", -rest);
|
||||
payer.overflow += rest;
|
||||
}
|
||||
payer.total += cost;
|
||||
};
|
||||
return payer;
|
||||
},
|
||||
|
||||
// -------------------------------------------------- completion --
|
||||
|
||||
_completeConstructionSite(site) {
|
||||
const k = key(site.coords.x, site.coords.y);
|
||||
if (this.constructionSites.get(k) !== site) return;
|
||||
const civ = site.civ;
|
||||
const proto = tileImprovementById(site.id) || transportImprovement(site.id);
|
||||
if (!proto || this.civAt(site.coords) !== civ) {
|
||||
this.constructionSites.delete(k);
|
||||
return;
|
||||
}
|
||||
if (site.kind === "upgrade") {
|
||||
const agent = this._ensureBuildingAgent(k);
|
||||
agent.level = site.targetLevel;
|
||||
agent.upgrading = null;
|
||||
agent.cash = (agent.cash || 0) + site.budget;
|
||||
this._gdpPerCapitaCache.clear();
|
||||
this._touchModifiers();
|
||||
this._tileImprovementVersion += 1;
|
||||
} else if (transportImprovement(site.id)) {
|
||||
this.roads.delete(k);
|
||||
this.railways.delete(k);
|
||||
if (site.id === "railway") this.railways.add(k);
|
||||
else this.roads.add(k);
|
||||
this._improvementVersion += 1;
|
||||
if (site.budget > 0) {
|
||||
this.improvementCash.set(k, (this.improvementCash.get(k) || 0) + site.budget);
|
||||
}
|
||||
} else {
|
||||
this.tileImprovements.set(k, site.id);
|
||||
this.tileImprovementOwner.set(k, civ);
|
||||
this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[site.id] || 1000);
|
||||
if (this._isProductionAgent(proto)) {
|
||||
const agent = this._ensureBuildingAgent(k);
|
||||
agent.cash = (agent.cash || 0) + site.budget;
|
||||
} else if (site.budget > 0) {
|
||||
this.improvementCash.set(k, (this.improvementCash.get(k) || 0) + site.budget);
|
||||
}
|
||||
this._tileImprovementVersion += 1;
|
||||
this._visibilityDirty = true;
|
||||
}
|
||||
this.constructionSites.delete(k);
|
||||
this._gdpPerCapitaCache.delete(k);
|
||||
this._emitChanged();
|
||||
},
|
||||
|
||||
// ------------------------------------------------- owner actions --
|
||||
|
||||
// Raises a stalled site's budget to the current material prices and unlocks
|
||||
// it. The extra money comes from the public treasury.
|
||||
requestIncreaseSiteBudget(civ, coords) {
|
||||
const site = this.constructionSiteAt(coords);
|
||||
if (!site || site.civ !== civ) return false;
|
||||
const prices = this._lockedUnitPrices();
|
||||
let wanted = 0;
|
||||
for (const id of RESOURCE_IDS) {
|
||||
const missing = Math.max(0, site.needed[id] - site.bought[id]);
|
||||
wanted += missing * prices[id];
|
||||
}
|
||||
const add = Math.max(0, wanted - site.budget);
|
||||
this._reserveSiteBudget(civ, add);
|
||||
site.budget += add;
|
||||
site.prices = prices;
|
||||
site.stalled = false;
|
||||
// Try again at once so the panel updates without waiting for the day.
|
||||
if (site.phase === "materials") this._gatherSiteMaterials(site);
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
// Cancels a site. Nothing is refunded: the budget already handed to it and
|
||||
// whatever materials it gathered are lost.
|
||||
requestCancelSite(civ, coords) {
|
||||
const site = this.constructionSiteAt(coords);
|
||||
if (!site || site.civ !== civ) return false;
|
||||
this.constructionSites.delete(key(site.coords.x, site.coords.y));
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -6,8 +6,8 @@ import { key, parseKey } from "../hex.js";
|
||||
import { ECONOMY } from "../data/economy.js";
|
||||
import { tileImprovementById } from "../data/improvements.js";
|
||||
import { TRENCH } from "../data/combat.js";
|
||||
import { TILE_IMPROVEMENT_HP, COASTAL_CANNON, EMBARK_HOURS } from "./constants.js";
|
||||
import { protoUpkeep, tileImprovementResourceCost } from "../resources.js";
|
||||
import { COASTAL_CANNON, EMBARK_HOURS } from "./constants.js";
|
||||
import { protoUpkeep } from "../resources.js";
|
||||
|
||||
export const warfareMethods = {
|
||||
// ----------------------------------------------------------- trenches --
|
||||
@@ -41,9 +41,10 @@ export const warfareMethods = {
|
||||
|
||||
// ------------------------------------------------- tile improvements --
|
||||
|
||||
// Queues a military tile improvement on a land tile the nation controls. The
|
||||
// work takes the same GDP-scaled time as any other construction and completes
|
||||
// on the hourly tile-works tick.
|
||||
// Opens a construction site for a military or resource tile improvement on a
|
||||
// land tile the nation controls. The state hands the site a forecast budget
|
||||
// and the tile's economic node gathers materials before building; nothing is
|
||||
// raised until the site completes.
|
||||
requestBuildTileImprovement(civ, coords, id) {
|
||||
if (!this._validCiv(civ) || !coords) return false;
|
||||
if (!Number.isFinite(coords.x) || !Number.isFinite(coords.y)) return false;
|
||||
@@ -53,42 +54,38 @@ export const warfareMethods = {
|
||||
const tile = this.tiles[k];
|
||||
if (!tile || tile.terrainClass !== "Land") return false;
|
||||
if (this.civAt(coords) !== civ) return false;
|
||||
if (this.tileImprovements.has(k)) return false;
|
||||
if (this.tileImprovements.has(k) || this.constructionSiteAt(coords)) return false;
|
||||
if (proto.coastal && !this.isCoastalCoords(coords)) return false;
|
||||
// Resource producers feed the map, not a city: they stand on open land.
|
||||
if (proto.resource && this.cityAt(coords)) return false;
|
||||
if (proto.requiresTechnology && !this.hasTechnology(civ, proto.requiresTechnology)) return false;
|
||||
const gdp = this.getPlayerGdp(civ);
|
||||
if (ECONOMY.productionCapacity(gdp) <= 0) return false;
|
||||
if (!this._spendBudget(civ, proto.buildCost, "tilebuild")) return false;
|
||||
const materials = tileImprovementResourceCost(proto, proto.buildCost);
|
||||
if (!this._payConstructionResources(civ, coords, materials, "tilebuild", proto.name)) {
|
||||
this._refundBudget(civ, proto.buildCost, "tilebuild");
|
||||
return false;
|
||||
}
|
||||
const work = {
|
||||
coords: { x: coords.x, y: coords.y },
|
||||
id,
|
||||
elapsedHours: 0,
|
||||
totalHours: ECONOMY.trainingHours(gdp, proto.buildCost) * this.constructionSpeedMultiplier(civ),
|
||||
};
|
||||
const queue = this.tileWorks.get(civ);
|
||||
if (queue) queue.push(work);
|
||||
else this.tileWorks.set(civ, [work]);
|
||||
const money = this._siteForecastBudget(civ, proto, proto.buildCost);
|
||||
this._reserveSiteBudget(civ, money);
|
||||
const site = this._openConstructionSite({
|
||||
civ, coords, proto, kind: "build", money, buildCost: proto.buildCost,
|
||||
});
|
||||
this._kickoffSite(site);
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
// Tears down a military improvement (or a trench) on a controlled tile.
|
||||
// Tears down a military improvement (or a trench) on a controlled tile. A
|
||||
// work still under construction is cancelled instead, with no refund.
|
||||
requestRemoveTileImprovement(civ, coords) {
|
||||
if (!this._validCiv(civ) || !coords) return false;
|
||||
const k = key(coords.x, coords.y);
|
||||
if (this.civAt(coords) !== civ) return false;
|
||||
if (this.constructionSites && this.constructionSites.has(k)) {
|
||||
return this.requestCancelSite(civ, coords);
|
||||
}
|
||||
let removed = false;
|
||||
if (this.trenches.delete(k)) removed = true;
|
||||
if (this.tileImprovements.delete(k)) {
|
||||
this.tileImprovementOwner.delete(k);
|
||||
this.tileImprovementHp.delete(k);
|
||||
this.improvementCash.delete(k);
|
||||
this._removeBuildingAgent(k);
|
||||
removed = true;
|
||||
}
|
||||
@@ -99,43 +96,6 @@ export const warfareMethods = {
|
||||
return true;
|
||||
},
|
||||
|
||||
// Advances every nation's queued tile works by an hour, completing the head
|
||||
// of each queue and carrying the overflow into the next.
|
||||
_tickTileWorks() {
|
||||
for (const [civ, queue] of this.tileWorks) {
|
||||
let hoursLeft = 1;
|
||||
while (hoursLeft > 0 && queue.length > 0) {
|
||||
const work = queue[0];
|
||||
const remaining = Math.max(work.totalHours - work.elapsedHours, 0);
|
||||
if (hoursLeft >= remaining) {
|
||||
hoursLeft -= remaining;
|
||||
this._completeTileWork(civ, work);
|
||||
queue.shift();
|
||||
} else {
|
||||
work.elapsedHours += hoursLeft;
|
||||
hoursLeft = 0;
|
||||
}
|
||||
}
|
||||
if (queue.length === 0) this.tileWorks.delete(civ);
|
||||
}
|
||||
},
|
||||
|
||||
_completeTileWork(civ, work) {
|
||||
const k = key(work.coords.x, work.coords.y);
|
||||
// The tile may have been taken or already improved while the work ran.
|
||||
if (this.civAt(work.coords) !== civ || this.tileImprovements.has(k)) return;
|
||||
const proto = tileImprovementById(work.id);
|
||||
if (!proto) return;
|
||||
this.tileImprovements.set(k, work.id);
|
||||
this.tileImprovementOwner.set(k, civ);
|
||||
this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[work.id] || 1000);
|
||||
if (this._isProductionAgent(proto)) this._ensureBuildingAgent(k);
|
||||
this._tileImprovementVersion += 1;
|
||||
this._gdpPerCapitaCache.delete(k);
|
||||
this._visibilityDirty = true;
|
||||
this._emitChanged();
|
||||
},
|
||||
|
||||
// The extra defense a tile's fortifications and trenches give a defender,
|
||||
// against a melee or artillery attacker.
|
||||
_tileFortificationMultiplier(coords, attackerProto) {
|
||||
@@ -181,6 +141,7 @@ export const warfareMethods = {
|
||||
this.tileImprovements.delete(k);
|
||||
this.tileImprovementOwner.delete(k);
|
||||
this.tileImprovementHp.delete(k);
|
||||
this.improvementCash.delete(k);
|
||||
this._removeBuildingAgent(k);
|
||||
this._tileImprovementVersion += 1;
|
||||
this._emitChanged();
|
||||
|
||||
+8
-8
@@ -618,24 +618,24 @@ export class CoastalBombardTest extends TestCase {
|
||||
test_ranged_fire_can_destroy_a_building_level() {
|
||||
const state = smallState();
|
||||
const city = state.cities[0];
|
||||
const market = buildingIndex(state, "market");
|
||||
giveBuilding(state, city, "market", 3);
|
||||
const barracks = buildingIndex(state, "barracks");
|
||||
giveBuilding(state, city, "barracks", 3);
|
||||
this.assertTrue(state._razeCityBuildings(city, 2500), "a strong enough blow razes");
|
||||
this.assertEqual(state.getCityBuildingLevel(city, market), 2, "the level drops by one");
|
||||
this.assertEqual(state.getCityBuildingLevel(city, barracks), 2, "the level drops by one");
|
||||
}
|
||||
|
||||
test_shelters_resist_destruction() {
|
||||
const state = smallState();
|
||||
const city = state.cities[0];
|
||||
const market = buildingIndex(state, "market");
|
||||
const barracks = buildingIndex(state, "barracks");
|
||||
const shelters = buildingIndex(state, "shelters");
|
||||
giveBuilding(state, city, "market", 50);
|
||||
giveBuilding(state, city, "barracks", 50);
|
||||
giveBuilding(state, city, "shelters", 50);
|
||||
for (let i = 0; i < 50; i++) state._razeCityBuildings(city, 2500);
|
||||
const marketLost = 50 - state.getCityBuildingLevel(city, market);
|
||||
const ordinaryLost = 50 - state.getCityBuildingLevel(city, barracks);
|
||||
const shelterLost = 50 - state.getCityBuildingLevel(city, shelters);
|
||||
this.assertEqual(marketLost, 50, "an ordinary building is razed every time");
|
||||
this.assertLess(shelterLost, marketLost, "shelters are far harder to destroy");
|
||||
this.assertEqual(ordinaryLost, 50, "an ordinary building is razed every time");
|
||||
this.assertLess(shelterLost, ordinaryLost, "shelters are far harder to destroy");
|
||||
}
|
||||
|
||||
test_air_defense_damages_a_hostile_plane_over_the_city() {
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState, cityOf } from "./framework/helpers.js";
|
||||
import { key } from "../shared/hex.js";
|
||||
import { tileImprovementById, RESOURCE_RULES } from "../shared/data.js";
|
||||
import { protoUpkeep } from "../shared/resources.js";
|
||||
|
||||
const BIG_BUDGET = 1.0e15;
|
||||
|
||||
// A bare owned land tile with no improvement and no work already on it.
|
||||
function bareOwnedLand(state, civ = 0) {
|
||||
return state.landCells.find((coords) => {
|
||||
const k = key(coords.x, coords.y);
|
||||
return state.civAt(coords) === civ &&
|
||||
!state.roads.has(k) &&
|
||||
!state.railways.has(k) &&
|
||||
!state.constructionSiteAt(coords);
|
||||
});
|
||||
}
|
||||
|
||||
// Registers a construction site literal, so the site tick can be exercised
|
||||
// without a full build order.
|
||||
function makeSite(state, coords, overrides = {}) {
|
||||
const k = key(coords.x, coords.y);
|
||||
const site = {
|
||||
coords: { x: coords.x, y: coords.y },
|
||||
id: "fortifications",
|
||||
civ: 0,
|
||||
kind: "build",
|
||||
targetLevel: 0,
|
||||
phase: "materials",
|
||||
budget: 0,
|
||||
prices: state._lockedUnitPrices(),
|
||||
needed: { steel: 0, hightech: 0, energy: 0 },
|
||||
bought: { steel: 0, hightech: 0, energy: 0 },
|
||||
elapsedHours: 0,
|
||||
totalHours: 10,
|
||||
cityId: null,
|
||||
path: [],
|
||||
stalled: false,
|
||||
lastBuyDay: -1,
|
||||
...overrides,
|
||||
};
|
||||
state.constructionSites.set(k, site);
|
||||
return site;
|
||||
}
|
||||
|
||||
// Empties every reachable store, so a site with no stock of its own buys
|
||||
// nothing from the market either.
|
||||
function clearMaterials(state) {
|
||||
for (const city of state.cities) {
|
||||
const stock = state.getCityResourceStock(city);
|
||||
stock.steel = 0;
|
||||
stock.hightech = 0;
|
||||
stock.energy = 0;
|
||||
}
|
||||
if (state.resourceStock) {
|
||||
for (const stock of state.resourceStock.values()) {
|
||||
stock.steel = 0;
|
||||
stock.hightech = 0;
|
||||
stock.energy = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ConstructionSiteTest extends TestCase {
|
||||
test_an_order_opens_a_site_without_raising_the_improvement() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
for (const city of state.cities) {
|
||||
if (city.civ !== 0) continue;
|
||||
const stock = state.getCityResourceStock(city);
|
||||
stock.steel = 1e9;
|
||||
stock.hightech = 1e6;
|
||||
stock.energy = 1e9;
|
||||
}
|
||||
const coords = bareOwnedLand(state);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
||||
const site = state.constructionSiteAt(coords);
|
||||
this.assertNotNull(site, "a site is opened on the tile");
|
||||
this.assertEqual(site.civ, 0);
|
||||
this.assertEqual(site.id, "road");
|
||||
this.assertFalse(state.roads.has(key(coords.x, coords.y)), "nothing raised yet");
|
||||
// The locked prices are recorded from the market when the work was decided.
|
||||
this.assertApprox(site.prices.steel, state._marketUnitCost("steel"), 1e-9);
|
||||
this.assertNotNull(site.cityId, "an allied city was found to haul to");
|
||||
}
|
||||
|
||||
test_a_site_locks_its_prices_when_decided() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const coords = bareOwnedLand(state);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
||||
const site = state.constructionSiteAt(coords);
|
||||
const locked = site.prices.steel;
|
||||
state.setResourcePrice("steel", state.getResourcePrice("steel") * 5);
|
||||
this.assertApprox(site.prices.steel, locked, 1e-9, "the site keeps its locked price");
|
||||
this.assertTrue(
|
||||
state._marketUnitCost("steel") > locked,
|
||||
"the market moved after the work was decided"
|
||||
);
|
||||
}
|
||||
|
||||
test_a_site_buys_from_its_hub_city_then_reaches_the_market() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
const stock = state.getCityResourceStock(city);
|
||||
stock.steel = 800;
|
||||
stock.hightech = 200;
|
||||
const coords = bareOwnedLand(state);
|
||||
const site = makeSite(state, coords, {
|
||||
cityId: city.id,
|
||||
budget: 1e9,
|
||||
needed: { steel: 1000, hightech: 100, energy: 0 },
|
||||
});
|
||||
state._gatherSiteMaterials(site);
|
||||
this.assertApprox(site.bought.steel, 1000, 1e-6, "all the steel was gathered");
|
||||
this.assertApprox(site.bought.hightech, 100, 1e-6, "all the high-tech was gathered");
|
||||
this.assertEqual(site.phase, "construction", "gathering is done, building begins");
|
||||
this.assertFalse(site.stalled);
|
||||
this.assertLess(site.budget, 1e9, "the site's own budget paid for the materials");
|
||||
}
|
||||
|
||||
test_a_day_that_buys_nothing_stalls_the_site() {
|
||||
const state = smallState();
|
||||
clearMaterials(state);
|
||||
const city = cityOf(state, 0);
|
||||
state.getCityResourceStock(city).steel = 1000;
|
||||
const coords = bareOwnedLand(state);
|
||||
const site = makeSite(state, coords, {
|
||||
cityId: city.id,
|
||||
budget: 1e9,
|
||||
needed: { steel: 100000, hightech: 0, energy: 0 },
|
||||
});
|
||||
// The first day buys what the store held.
|
||||
state._gatherSiteMaterials(site);
|
||||
this.assertGreater(site.bought.steel, 0, "some steel was bought");
|
||||
this.assertFalse(site.stalled, "a purchase was made");
|
||||
// The next day the store is empty, so nothing is bought and the site stalls.
|
||||
state._gatherSiteMaterials(site);
|
||||
this.assertTrue(site.stalled, "a day without a purchase stalls the work");
|
||||
this.assertEqual(site.phase, "materials", "it never reached construction");
|
||||
}
|
||||
|
||||
test_increasing_a_stalled_budget_reopens_the_work() {
|
||||
const state = smallState();
|
||||
clearMaterials(state);
|
||||
const city = cityOf(state, 0);
|
||||
const coords = bareOwnedLand(state);
|
||||
const site = makeSite(state, coords, {
|
||||
cityId: city.id,
|
||||
budget: 0,
|
||||
needed: { steel: 100, hightech: 0, energy: 0 },
|
||||
});
|
||||
state.totalHours = 1;
|
||||
state._gatherSiteMaterials(site);
|
||||
this.assertTrue(site.stalled, "no money and no purchase");
|
||||
// Refill the store, then raise the budget to the current prices.
|
||||
state.getCityResourceStock(city).steel = 1000;
|
||||
const treasury = 1e9;
|
||||
state.budgets.set(0, treasury);
|
||||
this.assertTrue(state.requestIncreaseSiteBudget(0, coords));
|
||||
this.assertFalse(site.stalled, "the work is unlocked");
|
||||
this.assertApprox(site.bought.steel, 100, 1e-6, "the materials were bought");
|
||||
this.assertLess(state.getBudget(0), treasury, "the treasury funded the increase");
|
||||
}
|
||||
|
||||
test_cancelling_a_site_refunds_nothing() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const coords = bareOwnedLand(state);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
||||
const site = state.constructionSiteAt(coords);
|
||||
this.assertGreater(site.budget, 0, "the site was funded");
|
||||
const treasury = state.getBudget(0);
|
||||
this.assertTrue(state.requestCancelSite(0, coords));
|
||||
this.assertNull(state.constructionSiteAt(coords), "the site is gone");
|
||||
this.assertFalse(state.roads.has(key(coords.x, coords.y)), "no improvement was raised");
|
||||
this.assertApprox(state.getBudget(0), treasury, 1e-6, "the budget is not returned");
|
||||
}
|
||||
|
||||
test_only_the_owner_may_increase_or_cancel_a_site() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const coords = bareOwnedLand(state);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
||||
this.assertFalse(state.requestCancelSite(1, coords), "a foreign nation cannot cancel it");
|
||||
this.assertFalse(state.requestIncreaseSiteBudget(1, coords), "nor raise its budget");
|
||||
this.assertNotNull(state.constructionSiteAt(coords));
|
||||
}
|
||||
|
||||
test_a_finished_site_keeps_its_leftover_as_improvement_cash() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
state.getCityResourceStock(city).steel = 1000;
|
||||
const coords = bareOwnedLand(state);
|
||||
const site = makeSite(state, coords, {
|
||||
cityId: city.id,
|
||||
budget: 1e6,
|
||||
needed: { steel: 10, hightech: 0, energy: 0 },
|
||||
phase: "construction",
|
||||
totalHours: 0,
|
||||
});
|
||||
// Buy the small material bill first, then let the work finish.
|
||||
state._gatherSiteMaterials(site);
|
||||
site.phase = "construction";
|
||||
site.totalHours = 0;
|
||||
state._tickConstructionSites();
|
||||
this.assertNull(state.constructionSiteAt(coords), "the work completed");
|
||||
this.assertEqual(state.tileImprovements.get(key(coords.x, coords.y)), "fortifications");
|
||||
this.assertGreater(state.improvementCashAt(coords), 0, "the leftover stays with the tile");
|
||||
}
|
||||
|
||||
test_a_works_reserve_pays_toward_its_own_upkeep() {
|
||||
const state = smallState();
|
||||
const coords = bareOwnedLand(state);
|
||||
const k = key(coords.x, coords.y);
|
||||
state.tileImprovements.set(k, "fortifications");
|
||||
state.tileImprovementOwner.set(k, 0);
|
||||
state.improvementCash.set(k, 1e9);
|
||||
const proto = tileImprovementById("fortifications");
|
||||
const costs = protoUpkeep(proto, proto.buildCost || 0);
|
||||
const categories = {
|
||||
works: { steel: costs.steel, hightech: costs.hightech },
|
||||
transport: { steel: 0, hightech: 0 },
|
||||
};
|
||||
state._applyWorksMaintenance(0, categories);
|
||||
this.assertLess(state.improvementCashAt(coords), 1e9, "the reserve was spent");
|
||||
this.assertApprox(categories.works.steel, 0, 1e-6, "the work covered its own steel");
|
||||
this.assertEqual(categories.works.hightech, 0, "and its high-tech");
|
||||
}
|
||||
|
||||
test_a_works_reserve_only_covers_what_its_money_reaches() {
|
||||
const state = smallState();
|
||||
const coords = bareOwnedLand(state);
|
||||
const k = key(coords.x, coords.y);
|
||||
state.tileImprovements.set(k, "fortifications");
|
||||
state.tileImprovementOwner.set(k, 0);
|
||||
const proto = tileImprovementById("fortifications");
|
||||
const costs = protoUpkeep(proto, proto.buildCost || 0);
|
||||
const unit = state._marketUnitCost("steel");
|
||||
// Half the day's steel bill, so only half the wear is covered.
|
||||
state.improvementCash.set(k, costs.steel * unit * 0.5);
|
||||
const categories = {
|
||||
works: { steel: costs.steel, hightech: costs.hightech },
|
||||
transport: { steel: 0, hightech: 0 },
|
||||
};
|
||||
state._applyWorksMaintenance(0, categories);
|
||||
this.assertApprox(state.improvementCashAt(coords), 0, 1e-6, "the reserve ran dry");
|
||||
this.assertApprox(categories.works.steel, costs.steel * 0.5, 1e-6, "the rest is still owed");
|
||||
}
|
||||
|
||||
test_a_transport_reserve_pays_its_own_steel() {
|
||||
const state = smallState();
|
||||
const coords = bareOwnedLand(state);
|
||||
const k = key(coords.x, coords.y);
|
||||
state.roads.add(k);
|
||||
state.improvementCash.set(k, 1e9);
|
||||
const categories = {
|
||||
works: { steel: 0, hightech: 0 },
|
||||
transport: { steel: RESOURCE_RULES.transportUpkeepSteel.road, hightech: 0 },
|
||||
};
|
||||
state._applyWorksMaintenance(0, categories);
|
||||
this.assertApprox(categories.transport.steel, 0, 1e-6, "the road covered its own wear");
|
||||
this.assertLess(state.improvementCashAt(coords), 1e9, "the reserve was spent");
|
||||
}
|
||||
|
||||
test_the_snapshot_ships_the_sites_with_their_phase_and_budget() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const coords = bareOwnedLand(state);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
||||
const snapshot = state.snapshot(0);
|
||||
const entry = (snapshot.constructionSites[0] || []).find(
|
||||
(site) => site.coords[0] === coords.x && site.coords[1] === coords.y
|
||||
);
|
||||
this.assertNotNull(entry, "the site is in the snapshot");
|
||||
this.assertEqual(entry.id, "road");
|
||||
this.assertTrue(entry.phase === "materials" || entry.phase === "construction");
|
||||
this.assertNotNull(entry.prices.steel, "the locked prices travel too");
|
||||
this.assertNotNull(entry.cityId, "the haul city travels too");
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -23,7 +23,7 @@ import {
|
||||
export class DataTest extends TestCase {
|
||||
test_lookups_return_the_matching_entry() {
|
||||
this.assertEqual(protoUnitById("modern_infantry"), PROTO_UNITS.find((u) => u.id === "modern_infantry"));
|
||||
this.assertEqual(buildingById("market"), BUILDINGS.find((b) => b.id === "market"));
|
||||
this.assertEqual(buildingById("barracks"), BUILDINGS.find((b) => b.id === "barracks"));
|
||||
this.assertEqual(governmentById("republic"), GOVERNMENTS[0]);
|
||||
this.assertEqual(technologyById("scientific_method"), TECHNOLOGIES[0]);
|
||||
}
|
||||
@@ -130,9 +130,10 @@ export class DataTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
test_market_is_a_cheap_starter_building() {
|
||||
this.assertEqual(buildingById("market").baseCost, 500_000);
|
||||
// The tax-raising Bank is gone: the state lives on trade taxes alone.
|
||||
test_market_and_bank_are_gone() {
|
||||
// The Market was removed and the tax-raising Bank is gone too: the state
|
||||
// lives on trade taxes alone.
|
||||
this.assertEqual(buildingById("market"), null);
|
||||
this.assertEqual(buildingById("bank"), null);
|
||||
}
|
||||
|
||||
|
||||
@@ -534,6 +534,13 @@ export class GameServerLoginTest extends TestCase {
|
||||
const { server } = makeServer();
|
||||
server.peerCiv.set(1, 0);
|
||||
server.state.budgets.set(0, 10_000_000_000);
|
||||
for (const city of server.state.cities) {
|
||||
if (city.civ !== 0) continue;
|
||||
const stock = server.state.getCityResourceStock(city);
|
||||
stock.steel = 1e9;
|
||||
stock.hightech = 1e6;
|
||||
stock.energy = 1e9;
|
||||
}
|
||||
// A land tile France owns but where the pre-generated network left no road.
|
||||
const coords = server.state.landCells.find((cell) => {
|
||||
const k = `${cell.x},${cell.y}`;
|
||||
@@ -548,6 +555,10 @@ export class GameServerLoginTest extends TestCase {
|
||||
})
|
||||
);
|
||||
const k = `${coords.x},${coords.y}`;
|
||||
this.assertNotNull(server.state.constructionSiteAt(coords), "the work is queued");
|
||||
for (let i = 0; i < 50000 && server.state.constructionSites.size > 0; i++) {
|
||||
server.state.advanceHour();
|
||||
}
|
||||
this.assertTrue(server.state.roads.has(k), "the road is laid");
|
||||
// The owner may replace it with a railway.
|
||||
this.assertTrue(
|
||||
@@ -557,6 +568,9 @@ export class GameServerLoginTest extends TestCase {
|
||||
improvement: "railway",
|
||||
})
|
||||
);
|
||||
for (let i = 0; i < 50000 && server.state.constructionSites.size > 0; i++) {
|
||||
server.state.advanceHour();
|
||||
}
|
||||
this.assertTrue(server.state.railways.has(k));
|
||||
this.assertFalse(server.state.roads.has(k), "the road was replaced");
|
||||
// A player who owns nothing cannot build there.
|
||||
|
||||
+60
-20
@@ -7,44 +7,70 @@ import { tileImprovementResourceCost } from "../shared/resources.js";
|
||||
|
||||
const BIG_BUDGET = 10_000_000_000;
|
||||
|
||||
// A land tile owned by civ 0 that carries no transport improvement yet.
|
||||
// A land tile owned by civ 0 that carries no transport improvement yet and is
|
||||
// not already the site of a work.
|
||||
function bareOwnedLand(state, civ = 0) {
|
||||
return state.landCells.find((coords) => {
|
||||
const k = key(coords.x, coords.y);
|
||||
return state.civAt(coords) === civ && !state.roads.has(k) && !state.railways.has(k);
|
||||
return state.civAt(coords) === civ &&
|
||||
!state.roads.has(k) &&
|
||||
!state.railways.has(k) &&
|
||||
!state.constructionSiteAt(coords);
|
||||
});
|
||||
}
|
||||
|
||||
// Stocks every owned city so the material bill can be drawn from the stores.
|
||||
function stockMaterials(state, civ = 0) {
|
||||
for (const city of state.cities) {
|
||||
if (city.civ !== civ) continue;
|
||||
const stock = state.getCityResourceStock(city);
|
||||
stock.steel = 1e9;
|
||||
stock.hightech = 1e6;
|
||||
stock.energy = 1e9;
|
||||
}
|
||||
}
|
||||
|
||||
// Runs the clock until every construction site has resolved (or the bound is
|
||||
// hit, so a stalled site fails the caller's assertion rather than hanging).
|
||||
function completeSites(state, maxHours = 50000) {
|
||||
for (let i = 0; i < maxHours && state.constructionSites.size > 0; i++) {
|
||||
state.advanceHour();
|
||||
}
|
||||
}
|
||||
|
||||
export class ImprovementsTest extends TestCase {
|
||||
test_building_a_road_costs_money_and_resources_and_lays_the_tile() {
|
||||
test_building_a_road_opens_a_site_then_lays_the_tile() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
// Stock enough steel that the whole material bill is drawn from the stores;
|
||||
// the treasury pays the build cost plus the steel it buys from the region.
|
||||
// Construction electricity is a grid draw, not a purchase, so it is free.
|
||||
for (const city of state.cities) {
|
||||
if (city.civ === 0) state.getCityResourceStock(city).steel = 1e9;
|
||||
}
|
||||
stockMaterials(state);
|
||||
const coords = bareOwnedLand(state);
|
||||
this.assertNotNull(coords, "the map has a bare owned land tile");
|
||||
const k = key(coords.x, coords.y);
|
||||
const before = state.getBudget(0);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
||||
this.assertTrue(state.roads.has(k), "the road is on the tile");
|
||||
this.assertFalse(state.railways.has(k), "it is not a railway");
|
||||
this.assertNotNull(state.constructionSiteAt(coords), "a site is opened");
|
||||
this.assertFalse(state.roads.has(k), "nothing is raised until the work is done");
|
||||
// The forecast is handed to the site at once: the build cost plus the steel.
|
||||
const materials = tileImprovementResourceCost(TRANSPORT_BY_ID.road);
|
||||
const steelValue = materials.steel * state.getResourcePrice("steel");
|
||||
this.assertApprox(
|
||||
before - state.getBudget(0),
|
||||
TRANSPORT_BY_ID.road.buildCost + steelValue,
|
||||
1e-3,
|
||||
"the build cost plus the steel the region supplied"
|
||||
"the forecast covers the build cost plus the steel the region supplied"
|
||||
);
|
||||
completeSites(state);
|
||||
this.assertNull(state.constructionSiteAt(coords), "the work completed");
|
||||
this.assertTrue(state.roads.has(k), "the road is on the tile");
|
||||
this.assertFalse(state.railways.has(k), "it is not a railway");
|
||||
}
|
||||
|
||||
test_a_railway_replaces_a_road_and_moves_twice_as_fast() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
stockMaterials(state);
|
||||
const coords = bareOwnedLand(state);
|
||||
this.assertNotNull(coords);
|
||||
const k = key(coords.x, coords.y);
|
||||
@@ -53,6 +79,7 @@ export class ImprovementsTest extends TestCase {
|
||||
state.roads.add(k);
|
||||
const roadHours = state._tileTravelHours(unit, coords);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
|
||||
completeSites(state);
|
||||
this.assertTrue(state.railways.has(k), "the railway is on the tile");
|
||||
this.assertFalse(state.roads.has(k), "the road was replaced");
|
||||
const railHours = state._tileTravelHours(unit, coords);
|
||||
@@ -62,6 +89,7 @@ export class ImprovementsTest extends TestCase {
|
||||
test_a_railway_moves_four_times_as_fast_as_open_grass() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
stockMaterials(state);
|
||||
const coords = state.landCells.find(
|
||||
(c) => state.tiles[key(c.x, c.y)].terrainType === "Land" && state.civAt(c) === 0
|
||||
);
|
||||
@@ -71,6 +99,7 @@ export class ImprovementsTest extends TestCase {
|
||||
state.railways.delete(key(coords.x, coords.y));
|
||||
const open = state._tileTravelHours(unit, coords);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
|
||||
completeSites(state);
|
||||
const rail = state._tileTravelHours(unit, coords);
|
||||
this.assertApprox(rail * 4, open, 1e-9, "a railway is four times grass speed");
|
||||
}
|
||||
@@ -103,8 +132,14 @@ export class ImprovementsTest extends TestCase {
|
||||
test_the_same_improvement_cannot_be_built_twice() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
stockMaterials(state);
|
||||
const coords = bareOwnedLand(state);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
||||
this.assertFalse(
|
||||
state.requestBuildImprovement(0, coords, "road"),
|
||||
"a work is already under way"
|
||||
);
|
||||
completeSites(state);
|
||||
const after = state.getBudget(0);
|
||||
this.assertFalse(state.requestBuildImprovement(0, coords, "road"), "already a road");
|
||||
this.assertApprox(state.getBudget(0), after, 1e-9, "nothing was charged");
|
||||
@@ -113,20 +148,23 @@ export class ImprovementsTest extends TestCase {
|
||||
test_a_road_can_be_built_on_an_empty_treasury() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 0);
|
||||
stockMaterials(state);
|
||||
const coords = bareOwnedLand(state);
|
||||
// The government can always pay, so the road goes ahead on borrowed money.
|
||||
// The government can always pay, so the work goes ahead on borrowed money.
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
||||
this.assertLess(state.getBudget(0), 0, "the forecast became central-bank debt");
|
||||
completeSites(state);
|
||||
this.assertTrue(state.roads.has(key(coords.x, coords.y)));
|
||||
this.assertLess(state.getBudget(0), 0, "the cost became central-bank debt");
|
||||
}
|
||||
|
||||
test_maintenance_counts_roads_and_railways_the_nation_holds() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
stockMaterials(state);
|
||||
const coords = bareOwnedLand(state);
|
||||
const k = key(coords.x, coords.y);
|
||||
const before = state.getTransportUpkeep(0);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
||||
completeSites(state);
|
||||
this.assertApprox(
|
||||
state.getTransportUpkeep(0) - before,
|
||||
RESOURCE_RULES.transportUpkeepSteel.road,
|
||||
@@ -134,6 +172,7 @@ export class ImprovementsTest extends TestCase {
|
||||
"a road adds its daily steel wear"
|
||||
);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
|
||||
completeSites(state);
|
||||
this.assertApprox(
|
||||
state.getTransportUpkeep(0) - before,
|
||||
RESOURCE_RULES.transportUpkeepSteel.railway,
|
||||
@@ -145,8 +184,10 @@ export class ImprovementsTest extends TestCase {
|
||||
test_the_snapshot_ships_railways() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
stockMaterials(state);
|
||||
const coords = bareOwnedLand(state);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
|
||||
completeSites(state);
|
||||
const snapshot = state.snapshot(0);
|
||||
this.assertTrue(
|
||||
snapshot.railways.some(([x, y]) => x === coords.x && y === coords.y),
|
||||
@@ -162,9 +203,11 @@ export class ImprovementsTest extends TestCase {
|
||||
test_an_improvement_can_be_removed_for_free() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
stockMaterials(state);
|
||||
const coords = bareOwnedLand(state);
|
||||
const k = key(coords.x, coords.y);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "railway"));
|
||||
completeSites(state);
|
||||
const before = state.getBudget(0);
|
||||
this.assertTrue(state.requestRemoveImprovement(0, coords), "the railway is removed");
|
||||
this.assertFalse(state.railways.has(k), "the tile is bare again");
|
||||
@@ -175,8 +218,10 @@ export class ImprovementsTest extends TestCase {
|
||||
test_only_the_controller_may_remove_an_improvement() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
stockMaterials(state);
|
||||
const coords = bareOwnedLand(state);
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"));
|
||||
completeSites(state);
|
||||
this.assertFalse(
|
||||
state.requestRemoveImprovement(1, coords),
|
||||
"another nation cannot tear up the road"
|
||||
@@ -199,13 +244,8 @@ export class ImprovementsTest extends TestCase {
|
||||
state.budgets.set(0, 1.0e15);
|
||||
// Enough stores that the whole material bill is drawn from them; the
|
||||
// treasury pays the build cost plus the steel and high-tech it buys from the
|
||||
// region. Construction electricity comes off the grid, so it is not bought.
|
||||
for (const city of state.cities) {
|
||||
if (city.civ !== 0) continue;
|
||||
const stock = state.getCityResourceStock(city);
|
||||
stock.steel = 1e9;
|
||||
stock.hightech = 1e6;
|
||||
}
|
||||
// region. Construction electricity is drawn from the grid as it runs.
|
||||
stockMaterials(state);
|
||||
const coords = state.landCells.find((cell) => {
|
||||
const k = key(cell.x, cell.y);
|
||||
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
|
||||
|
||||
+21
-12
@@ -107,31 +107,36 @@ export class ProductionBuildingAgentTest extends TestCase {
|
||||
agent.sales = [10, 12, 15, 18, 22, 27];
|
||||
fillMaterialStores(state, 0);
|
||||
state._tickBuildingDecisions();
|
||||
this.assertNotNull(agent.upgrading, "the upgrade started");
|
||||
const job = agent.upgrading;
|
||||
this.assertNotNull(state.constructionSiteAt(coords), "the upgrade started");
|
||||
const job = state.constructionSiteAt(coords);
|
||||
this.assertEqual(job.kind, "upgrade");
|
||||
this.assertEqual(job.targetLevel, 1);
|
||||
this.assertEqual(agent.level, 0, "the level waits for the work");
|
||||
// A second read may not replace the work already under way.
|
||||
agent.sales = [100, 200, 300, 400, 500, 600];
|
||||
state._tickBuildingDecisions();
|
||||
this.assertEqual(agent.upgrading, job, "only one upgrade at a time");
|
||||
this.assertEqual(state.constructionSiteAt(coords), job, "only one upgrade at a time");
|
||||
this.assertEqual(state.tileImprovements.get(k), "steel_mill", "the building still stands");
|
||||
}
|
||||
|
||||
test_spending_on_an_upgrade_consumes_cash_and_materials() {
|
||||
const state = smallState();
|
||||
const { agent, k } = placeProducer(state, "steel_mill");
|
||||
const { coords, agent } = placeProducer(state, "steel_mill");
|
||||
agent.cash = 1e12;
|
||||
agent.sales = [10, 12, 15, 18, 22, 27];
|
||||
fillMaterialStores(state, 0);
|
||||
const before = agent.cash;
|
||||
state._tickBuildingDecisions();
|
||||
this.assertLess(agent.cash, before, "the agent paid for its materials");
|
||||
const site = state.constructionSiteAt(coords);
|
||||
this.assertNotNull(site, "the agent opened a site");
|
||||
this.assertLess(agent.cash, before, "the agent handed its cash to the site");
|
||||
this.assertLess(site.budget, before, "the site spent some of it on materials");
|
||||
this.assertGreater(state.getCityResourceStock(cityOf(state, 0)).steel, 0, "stores left over");
|
||||
this.assertNotNull(tileImprovementById(state.tileImprovements.get(k)));
|
||||
}
|
||||
|
||||
test_completing_an_upgrade_raises_the_level_and_its_output() {
|
||||
const state = smallState();
|
||||
const { k, agent } = placeProducer(state, "steel_mill");
|
||||
const { coords, k, agent } = placeProducer(state, "steel_mill");
|
||||
const proto = tileImprovementById("steel_mill");
|
||||
agent.cash = 1e12;
|
||||
agent.sales = [10, 12, 15, 18, 22, 27];
|
||||
@@ -140,11 +145,14 @@ export class ProductionBuildingAgentTest extends TestCase {
|
||||
// Keep the day-boundary decision from firing while the work is completed.
|
||||
state.totalHours = 1;
|
||||
state._tickBuildingDecisions();
|
||||
this.assertNotNull(agent.upgrading);
|
||||
agent.upgrading.totalHours = 0;
|
||||
state._tickIndustry();
|
||||
const site = state.constructionSiteAt(coords);
|
||||
this.assertNotNull(site);
|
||||
site.phase = "construction";
|
||||
site.elapsedHours = 0;
|
||||
site.totalHours = 1;
|
||||
state.advanceHour();
|
||||
this.assertEqual(agent.level, 1, "the level arrived");
|
||||
this.assertNull(agent.upgrading, "the work is done");
|
||||
this.assertNull(state.constructionSiteAt(coords), "the work is done");
|
||||
this.assertGreater(
|
||||
state.resourceBuildingOutputAt(k, proto), baseOutput,
|
||||
"the upgraded building makes more"
|
||||
@@ -153,12 +161,13 @@ export class ProductionBuildingAgentTest extends TestCase {
|
||||
|
||||
test_an_agent_without_enough_cash_waits() {
|
||||
const state = smallState();
|
||||
const { agent } = placeProducer(state, "steel_mill");
|
||||
const { coords, agent } = placeProducer(state, "steel_mill");
|
||||
agent.cash = 0;
|
||||
agent.sales = [10, 12, 15, 18, 22, 27];
|
||||
fillMaterialStores(state, 0);
|
||||
state._tickBuildingDecisions();
|
||||
this.assertNull(agent.upgrading, "no cash, no upgrade");
|
||||
this.assertNull(state.constructionSiteAt(coords), "and no site");
|
||||
}
|
||||
|
||||
test_the_snapshot_ships_the_production_agents_apart_from_the_regions() {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { setupDom, teardownDom } from "./framework/dom.js";
|
||||
import { lineChart } from "../client/js/ui/line_chart.js";
|
||||
|
||||
// The central bank sizes the chart to its panel and caps the height, so the
|
||||
// chart must honour an explicit width/height instead of always drawing at its
|
||||
// own default aspect ratio.
|
||||
export class LineChartTest extends TestCase {
|
||||
async test_chart_honours_the_requested_size() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const svg = lineChart(
|
||||
[
|
||||
{ label: "Energy", colour: "#ff0000", values: [100, 120] },
|
||||
{ label: "Food", colour: "#00ff00", values: [100, 90] },
|
||||
],
|
||||
{ width: 800, height: 200, xLabel: (i) => `M${i}` }
|
||||
);
|
||||
this.assertEqual(svg.getAttribute("viewBox"), "0 0 800 200", "the viewBox uses the options");
|
||||
this.assertEqual(svg.getAttribute("width"), "800", "the width attribute uses the options");
|
||||
this.assertEqual(svg.getAttribute("height"), "200", "the height attribute uses the options");
|
||||
this.assertSize(svg.querySelectorAll(".line-chart-line"), 2, "one line per series");
|
||||
this.assertSize(
|
||||
svg.querySelectorAll(".line-chart-baseline"), 1, "the baseline is drawn"
|
||||
);
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_chart_defaults_to_a_small_size() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const svg = lineChart([{ values: [100] }]);
|
||||
this.assertEqual(svg.getAttribute("viewBox"), "0 0 320 150", "an unset size keeps the default");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@ export class CityModalTest extends TestCase {
|
||||
);
|
||||
const shelters = BUILDINGS.find((b) => b.id === "shelters");
|
||||
this.assertTrue(buildingEffectText(shelters, 2).includes("20%"), "the shelters show their protection");
|
||||
const market = BUILDINGS.find((b) => b.id === "market");
|
||||
this.assertTrue(buildingEffectText(market, 1).includes("production"), "stats keep their effect text");
|
||||
const airport = BUILDINGS.find((b) => b.id === "airport");
|
||||
this.assertTrue(buildingEffectText(airport, 1).includes("production"), "stats keep their effect text");
|
||||
}
|
||||
showTrain(modal, queue = []) {
|
||||
modal.show(
|
||||
|
||||
@@ -669,7 +669,7 @@ export class ResourceModelTest extends TestCase {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1e15);
|
||||
const city = cityOf(state, 0);
|
||||
grantBuilding(state, city, "market", 4);
|
||||
grantBuilding(state, city, "barracks", 4);
|
||||
// Drain the stores so the day's upkeep has to be bought.
|
||||
for (const own of state.cities) {
|
||||
if (own.civ === 0) state.getCityResourceStock(own).steel = 0;
|
||||
@@ -700,7 +700,7 @@ export class ResourceModelTest extends TestCase {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1e15);
|
||||
const city = cityOf(state, 0);
|
||||
const index = grantBuilding(state, city, "market", 1);
|
||||
const index = grantBuilding(state, city, "barracks", 1);
|
||||
city.buildings[index] = 0;
|
||||
state._queueCityRepair(city, index, 1);
|
||||
state._tickRepairs();
|
||||
|
||||
+11
-3
@@ -211,6 +211,13 @@ export class ServerProtocolTest extends TestCase {
|
||||
|
||||
const state = server.gameServer.state;
|
||||
state.budgets.set(0, 1_000_000_000_000);
|
||||
for (const city of state.cities) {
|
||||
if (city.civ !== 0) continue;
|
||||
const stock = state.getCityResourceStock(city);
|
||||
stock.steel = 1e9;
|
||||
stock.hightech = 1e6;
|
||||
stock.energy = 1e9;
|
||||
}
|
||||
const coords = state.landCells.find((cell) => {
|
||||
const k = `${cell.x},${cell.y}`;
|
||||
return state.civAt(cell) === 0 && !state.roads.has(k) && !state.railways.has(k);
|
||||
@@ -223,11 +230,12 @@ export class ServerProtocolTest extends TestCase {
|
||||
|
||||
const snapshot = await client.next("game_state");
|
||||
const k = `${coords.x},${coords.y}`;
|
||||
const sites = snapshot.state.constructionSites[0] || [];
|
||||
this.assertTrue(
|
||||
snapshot.state.roads.some(([x, y]) => x === coords.x && y === coords.y),
|
||||
"the built road is in the snapshot"
|
||||
sites.some((site) => site.coords[0] === coords.x && site.coords[1] === coords.y),
|
||||
"the construction site is in the snapshot"
|
||||
);
|
||||
this.assertTrue(state.roads.has(k), "the server state carries the road");
|
||||
this.assertNotNull(state.constructionSiteAt(coords), "the server state carries the site");
|
||||
client.close();
|
||||
});
|
||||
}
|
||||
|
||||
+15
-15
@@ -66,9 +66,9 @@ export class TestingModeTest extends TestCase {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const city = state.cities.find((c) => c.civ === 0);
|
||||
const market = state.protoBuildings.findIndex((b) => b.id === "market");
|
||||
this.assertTrue(state.requestBuild(city.id, market, true), "the free build is accepted");
|
||||
this.assertEqual(state.getCityBuildingLevel(city, market), 1, "the level rose at once");
|
||||
const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks");
|
||||
this.assertTrue(state.requestBuild(city.id, barracks, true), "the free build is accepted");
|
||||
this.assertEqual(state.getCityBuildingLevel(city, barracks), 1, "the level rose at once");
|
||||
this.assertEqual(state.getBudget(0), BIG_BUDGET, "nothing is charged");
|
||||
this.assertFalse(state.training.has(city.id), "nothing is queued");
|
||||
}
|
||||
@@ -85,9 +85,9 @@ export class TestingModeTest extends TestCase {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const city = state.cities.find((c) => c.civ === 0);
|
||||
const market = state.protoBuildings.findIndex((b) => b.id === "market");
|
||||
this.assertTrue(state.requestBuild(city.id, market), "the paid order is accepted");
|
||||
this.assertEqual(state.getCityBuildingLevel(city, market), 0, "it does not rise at once");
|
||||
const barracks = state.protoBuildings.findIndex((b) => b.id === "barracks");
|
||||
this.assertTrue(state.requestBuild(city.id, barracks), "the paid order is accepted");
|
||||
this.assertEqual(state.getCityBuildingLevel(city, barracks), 0, "it does not rise at once");
|
||||
this.assertLess(state.getBudget(0), BIG_BUDGET, "it was charged");
|
||||
this.assertTrue(state.training.has(city.id), "it went into the queue");
|
||||
}
|
||||
@@ -96,13 +96,13 @@ export class TestingModeTest extends TestCase {
|
||||
const server = makeServer(true);
|
||||
server.peerCiv.set(1, 0);
|
||||
const city = server.state.cities.find((c) => c.civ === 0);
|
||||
const market = server.state.protoBuildings.findIndex((b) => b.id === "market");
|
||||
const barracks = server.state.protoBuildings.findIndex((b) => b.id === "barracks");
|
||||
const budgetBefore = server.state.getBudget(0);
|
||||
this.assertTrue(
|
||||
server.handleOrder(1, { type: "build", city: city.id, building: market, free: true }),
|
||||
server.handleOrder(1, { type: "build", city: city.id, building: barracks, free: true }),
|
||||
"the order is accepted"
|
||||
);
|
||||
this.assertEqual(server.state.getCityBuildingLevel(city, market), 1, "the building went up");
|
||||
this.assertEqual(server.state.getCityBuildingLevel(city, barracks), 1, "the building went up");
|
||||
this.assertEqual(server.state.getBudget(0), budgetBefore, "it was free");
|
||||
this.assertTrue(server.state.testing, "the snapshot flag is set");
|
||||
}
|
||||
@@ -111,13 +111,13 @@ export class TestingModeTest extends TestCase {
|
||||
const server = makeServer(false);
|
||||
server.peerCiv.set(1, 0);
|
||||
const city = server.state.cities.find((c) => c.civ === 0);
|
||||
const market = server.state.protoBuildings.findIndex((b) => b.id === "market");
|
||||
const barracks = server.state.protoBuildings.findIndex((b) => b.id === "barracks");
|
||||
const budgetBefore = server.state.getBudget(0);
|
||||
this.assertTrue(
|
||||
server.handleOrder(1, { type: "build", city: city.id, building: market, free: true }),
|
||||
server.handleOrder(1, { type: "build", city: city.id, building: barracks, free: true }),
|
||||
"the order is still processed"
|
||||
);
|
||||
this.assertEqual(server.state.getCityBuildingLevel(city, market), 0, "it only went into the queue");
|
||||
this.assertEqual(server.state.getCityBuildingLevel(city, barracks), 0, "it only went into the queue");
|
||||
this.assertLess(server.state.getBudget(0), budgetBefore, "the normal cost was charged");
|
||||
this.assertFalse(server.state.testing, "the testing flag stays off");
|
||||
}
|
||||
@@ -127,13 +127,13 @@ export class TestingModeTest extends TestCase {
|
||||
server.setPeerLocal(1, true);
|
||||
server.peerCiv.set(1, 0);
|
||||
const city = server.state.cities.find((c) => c.civ === 0);
|
||||
const market = server.state.protoBuildings.findIndex((b) => b.id === "market");
|
||||
const barracks = server.state.protoBuildings.findIndex((b) => b.id === "barracks");
|
||||
const budgetBefore = server.state.getBudget(0);
|
||||
this.assertTrue(
|
||||
server.handleOrder(1, { type: "build", city: city.id, building: market, free: true }),
|
||||
server.handleOrder(1, { type: "build", city: city.id, building: barracks, free: true }),
|
||||
"the order is accepted"
|
||||
);
|
||||
this.assertEqual(server.state.getCityBuildingLevel(city, market), 1, "the building went up");
|
||||
this.assertEqual(server.state.getCityBuildingLevel(city, barracks), 1, "the building went up");
|
||||
this.assertEqual(server.state.getBudget(0), budgetBefore, "it was free");
|
||||
this.assertFalse(server.state.testing, "the game itself stays a normal game");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user