Compare commits
4
Commits
8f9f72aba7
...
1324128245
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1324128245 | ||
|
|
aa726ff431 | ||
|
|
c8260b2108 | ||
|
|
7c28c4591e |
+43
-6
@@ -357,9 +357,21 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
/* A barrage the player is looking at stands out from the rest. */
|
||||
.target-line.target-selected, .target-ring.target-selected { opacity: 1; }
|
||||
|
||||
/* The delivery graph, shown only in the economic map modes: a coloured line
|
||||
per delivery leg and a dot on every storage node. */
|
||||
/* The delivery graph, shown only in the economic map modes: a white line per
|
||||
delivery leg, an animated merchandise icon riding it and a dot on every
|
||||
storage node. */
|
||||
#layer-resources { pointer-events: none; }
|
||||
.resource-mover {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: -10px;
|
||||
margin-top: -10px;
|
||||
box-sizing: border-box;
|
||||
transform-origin: 50% 50%;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9));
|
||||
}
|
||||
.resource-node {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
@@ -376,7 +388,8 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.resource-icon { vertical-align: middle; }
|
||||
|
||||
/* The migration graph, shown in the population map: a shaft and an arrowhead
|
||||
per city pair, the thickness carrying the people who moved last month. */
|
||||
per city pair, the thickness carrying the people who moved last month, and a
|
||||
flag-textured disc per route drifting along it like the trade merchandise. */
|
||||
.migration-arrow { pointer-events: none; }
|
||||
.migration-arrow-head {
|
||||
position: absolute;
|
||||
@@ -384,6 +397,22 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
transform-origin: 100% 50%;
|
||||
clip-path: polygon(0 0, 100% 50%, 0 100%);
|
||||
}
|
||||
.migrant-mover {
|
||||
position: absolute;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-left: -11px;
|
||||
margin-top: -11px;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid rgba(255, 255, 255, 0.9);
|
||||
border-radius: 50%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
transform-origin: 50% 50%;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
box-shadow: 0 0 6px rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
/* Outlines the selected or inspected tile. The polygon is built in JS from the
|
||||
same warped hexagon as the terrain, so it follows the geography. */
|
||||
@@ -782,10 +811,10 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.map-legend-swatch {
|
||||
.map-legend-icon {
|
||||
width: 16px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
flex: none;
|
||||
}
|
||||
.map-legend-node {
|
||||
@@ -797,6 +826,14 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
border: 1px solid rgba(0, 0, 0, 0.6);
|
||||
flex: none;
|
||||
}
|
||||
/* The trade key's idle-route swatch: the same grey as the unused map lines. */
|
||||
.map-legend-idle {
|
||||
width: 18px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #7b7f85;
|
||||
flex: none;
|
||||
}
|
||||
/* The migration key: a small arrow in the same hue as the map's arrows. */
|
||||
.map-legend-arrow {
|
||||
position: relative;
|
||||
|
||||
+24
-24
@@ -1,6 +1,30 @@
|
||||
// Generated by scripts/generate-devlog.js from `git log`; do not edit.
|
||||
// The pre-commit hook refreshes it so the main menu shows the latest commits.
|
||||
export const DEVLOG = [
|
||||
{
|
||||
"hash": "aa726ff",
|
||||
"date": "2026-09-22",
|
||||
"subject": "Made queued buildings gather their materials and rise ten times faster",
|
||||
"body": "City building orders now keep their production-queue slot but open in a materials phase: the order reserves the market value of its steel and high-tech, buys from the nation's own stores and reachable suppliers once a day, and only joins a construction line once the bill is settled, so a city with no supplier simply waits. The queue, the city panel and the quick view show the gathering progress instead of a stalled bar.\n\nBuildings and tile improvements now take a tenth of the old time; unit training is untouched. Any material money left over is returned when the work completes."
|
||||
},
|
||||
{
|
||||
"hash": "c8260b2",
|
||||
"date": "2026-09-22",
|
||||
"subject": "Better tile building management",
|
||||
"body": ""
|
||||
},
|
||||
{
|
||||
"hash": "7c28c45",
|
||||
"date": "2026-09-22",
|
||||
"subject": "Fixed a typo for Northumbrian towns",
|
||||
"body": ""
|
||||
},
|
||||
{
|
||||
"hash": "8f9f72a",
|
||||
"date": "2026-09-22",
|
||||
"subject": "Added Bulgaria and Northumbria as playable nations",
|
||||
"body": "Bulgaria (Sofia, Plovdiv, Varna...) and Northumbria (Newcastle upon Tyne, Washington, Gateshead...) join the roster with their own colours, currencies, city-name pools and 128x128 flag art. France and Britain also lead with their capitals now, Paris and London."
|
||||
},
|
||||
{
|
||||
"hash": "c2f131d",
|
||||
"date": "2026-09-22",
|
||||
@@ -36,29 +60,5 @@ export const DEVLOG = [
|
||||
"date": "2026-09-22",
|
||||
"subject": "Replaced the GDP tax with national sales and tariff taxes",
|
||||
"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": "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": "9690c68",
|
||||
"date": "2026-09-21",
|
||||
"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": "f3e94a3",
|
||||
"date": "2026-09-21",
|
||||
"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."
|
||||
}
|
||||
];
|
||||
|
||||
+142
-100
@@ -969,7 +969,8 @@ export const panelMethods = {
|
||||
// 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.
|
||||
// raises its budget on its own, so the only control is cancelling the work;
|
||||
// the warning "!" is kept for a site no supplier can reach at all.
|
||||
_renderTileWorks(coords) {
|
||||
const $el = $("#tile-works");
|
||||
if (!$el.length) return;
|
||||
@@ -988,9 +989,10 @@ export const panelMethods = {
|
||||
? `Upgrading ${name} to level ${site.targetLevel} — `
|
||||
: `Building ${name} — `;
|
||||
if (site.phase === "materials") {
|
||||
const unreachable = site.unreachable || [];
|
||||
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> "));
|
||||
if (unreachable.length) {
|
||||
$label.prepend($("<span class='site-warning' title='No supplier in reach'>!</span> "));
|
||||
}
|
||||
$el.append($label);
|
||||
for (const id of ["steel", "hightech"]) {
|
||||
@@ -1007,27 +1009,34 @@ export const panelMethods = {
|
||||
$bar
|
||||
);
|
||||
}
|
||||
if (!site.stalled) {
|
||||
$el.append($("<span class='gather-spinner' title='Gathering materials'></span>"));
|
||||
if (unreachable.length) {
|
||||
const names = unreachable.map((id) => resourceById(id).name).join(", ");
|
||||
$el.append(
|
||||
$("<div class='site-material-label'></div>").text(
|
||||
`Budget ${currencySymbol()}${compact(site.budget || 0)}`
|
||||
$("<div class='site-material-label stalled'></div>").text(
|
||||
`No supplier can reach this site for ${names}. ` +
|
||||
"It stays stalled until the trade network does."
|
||||
)
|
||||
);
|
||||
} else if (site.stalled) {
|
||||
// The budget climbs on its own; the player is only told why the work
|
||||
// is waiting, with no button to press.
|
||||
$el.append(
|
||||
$("<div class='site-material-label stalled'></div>").text(
|
||||
"The market is above the budgeted prices. " +
|
||||
"The budget rises 1% a day, doubling each day, until the materials can be bought."
|
||||
)
|
||||
);
|
||||
} 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));
|
||||
$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)}`
|
||||
)
|
||||
);
|
||||
$el.append(
|
||||
$("<div class='site-actions'></div>").append(this._cancelSiteButton(coords))
|
||||
);
|
||||
} else {
|
||||
const total = Math.max(site.totalHours || 0, 0.0001);
|
||||
const elapsed = site.elapsedHours || 0;
|
||||
@@ -1039,6 +1048,11 @@ export const panelMethods = {
|
||||
$("<div class='city-production-label'></div>").text(`${building}${remaining} h left`),
|
||||
$bar
|
||||
);
|
||||
// Once building has begun the work can still be called off; only the
|
||||
// materials already bought and the time spent are lost.
|
||||
$el.append(
|
||||
$("<div class='site-actions'></div>").append(this._cancelSiteButton(coords))
|
||||
);
|
||||
}
|
||||
}
|
||||
// A production building upgrades itself: show the work its agent has begun.
|
||||
@@ -1080,6 +1094,15 @@ export const panelMethods = {
|
||||
const building = entry.kind === "building";
|
||||
const proto = building ? BUILDINGS[entry.protoIndex] : this.protoUnits[entry.protoIndex];
|
||||
const name = proto ? proto.name : (building ? "Building" : "Unit");
|
||||
if (building && entry.phase === "materials") {
|
||||
const $bar = $("<div class='progress city-production-bar'></div>");
|
||||
$bar.append($("<div></div>").css("width", `${this._gatherFraction(entry) * 100}%`));
|
||||
$el.append(
|
||||
$("<div class='city-production-label'></div>").text(`Gathering materials for ${name}`),
|
||||
$bar
|
||||
);
|
||||
return;
|
||||
}
|
||||
const verb = building ? "Building" : "Training";
|
||||
const total = Math.max(entry.totalHours || 0, 0.0001);
|
||||
const elapsed = entry.elapsedHours || 0;
|
||||
@@ -1181,13 +1204,15 @@ export const panelMethods = {
|
||||
// One square icon button per improvement, grouped under a category heading:
|
||||
// military first, then transportation, then economic (resource producers),
|
||||
// plus a control to remove the one already standing on the tile. Shown only
|
||||
// on a controlled land tile; an unaffordable improvement is greyed out.
|
||||
// on a controlled land tile. Money never disables a button: a nation funds
|
||||
// any order by borrowing from its central bank, so what it cannot yet pay for
|
||||
// simply becomes debt. While a work is under way every button is hidden, and
|
||||
// the only control left is the cancel drawn with the works progress.
|
||||
_renderImprovementActions(coords, buildable, current) {
|
||||
const $row = $("#tile-improvement-actions");
|
||||
if (!$row.length) return;
|
||||
$row.empty();
|
||||
if (!buildable) return;
|
||||
const budget = (this.snapshot.viewerStats || {}).budget || 0;
|
||||
const testing = !!this.testing;
|
||||
const gdp = (this.snapshot.viewerStats || {}).gdp || 0;
|
||||
const speed = (this.snapshot.viewerStats || {}).constructionSpeed || 0;
|
||||
@@ -1202,90 +1227,86 @@ export const panelMethods = {
|
||||
);
|
||||
|
||||
// Military tile works. They cost resources and take production time, so the
|
||||
// tooltip names the expected hours alongside the upkeep.
|
||||
// tooltip names the expected hours alongside the upkeep. A tile with a work
|
||||
// under way offers none of them, only the transport controls and a cancel.
|
||||
const military = [];
|
||||
for (const improvement of TILE_IMPROVEMENTS) {
|
||||
if (improvement.coastal && !this._tileIsCoastal(coords)) continue;
|
||||
const built = !!(standing && standing.id === improvement.id);
|
||||
const affordable = budget >= improvement.buildCost;
|
||||
const buildHours = ECONOMY.trainingHours(gdp, improvement.buildCost) / (1 + speed);
|
||||
const title = standing && !built
|
||||
? `${improvement.name} — another improvement already stands here`
|
||||
: affordable
|
||||
? `${improvement.name} — ${this._improvementCostText(improvement, prices)} · ` +
|
||||
`${this._improvementUpkeepText(improvement)}/day · ${hours(buildHours)} to build`
|
||||
: `${improvement.name} — needs ${this._improvementCostText(improvement, prices)}`;
|
||||
const $button = this._improvementButton(improvement, {
|
||||
className: "tile-btn",
|
||||
title,
|
||||
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))
|
||||
);
|
||||
if (!pending) {
|
||||
for (const improvement of TILE_IMPROVEMENTS) {
|
||||
if (improvement.coastal && !this._tileIsCoastal(coords)) continue;
|
||||
const built = !!(standing && standing.id === improvement.id);
|
||||
const buildHours = ECONOMY.buildingHours(gdp, improvement.buildCost) / (1 + speed);
|
||||
const title = standing && !built
|
||||
? `${improvement.name} — another improvement already stands here`
|
||||
: `${improvement.name} — ${this._improvementCostText(improvement, prices)} · ` +
|
||||
`${this._improvementUpkeepText(improvement)}/day · ${hours(buildHours)} to build`;
|
||||
const $button = this._improvementButton(improvement, {
|
||||
className: "tile-btn",
|
||||
title,
|
||||
disabled: !!standing,
|
||||
current: built,
|
||||
prices,
|
||||
});
|
||||
$button.on("click", () => this._buildTileImprovement(coords, improvement.id));
|
||||
military.push($button);
|
||||
}
|
||||
}
|
||||
|
||||
// Every button disappears while a work is under way: a tile under
|
||||
// construction offers nothing but the cancel control drawn with its
|
||||
// progress above, so an accidental click cannot start a second order.
|
||||
// Cancelling restores the full set.
|
||||
|
||||
// Transportation: roads and railways, bought outright and laid at once.
|
||||
const transport = [];
|
||||
for (const improvement of TRANSPORT_IMPROVEMENTS) {
|
||||
const isCurrent = current === improvement.id;
|
||||
const affordable = budget >= improvement.buildCost;
|
||||
const title = isCurrent
|
||||
? `${improvement.name} — already built on this tile`
|
||||
: affordable
|
||||
? `${improvement.name} — ${this._improvementCostText(improvement, prices)} · ` +
|
||||
`${this._improvementUpkeepText(improvement)}/day upkeep`
|
||||
: `${improvement.name} — needs ${this._improvementCostText(improvement, prices)}`;
|
||||
const $button = this._improvementButton(improvement, {
|
||||
className: "transport-btn",
|
||||
title,
|
||||
disabled: isCurrent || !!pending || !affordable,
|
||||
current: isCurrent,
|
||||
prices,
|
||||
});
|
||||
$button.on("click", () => this._buildImprovement(coords, improvement.id, false));
|
||||
transport.push($button);
|
||||
// A local test game may raise it for free and at once.
|
||||
if (testing && !isCurrent) {
|
||||
const $free = this._improvementButton(improvement, {
|
||||
className: "free-button",
|
||||
costLabel: "free",
|
||||
title: `${improvement.name} — build free and instantly (testing)`,
|
||||
disabled: false,
|
||||
current: false,
|
||||
if (!pending) {
|
||||
for (const improvement of TRANSPORT_IMPROVEMENTS) {
|
||||
const isCurrent = current === improvement.id;
|
||||
const title = isCurrent
|
||||
? `${improvement.name} — already built on this tile`
|
||||
: `${improvement.name} — ${this._improvementCostText(improvement, prices)} · ` +
|
||||
`${this._improvementUpkeepText(improvement)}/day upkeep`;
|
||||
const $button = this._improvementButton(improvement, {
|
||||
className: "transport-btn",
|
||||
title,
|
||||
disabled: isCurrent,
|
||||
current: isCurrent,
|
||||
prices,
|
||||
});
|
||||
$free.on("click", () => this._buildImprovement(coords, improvement.id, true));
|
||||
transport.push($free);
|
||||
$button.on("click", () => this._buildImprovement(coords, improvement.id, false));
|
||||
transport.push($button);
|
||||
// A local test game may raise it for free and at once.
|
||||
if (testing && !isCurrent) {
|
||||
const $free = this._improvementButton(improvement, {
|
||||
className: "free-button",
|
||||
costLabel: "free",
|
||||
title: `${improvement.name} — build free and instantly (testing)`,
|
||||
disabled: false,
|
||||
current: false,
|
||||
});
|
||||
$free.on("click", () => this._buildImprovement(coords, improvement.id, true));
|
||||
transport.push($free);
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
const proto = transportImprovement(current);
|
||||
const name = proto ? proto.name : current;
|
||||
transport.push(
|
||||
$("<button type='button' class='aero improvement-btn remove'></button>")
|
||||
.text(`Remove ${name}`)
|
||||
.on("click", () => this._removeImprovement(coords))
|
||||
);
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
const proto = transportImprovement(current);
|
||||
const name = proto ? proto.name : current;
|
||||
transport.push(
|
||||
$("<button type='button' class='aero improvement-btn remove'></button>")
|
||||
.text(`Remove ${name}`)
|
||||
.on("click", () => this._removeImprovement(coords))
|
||||
);
|
||||
}
|
||||
|
||||
// Economic: resource producers. They live on their own land, take
|
||||
// production time and feed the delivery graph; only one may stand on a
|
||||
// tile.
|
||||
const economic = [];
|
||||
if (!isResource) {
|
||||
if (!isResource && !pending) {
|
||||
for (const producer of RESOURCE_BUILDINGS) {
|
||||
if (producer.coastal && !this._tileIsCoastal(coords)) continue;
|
||||
const built = !!(standing && standing.id === producer.id);
|
||||
const affordable = budget >= producer.buildCost;
|
||||
const buildHours = ECONOMY.trainingHours(gdp, producer.buildCost) / (1 + speed);
|
||||
const buildHours = ECONOMY.buildingHours(gdp, producer.buildCost) / (1 + speed);
|
||||
// Some producers are locked behind a technology (the fusion plant).
|
||||
const techIndex = producer.requiresTechnology
|
||||
? TECHNOLOGIES.findIndex((tech) => tech.id === producer.requiresTechnology)
|
||||
@@ -1298,14 +1319,12 @@ export const panelMethods = {
|
||||
? `${producer.name} — requires ${TECHNOLOGIES[techIndex].name}`
|
||||
: standing && !built
|
||||
? `${producer.name} — another building already stands here`
|
||||
: affordable
|
||||
? `${producer.name} — ${out} · ${this._improvementCostText(producer, prices)} · ` +
|
||||
`${hours(buildHours)} to build`
|
||||
: `${producer.name} — needs ${this._improvementCostText(producer, prices)}`;
|
||||
: `${producer.name} — ${out} · ${this._improvementCostText(producer, prices)} · ` +
|
||||
`${hours(buildHours)} to build`;
|
||||
const $button = this._improvementButton(producer, {
|
||||
className: "tile-btn resource-btn",
|
||||
title,
|
||||
disabled: !!standing || !!pending || !affordable || locked,
|
||||
disabled: !!standing || locked,
|
||||
current: built,
|
||||
prices,
|
||||
});
|
||||
@@ -1313,7 +1332,7 @@ export const panelMethods = {
|
||||
economic.push($button);
|
||||
}
|
||||
}
|
||||
if (standing) {
|
||||
if (standing && !pending) {
|
||||
const name = standingProto ? standingProto.name : standing.id;
|
||||
const $remove = $("<button type='button' class='aero improvement-btn remove'></button>")
|
||||
.text(`Remove ${name}`)
|
||||
@@ -1380,11 +1399,13 @@ export const panelMethods = {
|
||||
});
|
||||
},
|
||||
|
||||
_increaseSiteBudget(coords) {
|
||||
this.sendOrder({
|
||||
type: "increase_site_budget",
|
||||
coords: [coords.x, coords.y],
|
||||
});
|
||||
// The one control a tile under construction still offers, wherever its
|
||||
// progress is drawn. Cancelling loses the money and materials already sunk
|
||||
// into the work and raises nothing.
|
||||
_cancelSiteButton(coords) {
|
||||
return $("<button type='button' class='aero'></button>")
|
||||
.text("Cancel work")
|
||||
.on("click", () => this._cancelSite(coords));
|
||||
},
|
||||
|
||||
_cancelSite(coords) {
|
||||
@@ -1835,6 +1856,13 @@ export const panelMethods = {
|
||||
const building = entry.kind === "building";
|
||||
const proto = building ? BUILDINGS[entry.protoIndex] : this.protoUnits[entry.protoIndex];
|
||||
const name = proto ? proto.name : (building ? "building" : "unit");
|
||||
// A building order spends its first days buying its steel and high-tech, so
|
||||
// show that gathering rather than a construction clock that has not started.
|
||||
if (building && entry.phase === "materials") {
|
||||
this._trainingCityId = this.selectedCityId;
|
||||
this.cityModal.setGatheringProgress(name, this._gatherFraction(entry));
|
||||
return;
|
||||
}
|
||||
const verb = building ? "Building" : "Training";
|
||||
if (this._trainingCityId !== this.selectedCityId) {
|
||||
this._trainingCityId = this.selectedCityId;
|
||||
@@ -1844,6 +1872,20 @@ export const panelMethods = {
|
||||
}
|
||||
},
|
||||
|
||||
// How far a building order has got through gathering its material bill, as the
|
||||
// average of each storable material's share bought. 1 when nothing is needed.
|
||||
_gatherFraction(entry) {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
for (const id of ["steel", "hightech"]) {
|
||||
const need = (entry.needed && entry.needed[id]) || 0;
|
||||
if (!(need > 0)) continue;
|
||||
sum += Math.min(1, ((entry.bought && entry.bought[id]) || 0) / need);
|
||||
count += 1;
|
||||
}
|
||||
return count > 0 ? sum / count : 1;
|
||||
},
|
||||
|
||||
// How many levels of each building are already waiting in the city's shared
|
||||
// production queue, so the buildings tab prices the next order correctly.
|
||||
_queuedBuildingLevels(cityId) {
|
||||
|
||||
@@ -57,7 +57,9 @@ export class MapView {
|
||||
if (this.$targets && this.$targets.length) this.$targets.after(this.$resources);
|
||||
else this.$world.append(this.$resources);
|
||||
}
|
||||
this.resourceGraph = { nodes: [], links: [] };
|
||||
this.resourceGraph = { nodes: [], links: [], edges: [] };
|
||||
// The merchandise icons riding the delivery legs, repositioned every frame.
|
||||
this._deliveryMovers = [];
|
||||
// City-to-city migration over the last month, drawn instead of the delivery
|
||||
// graph while the population map is active.
|
||||
this.migrationGraph = { links: [] };
|
||||
|
||||
@@ -110,13 +110,13 @@ 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.
|
||||
// tile: a spinner while materials are gathered, a red mark when no supplier
|
||||
// can reach the work at all, 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)}`)
|
||||
.map((site) => `${site.coords[0]},${site.coords[1]}:${site.phase}:${(site.unreachable || []).length ? 1 : 0}:${Math.round(site.elapsedHours || 0)}`)
|
||||
.join("|");
|
||||
if (signature === this._siteSignature) return;
|
||||
this._siteSignature = signature;
|
||||
@@ -135,7 +135,7 @@ export const entityMethods = {
|
||||
const el = document.createElement("div");
|
||||
el.className = "construction-marker";
|
||||
if (site.phase === "materials") {
|
||||
if (site.stalled) {
|
||||
if ((site.unreachable || []).length) {
|
||||
el.classList.add("stalled");
|
||||
el.textContent = "!";
|
||||
} else {
|
||||
|
||||
+140
-15
@@ -5,7 +5,7 @@
|
||||
// live in ./political.js; this file only decides what is on screen.
|
||||
|
||||
import { HEX_W, mapToLocal } from "../../../shared/hex.js";
|
||||
import { POLITICAL_ZOOM } from "./constants.js";
|
||||
import { CAMERA_TILT, POLITICAL_ZOOM } from "./constants.js";
|
||||
import { ECONOMIC_GRADIENT, ECONOMIC_MODES, economicColour, isEconomicMode } from "./economic.js";
|
||||
import { NEUTRAL_LAND, POLITICAL_WATER } from "./political.js";
|
||||
import { isWaterTile } from "./textures.js";
|
||||
@@ -15,12 +15,34 @@ import { RESOURCES } from "../../../shared/data/resources.js";
|
||||
|
||||
export const MAP_MODES = ["terrain", "political", "gdp", "population"];
|
||||
|
||||
// The migration arrows: one hue so they stay legible over the population
|
||||
// gradient, with the month's flow mapped between these thicknesses.
|
||||
// The migration map: one hue for the route so it stays legible over the
|
||||
// population gradient, with the month's flow mapped between these thicknesses.
|
||||
// A disc textured with the origin nation's flag rides each route, mirroring the
|
||||
// merchandise icons that ride the trade graph.
|
||||
const MIGRATION_COLOUR = "#ff3ec8";
|
||||
const MIGRATION_ARROW_MIN = 1.5;
|
||||
const MIGRATION_ARROW_MAX = 12;
|
||||
|
||||
// The delivery graph draws one line per leg, so the goods carried are
|
||||
// shown by the merchandise icon riding the line rather than by the line's
|
||||
// colour. Every icon makes the round trip in this many milliseconds. Routes
|
||||
// with no goods moving along them are drawn in the idle grey instead.
|
||||
const DELIVERY_COLOUR = "#ffffff";
|
||||
const DELIVERY_IDLE_COLOUR = "#7b7f85";
|
||||
const DELIVERY_PERIOD_MS = 2600;
|
||||
const RESOURCE_META = Object.fromEntries(RESOURCES.map((r) => [r.id, r]));
|
||||
|
||||
// A stable offset in 0..1 so every leg starts its run at a different point of
|
||||
// the cycle, spreading the icons out instead of moving them in lockstep.
|
||||
function deliveryPhase(resource, fromX, fromY, toX, toY) {
|
||||
const seed = `${resource}:${fromX},${fromY}->${toX},${toY}`;
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
hash = Math.imul(hash ^ seed.charCodeAt(i), 16777619);
|
||||
}
|
||||
return ((hash >>> 0) % 997) / 997;
|
||||
}
|
||||
|
||||
export const modeMethods = {
|
||||
// Selects a mode and repaints. Terrain mode keeps the far-out auto-switch, so
|
||||
// "political" forces the political map at any zoom while "terrain" only shows
|
||||
@@ -76,6 +98,7 @@ export const modeMethods = {
|
||||
this.resourceGraph = {
|
||||
nodes: Array.isArray(graph.nodes) ? graph.nodes : [],
|
||||
links: Array.isArray(graph.links) ? graph.links : [],
|
||||
edges: Array.isArray(graph.edges) ? graph.edges : [],
|
||||
};
|
||||
this._renderResourceGraph();
|
||||
},
|
||||
@@ -88,25 +111,57 @@ export const modeMethods = {
|
||||
if (!this.$resources || !this.$resources.length) return;
|
||||
const migration = this.mapMode === "population";
|
||||
const graph = migration ? this.migrationGraph : this.resourceGraph;
|
||||
if (!this.economic || graph.links.length === 0) {
|
||||
const empty = graph.links.length === 0 && (!graph.edges || graph.edges.length === 0);
|
||||
if (!this.economic || empty) {
|
||||
if (this.$resources[0].firstChild) this.$resources.empty();
|
||||
this._deliveryMovers = [];
|
||||
this._renderGraphLegend();
|
||||
return;
|
||||
}
|
||||
if (migration) this._renderMigrationArrows(graph.links);
|
||||
else this._renderDeliveryLines(graph);
|
||||
if (migration) {
|
||||
this._deliveryMovers = [];
|
||||
this._renderMigrationFlow(graph.links);
|
||||
} else {
|
||||
this._renderDeliveryLines(graph);
|
||||
}
|
||||
this._renderGraphLegend();
|
||||
},
|
||||
|
||||
// One line per delivery leg and a dot on every storage node.
|
||||
// Every potential route grey, the routes that actually carried goods white
|
||||
// over them, a dot on every storage node and a merchandise icon per active
|
||||
// leg, animated along the line by `_animateDeliveryIcons` so the goods moving
|
||||
// between regions are obvious at a glance.
|
||||
_renderDeliveryLines(graph) {
|
||||
const colours = Object.fromEntries(RESOURCES.map((r) => [r.id, r.colour]));
|
||||
const fragment = document.createDocumentFragment();
|
||||
const movers = [];
|
||||
for (const edge of graph.edges || []) {
|
||||
const [fromX, fromY, toX, toY] = edge;
|
||||
fragment.appendChild(
|
||||
makeSegment(mapToLocal(fromX, fromY), mapToLocal(toX, toY), DELIVERY_IDLE_COLOUR, 2)
|
||||
);
|
||||
}
|
||||
for (const link of graph.links) {
|
||||
const [fromX, fromY, toX, toY, resource] = link;
|
||||
const a = mapToLocal(fromX, fromY);
|
||||
const b = mapToLocal(toX, toY);
|
||||
fragment.appendChild(makeSegment(a, b, colours[resource] || "#ffffff", 3));
|
||||
fragment.appendChild(makeSegment(a, b, DELIVERY_COLOUR, 3));
|
||||
const meta = RESOURCE_META[resource];
|
||||
if (!meta || !meta.icon) continue;
|
||||
const mover = document.createElement("img");
|
||||
mover.className = "resource-mover";
|
||||
mover.src = `assets/${meta.icon}`;
|
||||
mover.alt = meta.name;
|
||||
mover.draggable = false;
|
||||
// The layer is foreshortened on Y; undo the tilt on the sprite so the
|
||||
// merchandise stays upright while its centre rides the line.
|
||||
mover.style.transform = `scaleY(${1 / CAMERA_TILT})`;
|
||||
movers.push({
|
||||
el: mover,
|
||||
a,
|
||||
b,
|
||||
phase: deliveryPhase(resource, fromX, fromY, toX, toY),
|
||||
});
|
||||
fragment.appendChild(mover);
|
||||
}
|
||||
for (const node of graph.nodes) {
|
||||
const point = mapToLocal(node[0], node[1]);
|
||||
@@ -117,23 +172,69 @@ export const modeMethods = {
|
||||
fragment.appendChild(dot);
|
||||
}
|
||||
this.$resources.empty()[0].appendChild(fragment);
|
||||
this._deliveryMovers = movers;
|
||||
this._animateDeliveryIcons();
|
||||
},
|
||||
|
||||
// Slides every graph icon along its leg (merchandise on the trade routes,
|
||||
// flags on the migration ones), the whole fleet sharing one wall-clock cycle
|
||||
// so a snapshot rebuild leaves the animation uninterrupted.
|
||||
_animateDeliveryIcons() {
|
||||
const movers = this._deliveryMovers;
|
||||
if (!movers || movers.length === 0) return;
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
const travel = (now % DELIVERY_PERIOD_MS) / DELIVERY_PERIOD_MS;
|
||||
for (const mover of movers) {
|
||||
const t = (travel + mover.phase) % 1;
|
||||
mover.el.style.left = `${mover.a.x + (mover.b.x - mover.a.x) * t}px`;
|
||||
mover.el.style.top = `${mover.a.y + (mover.b.y - mover.a.y) * t}px`;
|
||||
// Fade at both ends, so the jump back to the origin is hidden and the
|
||||
// fleet reads as a continuous flow.
|
||||
mover.el.style.opacity = Math.min(1, t / 0.1, (1 - t) / 0.1).toFixed(3);
|
||||
}
|
||||
},
|
||||
|
||||
// One arrow per city pair, its thickness scaled to the people who moved over
|
||||
// the month so the volume reads at a glance.
|
||||
_renderMigrationArrows(links) {
|
||||
// the month so the volume reads at a glance, with a flag disc per route
|
||||
// drifting from origin to destination like the trade graph's merchandise.
|
||||
_renderMigrationFlow(links) {
|
||||
let max = 0;
|
||||
for (const link of links) if (link[4] > max) max = link[4];
|
||||
const fragment = document.createDocumentFragment();
|
||||
const movers = [];
|
||||
for (const link of links) {
|
||||
const [fromX, fromY, toX, toY, people] = link;
|
||||
const [fromX, fromY, toX, toY, people, civ] = link;
|
||||
const a = mapToLocal(fromX, fromY);
|
||||
const b = mapToLocal(toX, toY);
|
||||
const width = MIGRATION_ARROW_MIN +
|
||||
(MIGRATION_ARROW_MAX - MIGRATION_ARROW_MIN) * (max > 0 ? people / max : 0);
|
||||
fragment.appendChild(makeArrow(a, b, MIGRATION_COLOUR, width));
|
||||
const flag = this._migrationFlag(civ);
|
||||
if (!flag) continue;
|
||||
const mover = document.createElement("div");
|
||||
mover.className = "migrant-mover";
|
||||
mover.style.backgroundImage = `url("assets/flags/${flag}")`;
|
||||
// The layer is foreshortened on Y; undo the tilt on the disc so the flag
|
||||
// stays circular while its centre rides the route.
|
||||
mover.style.transform = `scaleY(${1 / CAMERA_TILT})`;
|
||||
movers.push({
|
||||
el: mover,
|
||||
a,
|
||||
b,
|
||||
phase: deliveryPhase(`migrant:${civ}`, fromX, fromY, toX, toY),
|
||||
});
|
||||
fragment.appendChild(mover);
|
||||
}
|
||||
this.$resources.empty()[0].appendChild(fragment);
|
||||
this._deliveryMovers = movers;
|
||||
this._animateDeliveryIcons();
|
||||
},
|
||||
|
||||
// The flag art of the nation an origin city belongs to, or null when the
|
||||
// catalogue is not in yet. `this.civilisations` is indexed by civ number.
|
||||
_migrationFlag(civ) {
|
||||
const entry = this.civilisations && this.civilisations[civ];
|
||||
return entry && entry.flag ? entry.flag : null;
|
||||
},
|
||||
|
||||
// The key for the economic overlay's graph. The population mode keys the
|
||||
@@ -144,7 +245,8 @@ export const modeMethods = {
|
||||
if (!$el || !$el.length) return;
|
||||
const migration = this.mapMode === "population";
|
||||
const graph = migration ? this.migrationGraph : this.resourceGraph;
|
||||
if (!this.economic || graph.links.length === 0) {
|
||||
const hasEdges = !migration && graph.edges && graph.edges.length > 0;
|
||||
if (!this.economic || (graph.links.length === 0 && !hasEdges)) {
|
||||
$el.addClass("hidden");
|
||||
return;
|
||||
}
|
||||
@@ -159,7 +261,7 @@ export const modeMethods = {
|
||||
$list.append(
|
||||
$("<div class='map-legend-resource'></div>")
|
||||
.append($("<span class='map-legend-arrow'></span>"))
|
||||
.append($("<span></span>").text("People between cities"))
|
||||
.append($("<span></span>").text("People between cities (flags show origin)"))
|
||||
);
|
||||
$list.append(
|
||||
$("<div class='map-legend-resource'></div>")
|
||||
@@ -173,10 +275,21 @@ export const modeMethods = {
|
||||
if (!present.has(resource.id)) continue;
|
||||
$list.append(
|
||||
$("<div class='map-legend-resource'></div>")
|
||||
.append($("<span class='map-legend-swatch'></span>").css("background", resource.colour))
|
||||
.append(
|
||||
$("<img class='map-legend-icon' />")
|
||||
.attr("src", `assets/${resource.icon}`)
|
||||
.attr("alt", resource.name)
|
||||
)
|
||||
.append($("<span></span>").text(resource.name))
|
||||
);
|
||||
}
|
||||
if (this._hasIdleEdges(graph)) {
|
||||
$list.append(
|
||||
$("<div class='map-legend-resource'></div>")
|
||||
.append($("<span class='map-legend-idle'></span>"))
|
||||
.append($("<span></span>").text("Idle route"))
|
||||
);
|
||||
}
|
||||
$list.append(
|
||||
$("<div class='map-legend-resource'></div>")
|
||||
.append($("<span class='map-legend-node'></span>"))
|
||||
@@ -184,6 +297,18 @@ export const modeMethods = {
|
||||
);
|
||||
},
|
||||
|
||||
// Whether any potential route is carrying nothing: the ones the graph draws
|
||||
// grey. Keyed to the same directed pair the server ships as an edge.
|
||||
_hasIdleEdges(graph) {
|
||||
if (!graph.edges || graph.edges.length === 0) return false;
|
||||
const active = new Set(
|
||||
graph.links.map((link) => `${link[0]},${link[1]}->${link[2]},${link[3]}`)
|
||||
);
|
||||
return graph.edges.some(
|
||||
(edge) => !active.has(`${edge[0]},${edge[1]}->${edge[2]},${edge[3]}`)
|
||||
);
|
||||
},
|
||||
|
||||
// Political shows when the user asked for it, or while terrain mode is
|
||||
// zoomed far enough out. Economic modes are exclusive with it.
|
||||
_effectivePolitical() {
|
||||
|
||||
@@ -95,6 +95,7 @@ export const motionMethods = {
|
||||
this._applyEntityWrap();
|
||||
this._drawPaths();
|
||||
this._drawStrikeTargets();
|
||||
this._animateDeliveryIcons();
|
||||
this._flushFog();
|
||||
this._glRender();
|
||||
},
|
||||
|
||||
@@ -391,10 +391,15 @@ export class CityModal {
|
||||
for (const index of group.indices) {
|
||||
const entry = queue[index];
|
||||
const elapsed = index === 0 ? entry.elapsedHours || 0 : 0;
|
||||
cumulative += Math.max(entry.totalHours - elapsed, 0);
|
||||
// A building still gathering its materials has not started building, so
|
||||
// it adds no time to the queue's readiness estimate.
|
||||
const gather = entry.kind === "building" && entry.phase === "materials";
|
||||
cumulative += gather ? 0 : Math.max(entry.totalHours - elapsed, 0);
|
||||
if (index === group.indices[0]) firstReady = cumulative;
|
||||
}
|
||||
const count = group.indices.length;
|
||||
const gathering = queue[group.indices[0]].kind === "building" &&
|
||||
queue[group.indices[0]].phase === "materials";
|
||||
const name = group.kind === "building"
|
||||
? this.buildingNames.get(group.protoIndex) || "Building"
|
||||
: this.protoNames.get(group.protoIndex) || "Unit";
|
||||
@@ -404,9 +409,11 @@ export class CityModal {
|
||||
$info.append($("<div class='name'></div>").text(`${groupIndex + 1}. ${label}`));
|
||||
$info.append(
|
||||
$("<div class='desc'></div>").text(
|
||||
count > 1
|
||||
? `Next in ${hours(firstReady)} · all ready in ${hours(cumulative)}`
|
||||
: `Ready in ${hours(cumulative)}`
|
||||
gathering
|
||||
? "Gathering materials"
|
||||
: count > 1
|
||||
? `Next in ${hours(firstReady)} · all ready in ${hours(cumulative)}`
|
||||
: `Ready in ${hours(cumulative)}`
|
||||
)
|
||||
);
|
||||
const $actions = $("<div class='actions'></div>");
|
||||
@@ -435,6 +442,15 @@ export class CityModal {
|
||||
this.$trainProgress.css("width", "100%");
|
||||
}
|
||||
|
||||
// The selected city's head of queue is a building still buying its steel and
|
||||
// high-tech: show how far that gathering has got, with no hours figure since
|
||||
// the construction clock has not started.
|
||||
setGatheringProgress(name, fraction = 0) {
|
||||
this.$trainLabel.text(`Gathering materials for ${name}…`);
|
||||
this.$trainProgress.css("width", `${Math.min(100, Math.max(0, fraction) * 100)}%`);
|
||||
this.$trainStatus.removeClass("hidden");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- buildings --
|
||||
|
||||
_makeBuildingRow(proto, index) {
|
||||
@@ -495,7 +511,7 @@ export class CityModal {
|
||||
const parts = [];
|
||||
if (level > 0) parts.push(`Upkeep ${currencySymbol()}${groupDigits(Math.round(buildingUpkeep(row.proto, level)))}/h`);
|
||||
const cost = buildingBuildCost(row.proto, nextLevel);
|
||||
const duration = ECONOMY.trainingHours(gdp, cost) * this._constructionTimeFactor();
|
||||
const duration = ECONOMY.buildingHours(gdp, cost) * this._constructionTimeFactor();
|
||||
parts.push(`Build cost ${currencySymbol()}${groupDigits(this._quotedCost(cost, row.proto, nextLevel))} · ${hours(duration)}`);
|
||||
const requirement = this._buildRequirement(row.proto);
|
||||
if (requirement) parts.push(requirement);
|
||||
|
||||
@@ -196,8 +196,6 @@ 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":
|
||||
@@ -353,10 +351,6 @@ 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));
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export const CIVILISATIONS = [
|
||||
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"]
|
||||
cityNames: ["Newcastle upon Tyne", "Washington", "New York", "Gateshead", "Morpeth", "Ashington", "Blyth", "Newsham", "Blaydon", "Rothbury", "Longbenton", "Snitter", "Alnwick", "Consett", "Tynemouth", "Whickham"]
|
||||
},
|
||||
{
|
||||
id: "slovenia",
|
||||
|
||||
@@ -86,6 +86,10 @@ export const ECONOMY = {
|
||||
capitalTileProductionMultiplier: 2.5,
|
||||
citySurroundingProductionBonus: 0.3,
|
||||
gdpPerProductionPoint: 100_000,
|
||||
// Building and tile-improvement construction runs this much faster than
|
||||
// training: a building takes a tenth of the hours a unit of the same cost
|
||||
// would. Unit training is untouched.
|
||||
buildingTimeMultiplier: 0.1,
|
||||
// A nation opens the game on 1 January 2000 with a tenth of its starting GDP.
|
||||
startingBudgetFraction: 0.1,
|
||||
startingBudget: 1_000_000_000,
|
||||
@@ -106,6 +110,12 @@ export const ECONOMY = {
|
||||
return cost / capacity;
|
||||
},
|
||||
|
||||
// The hours a building or tile improvement of `cost` takes to rise, ten times
|
||||
// quicker than training something of the same cost.
|
||||
buildingHours(gdp, cost) {
|
||||
return this.trainingHours(gdp, cost) * this.buildingTimeMultiplier;
|
||||
},
|
||||
|
||||
populationGrowthFactor(hoursPerYear, hours = 1) {
|
||||
if (this.basePopulationGrowthRate <= 0 || hoursPerYear <= 0 || hours <= 0) return 0;
|
||||
return Math.pow(1 + this.basePopulationGrowthRate, hours / hoursPerYear) - 1;
|
||||
|
||||
@@ -923,12 +923,22 @@ export const economyMethods = {
|
||||
|
||||
_tickTraining() {
|
||||
const completed = [];
|
||||
const day = Math.floor(this.totalHours / HOURS_PER_DAY);
|
||||
for (const [cityId, queue] of this.training) {
|
||||
const city = this.findCity(cityId);
|
||||
if (!city) {
|
||||
completed.push(cityId);
|
||||
continue;
|
||||
}
|
||||
// A queued building first gathers its materials, once a day, exactly as a
|
||||
// tile improvement's construction site does; only then does its
|
||||
// construction time begin.
|
||||
for (const entry of queue) {
|
||||
if (entry.kind !== "building" || entry.phase !== "materials") continue;
|
||||
if (entry.lastGatherDay === day) continue;
|
||||
entry.lastGatherDay = day;
|
||||
this._gatherBuildEntry(city, entry);
|
||||
}
|
||||
// A city runs one production line per kind plus whatever its Barracks
|
||||
// (units) and Shipyards (buildings) add. Each line works one entry at full
|
||||
// capacity, so a higher-level city produces several things at once; the
|
||||
@@ -970,6 +980,12 @@ export const economyMethods = {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// A building still gathering its materials is not on a construction line
|
||||
// yet, so it neither works nor blocks the ones that are.
|
||||
if (entry.phase === "materials") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const proto = this._queueEntryProto(entry);
|
||||
if (!proto) {
|
||||
queue.splice(i, 1);
|
||||
@@ -1006,9 +1022,11 @@ export const economyMethods = {
|
||||
return index >= 0 && index < this.protoUnits.length ? this.protoUnits[index] : null;
|
||||
},
|
||||
|
||||
// Raises one level of a completed building. The cost was paid when the order
|
||||
// was placed, so this only changes the city and invalidates the GDP cache.
|
||||
// Raises one level of a completed building. The money cost was paid when the
|
||||
// order was placed and the materials gathered as it ran, so this only returns
|
||||
// any unspent material budget, changes the city and invalidates the GDP cache.
|
||||
_completeBuilding(city, entry) {
|
||||
this._releaseEntryBudget(city.civ, entry);
|
||||
const level = this.getCityBuildingLevel(city, entry.protoIndex);
|
||||
city.buildings[entry.protoIndex] = level + 1;
|
||||
this._gdpPerCapitaCache.clear();
|
||||
|
||||
+28
-10
@@ -7,7 +7,7 @@ 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 { TRAINING_QUEUE_LIMIT } from "./constants.js";
|
||||
import { TRAINING_QUEUE_LIMIT, HOURS_PER_DAY } from "./constants.js";
|
||||
|
||||
export const orderMethods = {
|
||||
// Adds one unit to the city's production queue. The queue is shared with
|
||||
@@ -73,10 +73,12 @@ export const orderMethods = {
|
||||
return true;
|
||||
},
|
||||
|
||||
// Queues one more level of a building in the city's production queue. Like a
|
||||
// unit, the level is paid for up front from the budget and only rises once
|
||||
// its production time has elapsed. Repeated orders stack levels: the second
|
||||
// queued level is priced off the level the first will leave behind.
|
||||
// Queues one more level of a building in the city's production queue. The
|
||||
// level's money cost is paid up front, but its steel and high-tech are
|
||||
// gathered over the days that follow -- exactly as a tile improvement does --
|
||||
// and only then does the construction time begin. Repeated orders stack
|
||||
// levels: the second queued level is priced off the level the first will
|
||||
// leave behind.
|
||||
requestBuild(cityId, protoIndex, free = false) {
|
||||
const city = this.findCity(cityId);
|
||||
if (!city || protoIndex < 0 || protoIndex >= this.protoBuildings.length) return false;
|
||||
@@ -113,23 +115,39 @@ export const orderMethods = {
|
||||
const capacity = ECONOMY.productionCapacity(this.getPlayerGdp(city.civ));
|
||||
if (capacity <= 0) return false;
|
||||
if (!this._spendBudget(city.civ, cost, "construction")) return false;
|
||||
// The level's material bill is not bought up front any more: like a tile
|
||||
// improvement, the order reserves a budget and gathers the steel and
|
||||
// high-tech over the days before construction can begin.
|
||||
const materials = this.constructionResourceCost(proto, level, cost);
|
||||
if (!this._payConstructionResources(city.civ, city.coords, materials, "construction", proto.name)) {
|
||||
this._refundBudget(city.civ, cost, "construction");
|
||||
return false;
|
||||
}
|
||||
const needed = {
|
||||
steel: Math.max(0, materials.steel || 0),
|
||||
hightech: Math.max(0, materials.hightech || 0),
|
||||
};
|
||||
const reserved = needed.steel * this.getResourcePrice("steel") +
|
||||
needed.hightech * this.getResourcePrice("hightech");
|
||||
this._reserveSiteBudget(city.civ, reserved, "construction");
|
||||
const entry = {
|
||||
kind: "building",
|
||||
protoIndex,
|
||||
level,
|
||||
capacity,
|
||||
phase: "materials",
|
||||
budget: reserved,
|
||||
prices: this._lockedUnitPrices(),
|
||||
needed,
|
||||
bought: { steel: 0, hightech: 0 },
|
||||
elapsedHours: 0,
|
||||
totalHours: (cost / capacity) *
|
||||
totalHours: ECONOMY.buildingHours(this.getPlayerGdp(city.civ), cost) *
|
||||
this.constructionApprovalMultiplier(city) *
|
||||
this.constructionSpeedMultiplier(city.civ),
|
||||
lastGatherDay: -1,
|
||||
};
|
||||
if (queue) queue.push(entry);
|
||||
else this.training.set(cityId, [entry]);
|
||||
// Make the first purchase at once, so a well-supplied city starts building
|
||||
// right away instead of waiting for the first day boundary.
|
||||
entry.lastGatherDay = Math.floor(this.totalHours / HOURS_PER_DAY);
|
||||
this._gatherBuildEntry(city, entry);
|
||||
this._emitChanged();
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -92,6 +92,9 @@ export const resourceMethods = {
|
||||
for (const id of RESOURCE_IDS) this.resourcePriceHistory.set(id, [RESOURCE_MARKET_BASE[id]]);
|
||||
this.resourceLinks = [];
|
||||
this._resourceShortages = new Map();
|
||||
// The viewer-keyed delivery graph is rebuilt when the resource day rolls
|
||||
// over; the cache starts empty after a fresh configure.
|
||||
this._resourceGraphCache = null;
|
||||
// The food each region has bought to relay on to its neighbours, so only
|
||||
// that stock carries the middleman margin when it is resold.
|
||||
this._relayStock = new Map();
|
||||
@@ -2133,6 +2136,15 @@ export const resourceMethods = {
|
||||
// at RESOURCE_TRADE_RADIUS tiles. `routeEnergyPerTonne` is the accumulated
|
||||
// kWh to move one tonne from the city to that node.
|
||||
_nearbySuppliers(city, id, nodes, allowSea = true) {
|
||||
return this._tradeReachable(city, nodes, allowSea, (node) => this._nodeSurplus(node, id) > 0, id);
|
||||
},
|
||||
|
||||
// The storage nodes a city can haul from over the transport network: a
|
||||
// breadth-first search over land and sea, capped at RESOURCE_TRADE_RADIUS
|
||||
// tiles, that hands every reachable node to `accept`. `resource` only names
|
||||
// the commodity charged for the haul; the transport rules themselves are
|
||||
// commodity-independent, so the delivery graph passes one to price its rides.
|
||||
_tradeReachable(city, nodes, allowSea, accept, resource = "steel") {
|
||||
const results = [];
|
||||
const byKey = new Map(nodes.map((node) => [key(node.coords.x, node.coords.y), node]));
|
||||
const origin = key(city.coords.x, city.coords.y);
|
||||
@@ -2146,7 +2158,7 @@ export const resourceMethods = {
|
||||
const here = byKey.get(key(current.coords.x, current.coords.y));
|
||||
if (here && key(here.coords.x, here.coords.y) !== origin) {
|
||||
const hostile = here.civ >= 0 && here.civ !== city.civ && this.isAtWar(city.civ, here.civ);
|
||||
if (!hostile && this._nodeSurplus(here, id) > 0) {
|
||||
if (!hostile && accept(here)) {
|
||||
results.push({
|
||||
...here,
|
||||
tiles: current.tiles,
|
||||
@@ -2177,7 +2189,7 @@ export const resourceMethods = {
|
||||
const owner = this.civAt(neighbour);
|
||||
if (owner >= 0 && owner !== city.civ && this.isAtWar(city.civ, owner)) continue;
|
||||
}
|
||||
const stepEnergy = deliveryEnergyForResource(id, 1, 1, mode, tile.movementCostMultiplier || 1);
|
||||
const stepEnergy = deliveryEnergyForResource(resource, 1, 1, mode, tile.movementCostMultiplier || 1);
|
||||
visited.add(nk);
|
||||
queue.push({
|
||||
coords: neighbour,
|
||||
@@ -2190,6 +2202,33 @@ export const resourceMethods = {
|
||||
return results;
|
||||
},
|
||||
|
||||
// Every supplier-to-city route among a nation's own storage nodes, whether or
|
||||
// not a delivery ran along it. The trade graph draws these idle edges grey and
|
||||
// lays the active legs over them. One BFS per city, so callers cache it.
|
||||
_resourceCandidateEdges(civ) {
|
||||
const nodes = this._resourceNodes();
|
||||
const ownKeys = new Set();
|
||||
for (const node of nodes) {
|
||||
if (node.civ === civ) ownKeys.add(key(node.coords.x, node.coords.y));
|
||||
}
|
||||
const edges = new Map();
|
||||
for (const city of this.cities) {
|
||||
if (city.civ !== civ) continue;
|
||||
const reachable = this._tradeReachable(
|
||||
city, nodes, this._cityMarketAccess(city),
|
||||
(node) => ownKeys.has(key(node.coords.x, node.coords.y))
|
||||
);
|
||||
for (const node of reachable) {
|
||||
const from = key(node.coords.x, node.coords.y);
|
||||
const to = key(city.coords.x, city.coords.y);
|
||||
if (!edges.has(`${from}->${to}`)) {
|
||||
edges.set(`${from}->${to}`, [node.coords.x, node.coords.y, city.coords.x, city.coords.y]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(edges.values());
|
||||
},
|
||||
|
||||
// The middleman margin a region charges when it sells food it bought to relay.
|
||||
// Only the stock it actually bought for resale carries the margin, and the
|
||||
// stock is drawn down as it sells.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Serialisation: turning the live model into the snapshots the server ships to
|
||||
// each viewer, with fog-of-war applied per civilisation.
|
||||
|
||||
import { parseKey } from "../hex.js";
|
||||
import { key, parseKey } from "../hex.js";
|
||||
import { OPINION } from "../data/politics.js";
|
||||
import { EFFECT_RAIL_SPEED, EFFECT_CONSTRUCTION_SPEED, EFFECT_RESEARCH } from "../data/effects.js";
|
||||
import { RESOURCE_IDS } from "../data/resources.js";
|
||||
@@ -522,6 +522,9 @@ export const serializationMethods = {
|
||||
phase: site.phase,
|
||||
stalled: site.stalled,
|
||||
budget: site.budget,
|
||||
// Materials no reachable source can supply: the one case the panel
|
||||
// keeps a warning for, since no budget can unstall the work.
|
||||
unreachable: site.stalled ? this._siteUnreachableMaterials(site) : [],
|
||||
prices: { ...site.prices },
|
||||
needed: { ...site.needed },
|
||||
bought: { ...site.bought },
|
||||
@@ -691,21 +694,49 @@ export const serializationMethods = {
|
||||
});
|
||||
},
|
||||
|
||||
// The viewer's own storage nodes and delivery legs, as [x, y] nodes and
|
||||
// [fromX, fromY, toX, toY, resource, amount] links. A spectator sees none.
|
||||
// The viewer's own storage nodes and delivery legs, as [x, y, isCity] nodes,
|
||||
// [fromX, fromY, toX, toY, resource, amount] links and [fromX, fromY, toX,
|
||||
// toY] edges. Links are the deliveries that actually ran over the last day,
|
||||
// imports and exports alike; edges are every route the viewer's own nodes
|
||||
// could trade over, so the map can draw the idle ones grey. A spectator sees
|
||||
// none.
|
||||
_serializeResourceGraph(viewerCiv) {
|
||||
if (viewerCiv < 0 || !this.resourceStock) return { nodes: [], links: [] };
|
||||
const nodes = [];
|
||||
if (viewerCiv < 0 || !this.resourceStock) return { nodes: [], links: [], edges: [] };
|
||||
// The reachability behind the edges costs a BFS per city, and the trade
|
||||
// network only moves on the resource day, so the whole graph is memoised
|
||||
// against the resource version.
|
||||
if (!this._resourceGraphCache) this._resourceGraphCache = new Map();
|
||||
const cached = this._resourceGraphCache.get(viewerCiv);
|
||||
if (cached && cached.version === this._resourceVersion) return cached.graph;
|
||||
const graph = this._buildResourceGraph(viewerCiv);
|
||||
this._resourceGraphCache.set(viewerCiv, { version: this._resourceVersion, graph });
|
||||
return graph;
|
||||
},
|
||||
|
||||
_buildResourceGraph(viewerCiv) {
|
||||
const nodes = new Map();
|
||||
const addNode = (coords, isCity) => {
|
||||
const k = key(coords.x, coords.y);
|
||||
if (!nodes.has(k)) nodes.set(k, [coords.x, coords.y, isCity ? 1 : 0]);
|
||||
};
|
||||
for (const [k] of this.resourceStock) {
|
||||
const coords = parseKey(k);
|
||||
const node = this._resourceNodeAt(coords);
|
||||
if (!node || node.civ !== viewerCiv) continue;
|
||||
nodes.push([coords.x, coords.y, node.kind === "city" ? 1 : 0]);
|
||||
addNode(coords, node.kind === "city");
|
||||
}
|
||||
const links = [];
|
||||
for (const link of this.resourceLinks || []) {
|
||||
const to = link.to;
|
||||
if (this.civAt({ x: to[0], y: to[1] }) !== viewerCiv) continue;
|
||||
const from = { x: link.from[0], y: link.from[1] };
|
||||
const to = { x: link.to[0], y: link.to[1] };
|
||||
const supplier = this._resourceNodeAt(from);
|
||||
const supplierCiv = supplier ? supplier.civ : -1;
|
||||
const buyerCiv = this.civAt(to);
|
||||
// Imports into the viewer's cities and exports out of its nodes both
|
||||
// belong on the graph.
|
||||
if (supplierCiv !== viewerCiv && buyerCiv !== viewerCiv) continue;
|
||||
addNode(from, !!(supplier && supplier.kind === "city"));
|
||||
addNode(to, true);
|
||||
links.push([
|
||||
link.from[0],
|
||||
link.from[1],
|
||||
@@ -715,7 +746,7 @@ export const serializationMethods = {
|
||||
Math.round(link.amount * 1000) / 1000,
|
||||
]);
|
||||
}
|
||||
return { nodes, links };
|
||||
return { nodes: Array.from(nodes.values()), links, edges: this._resourceCandidateEdges(viewerCiv) };
|
||||
},
|
||||
|
||||
// --------------------------------------------------------- politics --
|
||||
@@ -828,7 +859,8 @@ export const serializationMethods = {
|
||||
},
|
||||
|
||||
// The city-to-city migration over the rolling month, as [fromX, fromY, toX,
|
||||
// toY, people] links for the population map's arrows. World-wide, like the
|
||||
// toY, people, fromCiv] links for the population map. The origin nation rides
|
||||
// along so the client can flag the people on the move. World-wide, like the
|
||||
// per-tile population the mode paints, and memoised against the flow version
|
||||
// so an unchanged graph is not re-summed on every broadcast.
|
||||
_serializeMigrationGraph() {
|
||||
@@ -842,7 +874,7 @@ export const serializationMethods = {
|
||||
const from = this._cityById.get(fromId);
|
||||
const to = this._cityById.get(toId);
|
||||
if (!from || !to) continue;
|
||||
links.push([from.coords.x, from.coords.y, to.coords.x, to.coords.y, amount]);
|
||||
links.push([from.coords.x, from.coords.y, to.coords.x, to.coords.y, amount, from.civ]);
|
||||
}
|
||||
links.sort((a, b) => a[4] - b[4]);
|
||||
const value = { links };
|
||||
|
||||
+138
-29
@@ -9,10 +9,13 @@
|
||||
// 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.
|
||||
// locked price (the market moved against it). Rather than waiting for a manual
|
||||
// top-up, it raises its budgeted prices -- and the money behind them -- each
|
||||
// day, 1% at first and doubling daily, until it can buy; only when no supplier
|
||||
// is within reach at all does it keep a lasting warning. The owner may still
|
||||
// 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";
|
||||
@@ -24,9 +27,13 @@ import {
|
||||
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;
|
||||
// locked price is not bought from: the site waits for the market or raises its
|
||||
// own budget.
|
||||
const PRICE_TOLERANCE = 0.01;
|
||||
// The first day a stalled site cannot buy, its budgeted prices and the matching
|
||||
// money rise by 1%; the rise doubles each following day (2%, 4%, 8%, ...), so
|
||||
// it bids its way up to the market unaided.
|
||||
const BUDGET_DAILY_RISE = 0.01;
|
||||
const MATERIAL_IDS = ["steel", "hightech"];
|
||||
const RESOURCE_IDS = ["steel", "hightech", "energy"];
|
||||
|
||||
@@ -121,10 +128,8 @@ export const siteMethods = {
|
||||
: 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 gdp = this.getPlayerGdp(civ);
|
||||
const baseHours = ECONOMY.buildingHours(gdp, buildCost);
|
||||
const totalHours = Math.max(1, baseHours * this.constructionSpeedMultiplier(civ));
|
||||
const route = this._siteCityPath(civ, coords);
|
||||
const site = {
|
||||
@@ -143,6 +148,8 @@ export const siteMethods = {
|
||||
cityId: route.cityId,
|
||||
path: route.path,
|
||||
stalled: false,
|
||||
// Consecutive stalled days, driving the doubling budget rise.
|
||||
budgetRaises: 0,
|
||||
lastBuyDay: -1,
|
||||
};
|
||||
this.constructionSites.set(k, site);
|
||||
@@ -194,6 +201,7 @@ export const siteMethods = {
|
||||
if (site.lastBuyDay !== day) {
|
||||
site.lastBuyDay = day;
|
||||
this._gatherSiteMaterials(site);
|
||||
this._raiseSiteBudgetIfStalled(site);
|
||||
}
|
||||
} else {
|
||||
site.elapsedHours += 1;
|
||||
@@ -207,8 +215,8 @@ export const siteMethods = {
|
||||
},
|
||||
|
||||
// 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).
|
||||
// that buys nothing while materials are still missing stalls the work, and the
|
||||
// budget below starts climbing to meet the market.
|
||||
_gatherSiteMaterials(site) {
|
||||
let boughtAnything = false;
|
||||
for (const id of MATERIAL_IDS) {
|
||||
@@ -223,6 +231,7 @@ export const siteMethods = {
|
||||
const stillMissing = MATERIAL_IDS.some((id) => site.needed[id] - site.bought[id] > 1e-9);
|
||||
if (!stillMissing) {
|
||||
site.stalled = false;
|
||||
site.budgetRaises = 0;
|
||||
site.phase = "construction";
|
||||
site.elapsedHours = 0;
|
||||
this._emitChanged();
|
||||
@@ -233,6 +242,28 @@ export const siteMethods = {
|
||||
site.stalled = stalled;
|
||||
this._emitChanged();
|
||||
}
|
||||
if (!stalled) site.budgetRaises = 0;
|
||||
},
|
||||
|
||||
// A day that bought nothing while a supplier is within reach raises the site's
|
||||
// budgeted prices and moves the matching money out of the treasury, then tries
|
||||
// again. The first day's rise is 1% and it doubles every day after (2%, 4%,
|
||||
// 8%, ...), so a stubborn price is met within a few days. A supplier still
|
||||
// above the raised cap simply stalls it once more; the count resets as soon as
|
||||
// it buys. When no supplier can reach the site at any price the rise cannot
|
||||
// help, so it is left alone.
|
||||
_raiseSiteBudgetIfStalled(site) {
|
||||
if (!site.stalled || site.phase !== "materials") return;
|
||||
if (this._siteUnreachableMaterials(site).length > 0) return;
|
||||
const raises = site.budgetRaises || 0;
|
||||
const factor = 1 + BUDGET_DAILY_RISE * 2 ** raises;
|
||||
for (const id of RESOURCE_IDS) site.prices[id] *= factor;
|
||||
site.budgetRaises = raises + 1;
|
||||
const add = this._siteTopUp(site, site.prices);
|
||||
this._reserveSiteBudget(site.civ, add);
|
||||
site.budget += add;
|
||||
this._gatherSiteMaterials(site);
|
||||
this._emitChanged();
|
||||
},
|
||||
|
||||
// Buys `amount` of one resource for the site: first from the allied city the
|
||||
@@ -303,6 +334,70 @@ export const siteMethods = {
|
||||
return payer;
|
||||
},
|
||||
|
||||
// ------------------------------------------------ city building orders --
|
||||
|
||||
// One day of a queued city building's material phase. Like a construction
|
||||
// site it buys whatever of its steel and high-tech bill it still lacks, at the
|
||||
// prices locked in when the order was placed: the nation's own stores first,
|
||||
// then the reachable suppliers. It only flips to construction once the whole
|
||||
// bill is gathered; a day that buys nothing leaves it stalled.
|
||||
_gatherBuildEntry(city, entry) {
|
||||
const payer = this._entryPayer(city, entry);
|
||||
const remaining = { steel: 0, hightech: 0 };
|
||||
for (const id of MATERIAL_IDS) {
|
||||
remaining[id] = Math.max(0, (entry.needed[id] || 0) - (entry.bought[id] || 0));
|
||||
}
|
||||
// The nation's own cities are bought from first, nearest to the building.
|
||||
const drawn = this._drawFromNearest(city.civ, city.coords, remaining, payer);
|
||||
for (const id of MATERIAL_IDS) {
|
||||
if (drawn[id] > 0) entry.bought[id] = (entry.bought[id] || 0) + drawn[id];
|
||||
}
|
||||
for (const id of MATERIAL_IDS) {
|
||||
const missing = (entry.needed[id] || 0) - (entry.bought[id] || 0);
|
||||
if (!(missing > 1e-9)) continue;
|
||||
const temp = emptyResources();
|
||||
this._procureFromNeighbours(city, id, missing, temp, this._resourceNodes(), {
|
||||
payer,
|
||||
allowSea: this._cityMarketAccess(city),
|
||||
});
|
||||
if (temp[id] > 0) entry.bought[id] = (entry.bought[id] || 0) + temp[id];
|
||||
}
|
||||
const stillMissing = MATERIAL_IDS.some(
|
||||
(id) => (entry.needed[id] || 0) - (entry.bought[id] || 0) > 1e-9
|
||||
);
|
||||
if (!stillMissing) entry.phase = "construction";
|
||||
entry.stalled = stillMissing;
|
||||
return !stillMissing;
|
||||
},
|
||||
|
||||
// A payer for a queued building: its reserved material budget first, then the
|
||||
// public treasury, so an overrun is public money.
|
||||
_entryPayer(city, entry) {
|
||||
const payer = { total: 0, overflow: 0 };
|
||||
payer.charge = (cost) => {
|
||||
if (!(cost > 0)) return;
|
||||
const fromBudget = Math.min(entry.budget || 0, cost);
|
||||
entry.budget = (entry.budget || 0) - fromBudget;
|
||||
const rest = cost - fromBudget;
|
||||
if (rest > 0) {
|
||||
this.budgets.set(city.civ, this.getBudget(city.civ) - rest);
|
||||
this._recordBudgetCash(city.civ, "construction", -rest);
|
||||
payer.overflow += rest;
|
||||
}
|
||||
payer.total += cost;
|
||||
};
|
||||
return payer;
|
||||
},
|
||||
|
||||
// Hands a finished order's unspent material budget back to the treasury. The
|
||||
// reserved money was never paid to the regions, so it is not clawed back.
|
||||
_releaseEntryBudget(civ, entry) {
|
||||
if (!(entry.budget > 0)) return;
|
||||
this.budgets.set(civ, this.getBudget(civ) + entry.budget);
|
||||
this._recordBudgetCash(civ, "construction", entry.budget);
|
||||
entry.budget = 0;
|
||||
},
|
||||
|
||||
// -------------------------------------------------- completion --
|
||||
|
||||
_completeConstructionSite(site) {
|
||||
@@ -351,26 +446,40 @@ export const siteMethods = {
|
||||
|
||||
// ------------------------------------------------- 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();
|
||||
// The materials a site still needs that no reachable source can provide: the
|
||||
// hub city's own store is empty and no supplier sits within the trade radius.
|
||||
// A non-empty result means no budget, however large, can unstall the work, so
|
||||
// the panel keeps a lasting warning instead.
|
||||
_siteUnreachableMaterials(site) {
|
||||
const missing = MATERIAL_IDS.filter(
|
||||
(id) => site.needed[id] - site.bought[id] > 1e-9
|
||||
);
|
||||
if (missing.length === 0) return [];
|
||||
const city = site.cityId != null ? this._cityById.get(site.cityId) : null;
|
||||
if (!city) return missing.slice();
|
||||
const nodes = this._resourceNodes();
|
||||
const allowSea = this._cityMarketAccess(city);
|
||||
const store = this.getCityResourceStock(city);
|
||||
const unreachable = [];
|
||||
for (const id of missing) {
|
||||
if ((store[id] || 0) > 0) continue;
|
||||
if (this._nearbySuppliers(city, id, nodes, allowSea).length > 0) continue;
|
||||
unreachable.push(id);
|
||||
}
|
||||
return unreachable;
|
||||
},
|
||||
|
||||
// The extra a site must be granted to buy its remaining materials at the given
|
||||
// unit prices (its own budgeted prices by default): the shortfall between what
|
||||
// those materials cost and the money the site still holds.
|
||||
_siteTopUp(site, prices = null) {
|
||||
const p = prices || site.prices;
|
||||
let wanted = 0;
|
||||
for (const id of RESOURCE_IDS) {
|
||||
const missing = Math.max(0, site.needed[id] - site.bought[id]);
|
||||
wanted += missing * prices[id];
|
||||
wanted += missing * p[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;
|
||||
return Math.max(0, wanted - site.budget);
|
||||
},
|
||||
|
||||
// Cancels a site. Nothing is refunded: the budget already handed to it and
|
||||
|
||||
+207
-11
@@ -37,6 +37,7 @@ function makeSite(state, coords, overrides = {}) {
|
||||
cityId: null,
|
||||
path: [],
|
||||
stalled: false,
|
||||
budgetRaises: 0,
|
||||
lastBuyDay: -1,
|
||||
...overrides,
|
||||
};
|
||||
@@ -141,7 +142,35 @@ export class ConstructionSiteTest extends TestCase {
|
||||
this.assertEqual(site.phase, "materials", "it never reached construction");
|
||||
}
|
||||
|
||||
test_increasing_a_stalled_budget_reopens_the_work() {
|
||||
test_a_stalled_budget_climbs_on_its_own() {
|
||||
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 },
|
||||
stalled: true,
|
||||
});
|
||||
// A token of stock in the hub city keeps the material reachable, so the
|
||||
// automatic rise applies; it is nowhere near enough to finish the work.
|
||||
state.getCityResourceStock(city).steel = 1;
|
||||
const treasury = 1e9;
|
||||
state.budgets.set(0, treasury);
|
||||
const locked = site.prices.steel;
|
||||
state._raiseSiteBudgetIfStalled(site);
|
||||
this.assertApprox(
|
||||
site.prices.steel,
|
||||
locked * 1.01,
|
||||
1e-6,
|
||||
"the first day's budgeted price rises 1%"
|
||||
);
|
||||
this.assertGreater(site.budget, 0, "the matching money moved into the site");
|
||||
this.assertLess(state.getBudget(0), treasury, "and came from the treasury");
|
||||
}
|
||||
|
||||
test_the_budget_rise_doubles_every_day() {
|
||||
const state = smallState();
|
||||
clearMaterials(state);
|
||||
const city = cityOf(state, 0);
|
||||
@@ -151,17 +180,85 @@ export class ConstructionSiteTest extends TestCase {
|
||||
budget: 0,
|
||||
needed: { steel: 100, hightech: 0, energy: 0 },
|
||||
});
|
||||
state.totalHours = 1;
|
||||
// A supplier far out of reach in price keeps the site stalling as it climbs.
|
||||
site.prices.steel = state._marketUnitCost("steel") * 0.001;
|
||||
for (const other of state.cities) state.getCityResourceStock(other).steel = 1e9;
|
||||
const nodes = state._resourceNodes();
|
||||
const suppliers = state._nearbySuppliers(city, "steel", nodes);
|
||||
this.assertGreater(suppliers.length, 0, "a supplier is reachable");
|
||||
for (const other of state.cities) state.getCityResourceStock(other).steel = 0;
|
||||
suppliers[0].stock.steel = 1e9;
|
||||
state.budgets.set(0, 1e15);
|
||||
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;
|
||||
this.assertTrue(site.stalled, "the locked price is too low to buy at");
|
||||
// The rises compound: 1%, then 2%, then 4%, ...
|
||||
let prev = site.prices.steel;
|
||||
for (let day = 0; day < 4 && site.phase === "materials"; day++) {
|
||||
state._raiseSiteBudgetIfStalled(site);
|
||||
const expected = prev * (1 + 0.01 * 2 ** day);
|
||||
this.assertApprox(site.prices.steel, expected, 1e-6, `day ${day + 1} rise`);
|
||||
prev = site.prices.steel;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
test_a_stalled_budget_climbs_until_a_supplier_is_affordable() {
|
||||
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 },
|
||||
});
|
||||
// A supplier sits within reach, but its delivered price is far above the
|
||||
// site's locked price, so it takes several days of rises to afford it.
|
||||
site.prices.steel = state._marketUnitCost("steel") * 0.2;
|
||||
for (const other of state.cities) state.getCityResourceStock(other).steel = 1e9;
|
||||
const nodes = state._resourceNodes();
|
||||
const suppliers = state._nearbySuppliers(city, "steel", nodes);
|
||||
this.assertGreater(suppliers.length, 0, "a supplier is reachable");
|
||||
for (const other of state.cities) state.getCityResourceStock(other).steel = 0;
|
||||
suppliers[0].stock.steel = 1e9;
|
||||
state.budgets.set(0, 1e15);
|
||||
state._gatherSiteMaterials(site);
|
||||
this.assertTrue(site.stalled, "the locked price is too low to buy at");
|
||||
const locked = site.prices.steel;
|
||||
state._raiseSiteBudgetIfStalled(site);
|
||||
this.assertApprox(
|
||||
site.prices.steel,
|
||||
locked * 1.01,
|
||||
1e-6,
|
||||
"the first day's budgeted price rises 1%"
|
||||
);
|
||||
// Keep letting the budget climb; it must eventually reach the supplier.
|
||||
for (let day = 0; day < 60 && site.phase === "materials"; day++) {
|
||||
state._raiseSiteBudgetIfStalled(site);
|
||||
}
|
||||
this.assertEqual(site.phase, "construction", "the site eventually bought at the market");
|
||||
this.assertApprox(site.bought.steel, 100, 1e-6, "all the steel was gathered");
|
||||
}
|
||||
|
||||
test_an_unreachable_site_does_not_waste_money_on_a_rise() {
|
||||
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 },
|
||||
stalled: true,
|
||||
});
|
||||
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");
|
||||
const locked = site.prices.steel;
|
||||
state._raiseSiteBudgetIfStalled(site);
|
||||
this.assertEqual(site.stalled, true, "the work stays stalled");
|
||||
this.assertApprox(site.budget, 0, 1e-6, "no money was moved");
|
||||
this.assertApprox(site.prices.steel, locked, 1e-9, "and no price was raised");
|
||||
this.assertApprox(state.getBudget(0), treasury, 1e-6, "the treasury is untouched");
|
||||
}
|
||||
|
||||
test_cancelling_a_site_refunds_nothing() {
|
||||
@@ -178,13 +275,28 @@ export class ConstructionSiteTest extends TestCase {
|
||||
this.assertApprox(state.getBudget(0), treasury, 1e-6, "the budget is not returned");
|
||||
}
|
||||
|
||||
test_only_the_owner_may_increase_or_cancel_a_site() {
|
||||
test_a_site_can_be_cancelled_once_construction_begins() {
|
||||
const state = smallState();
|
||||
const coords = bareOwnedLand(state);
|
||||
const site = makeSite(state, coords, {
|
||||
budget: 1e6,
|
||||
needed: { steel: 10, hightech: 0, energy: 0 },
|
||||
bought: { steel: 10, hightech: 0, energy: 0 },
|
||||
phase: "construction",
|
||||
elapsedHours: 5,
|
||||
totalHours: 10,
|
||||
});
|
||||
this.assertEqual(site.phase, "construction", "the materials are in and building has begun");
|
||||
this.assertTrue(state.requestCancelSite(0, coords), "the work can still be called off");
|
||||
this.assertNull(state.constructionSiteAt(coords), "the site is gone");
|
||||
}
|
||||
|
||||
test_only_the_owner_may_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));
|
||||
}
|
||||
|
||||
@@ -264,6 +376,88 @@ export class ConstructionSiteTest extends TestCase {
|
||||
this.assertLess(state.improvementCashAt(coords), 1e9, "the reserve was spent");
|
||||
}
|
||||
|
||||
test_a_stalled_site_knows_what_its_remaining_materials_cost() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
const coords = bareOwnedLand(state);
|
||||
const site = makeSite(state, coords, {
|
||||
cityId: city.id,
|
||||
budget: 1000,
|
||||
needed: { steel: 100, hightech: 0, energy: 0 },
|
||||
stalled: true,
|
||||
});
|
||||
// The shortfall between the remaining materials at the site's own budgeted
|
||||
// prices and the money it still holds is what a raise must move in.
|
||||
const expected = 100 * site.prices.steel - 1000;
|
||||
this.assertApprox(state._siteTopUp(site), expected, 1e-6, "the site's shortfall");
|
||||
this.assertApprox(
|
||||
state._siteTopUp(site, { ...site.prices, steel: site.prices.steel * 2 }),
|
||||
expected + 100 * site.prices.steel,
|
||||
1e-6,
|
||||
"a higher price asks for more"
|
||||
);
|
||||
}
|
||||
|
||||
test_a_site_holding_enough_reports_no_top_up() {
|
||||
const state = smallState();
|
||||
const coords = bareOwnedLand(state);
|
||||
const site = makeSite(state, coords, {
|
||||
budget: 1e12,
|
||||
needed: { steel: 100, hightech: 0, energy: 0 },
|
||||
stalled: true,
|
||||
});
|
||||
this.assertEqual(state._siteTopUp(site), 0, "a funded site owes nothing");
|
||||
}
|
||||
|
||||
test_a_site_with_no_reachable_supplier_reports_it() {
|
||||
const state = smallState();
|
||||
clearMaterials(state);
|
||||
const city = cityOf(state, 0);
|
||||
const coords = bareOwnedLand(state);
|
||||
const site = makeSite(state, coords, {
|
||||
cityId: city.id,
|
||||
budget: 1e9,
|
||||
needed: { steel: 100, hightech: 40, energy: 0 },
|
||||
stalled: true,
|
||||
});
|
||||
// Nothing within reach holds either material, so no budget can unstall it.
|
||||
const unreachable = state._siteUnreachableMaterials(site).slice().sort();
|
||||
this.assertEqual(unreachable.join(","), "hightech,steel", "both materials are unreachable");
|
||||
const snapshot = state.snapshot(0);
|
||||
const entry = (snapshot.constructionSites[0] || []).find(
|
||||
(row) => row.coords[0] === coords.x && row.coords[1] === coords.y
|
||||
);
|
||||
this.assertEqual(
|
||||
entry.unreachable.slice().sort().join(","),
|
||||
"hightech,steel",
|
||||
"the panel is told no supplier is in reach"
|
||||
);
|
||||
}
|
||||
|
||||
test_a_reachable_supplier_clears_the_unreachable_flag() {
|
||||
const state = smallState();
|
||||
clearMaterials(state);
|
||||
const city = cityOf(state, 0);
|
||||
const coords = bareOwnedLand(state);
|
||||
const site = makeSite(state, coords, {
|
||||
cityId: city.id,
|
||||
budget: 1e9,
|
||||
needed: { steel: 100, hightech: 0, energy: 0 },
|
||||
stalled: true,
|
||||
});
|
||||
this.assertEqual(
|
||||
state._siteUnreachableMaterials(site).join(","),
|
||||
"steel",
|
||||
"the empty market leaves steel unreachable"
|
||||
);
|
||||
state.getCityResourceStock(city).steel = 1000;
|
||||
this.assertEqual(
|
||||
state._siteUnreachableMaterials(site).length,
|
||||
0,
|
||||
"the hub city's own store counts as reachable"
|
||||
);
|
||||
}
|
||||
|
||||
test_the_snapshot_ships_the_sites_with_their_phase_and_budget() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
@@ -278,5 +472,7 @@ export class ConstructionSiteTest extends TestCase {
|
||||
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");
|
||||
this.assertEqual(entry.unreachable.length, 0, "a fresh site has no missing supplier");
|
||||
this.assertEqual("topUp" in entry, false, "no manual top-up is shipped any more");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,23 @@ export class EconomyTest extends TestCase {
|
||||
this.assertEqual(config({ gdpPerProductionPoint: 0 }).trainingHours(1000, 50), 0);
|
||||
}
|
||||
|
||||
test_building_hours_are_a_tenth_of_training() {
|
||||
const cfg = config({ gdpPerProductionPoint: 100 });
|
||||
this.assertApprox(cfg.buildingHours(1000, 50), 0.5);
|
||||
this.assertApprox(
|
||||
cfg.buildingHours(1000, 50),
|
||||
cfg.trainingHours(1000, 50) / 10,
|
||||
1e-9,
|
||||
"construction is ten times faster than training the same cost"
|
||||
);
|
||||
}
|
||||
|
||||
test_building_hours_follow_the_multiplier() {
|
||||
const cfg = config({ gdpPerProductionPoint: 100, buildingTimeMultiplier: 0.5 });
|
||||
this.assertApprox(cfg.buildingHours(1000, 50), 2.5);
|
||||
this.assertEqual(config({ gdpPerProductionPoint: 0 }).buildingHours(1000, 50), 0);
|
||||
}
|
||||
|
||||
test_trade_tax_defaults() {
|
||||
this.assertApprox(TAXES.defaultSales, 0.2);
|
||||
this.assertApprox(TAXES.defaultExport, 0.05);
|
||||
|
||||
@@ -1647,6 +1647,178 @@ export class GameScreenTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_a_negative_budget_never_greys_out_an_improvement() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
// The treasury is far in the red: any order is financed by the central
|
||||
// bank as debt, so the buttons must stay live.
|
||||
state.budgets.set(0, -5_000_000);
|
||||
screen.onState(state.snapshot(0));
|
||||
const coords = state.landCells.find((cell) => {
|
||||
const k = `${cell.x},${cell.y}`;
|
||||
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
|
||||
});
|
||||
this.assertNotNull(coords, "the player owns a bare land tile");
|
||||
screen._showTile(coords);
|
||||
this.assertFalse(
|
||||
env.$("#tile-improvement-actions .transport-btn").eq(0).prop("disabled"),
|
||||
"transport stays clickable in debt"
|
||||
);
|
||||
const military = env.$("#tile-improvement-actions .improvement-row")
|
||||
.eq(0).find(".tile-btn").first();
|
||||
this.assertFalse(military.prop("disabled"), "a military work stays clickable in debt");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_a_tile_under_construction_offers_only_a_cancel() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const network = stubNetwork();
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1.0e15);
|
||||
const coords = state.landCells.find((cell) => {
|
||||
const k = `${cell.x},${cell.y}`;
|
||||
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
|
||||
});
|
||||
this.assertNotNull(coords, "the player owns a bare land tile");
|
||||
this.assertTrue(
|
||||
state.requestBuildTileImprovement(0, coords, "radar_tower"),
|
||||
"the work is ordered"
|
||||
);
|
||||
screen.onState(state.snapshot(0));
|
||||
screen._showTile(coords);
|
||||
// Every improvement button is gone while the work runs.
|
||||
this.assertSize(env.$("#tile-improvement-actions .improvement-group"), 0, "nothing is left");
|
||||
this.assertSize(env.$("#tile-improvement-actions .tile-btn"), 0, "no military or economic works");
|
||||
this.assertSize(env.$("#tile-improvement-actions .transport-btn"), 0, "not even transport");
|
||||
// The only control is the cancel beside the progress.
|
||||
const $cancel = env.$("#tile-works .site-actions button");
|
||||
this.assertSize($cancel, 1, "the work can still be cancelled");
|
||||
this.assertTrue($cancel.text().includes("Cancel"), `it cancels the work: ${$cancel.text()}`);
|
||||
$cancel.click();
|
||||
const order = network.orders[network.orders.length - 1];
|
||||
this.assertEqual(order.type, "cancel_site");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_a_stalled_site_raises_its_budget_without_a_button() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const network = stubNetwork();
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1.0e15);
|
||||
const coords = state.landCells.find((cell) => {
|
||||
const k = `${cell.x},${cell.y}`;
|
||||
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
|
||||
});
|
||||
this.assertNotNull(coords, "the player owns a bare land tile");
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"), "the work is ordered");
|
||||
// Force a stall with materials still to buy and the market up sharply,
|
||||
// but a supplier within reach so the budget can climb.
|
||||
const site = state.constructionSiteAt(coords);
|
||||
site.budget = 1000;
|
||||
site.phase = "materials";
|
||||
site.needed.steel = 100;
|
||||
site.bought.steel = 0;
|
||||
site.stalled = true;
|
||||
state.setResourcePrice("steel", state.getResourcePrice("steel") * 10);
|
||||
const snapshot = state.snapshot(0);
|
||||
const entry = (snapshot.constructionSites[0] || []).find(
|
||||
(row) => row.coords[0] === coords.x && row.coords[1] === coords.y
|
||||
);
|
||||
entry.unreachable = [];
|
||||
screen.onState(snapshot);
|
||||
screen._showTile(coords);
|
||||
const labels = env.$("#tile-works .site-material-label")
|
||||
.map((_, el) => env.$(el).text())
|
||||
.get()
|
||||
.join("|");
|
||||
this.assertTrue(labels.includes("doubling"), `the automatic rise is explained: ${labels}`);
|
||||
this.assertFalse(labels.includes("Increase budget"), `no raise is offered: ${labels}`);
|
||||
this.assertEqual(
|
||||
env.$("#tile-works .site-warning").length,
|
||||
0,
|
||||
"a reachable site keeps no warning sign"
|
||||
);
|
||||
const $actions = env.$("#tile-works .site-actions button");
|
||||
this.assertEqual($actions.length, 1, "only one control is left");
|
||||
this.assertTrue(
|
||||
$actions.eq(0).text().includes("Cancel"),
|
||||
`cancelling stays: ${$actions.eq(0).text()}`
|
||||
);
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_a_stalled_site_with_no_supplier_says_so() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const network = stubNetwork();
|
||||
const screen = new GameScreen(network, makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
state.budgets.set(0, 1.0e15);
|
||||
const coords = state.landCells.find((cell) => {
|
||||
const k = `${cell.x},${cell.y}`;
|
||||
return state.civAt(cell) === 0 && !state.cityAt(cell) && !state.tileImprovements.has(k);
|
||||
});
|
||||
this.assertNotNull(coords, "the player owns a bare land tile");
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"), "the work is ordered");
|
||||
const site = state.constructionSiteAt(coords);
|
||||
site.budget = 1000;
|
||||
site.phase = "materials";
|
||||
site.needed.steel = 100;
|
||||
site.bought.steel = 0;
|
||||
site.stalled = true;
|
||||
const snapshot = state.snapshot(0);
|
||||
const entry = (snapshot.constructionSites[0] || []).find(
|
||||
(row) => row.coords[0] === coords.x && row.coords[1] === coords.y
|
||||
);
|
||||
// No reachable source holds the steel: no budget can unstall the work, so
|
||||
// this is the only case that keeps the warning sign.
|
||||
entry.unreachable = ["steel"];
|
||||
screen.onState(snapshot);
|
||||
screen._showTile(coords);
|
||||
const labels = env.$("#tile-works .site-material-label")
|
||||
.map((_, el) => env.$(el).text())
|
||||
.get()
|
||||
.join("|");
|
||||
this.assertTrue(labels.includes("No supplier"), `the missing supplier is shown: ${labels}`);
|
||||
this.assertFalse(labels.includes("Increase budget"), `no raise is offered: ${labels}`);
|
||||
this.assertEqual(
|
||||
env.$("#tile-works .site-warning").length,
|
||||
1,
|
||||
"the warning sign marks the missing supplier"
|
||||
);
|
||||
const $actions = env.$("#tile-works .site-actions button");
|
||||
this.assertEqual($actions.length, 1, "only the cancel button remains");
|
||||
this.assertTrue($actions.eq(0).text().includes("Cancel"), "cancelling still works");
|
||||
$actions.eq(0).click();
|
||||
this.assertEqual(
|
||||
network.orders[network.orders.length - 1],
|
||||
{ type: "cancel_site", coords: [coords.x, coords.y] }
|
||||
);
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_improvement_cost_tracks_market_prices() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { smallState, cityOf, gdpBuilding, grantBuilding } from "./framework/help
|
||||
import { key } from "../shared/hex.js";
|
||||
import { EFFECT_RESEARCH } from "../shared/data.js";
|
||||
import { tileFoodOutput } from "../shared/resources.js";
|
||||
import { DAYS_PER_YEAR } from "../shared/game_state/constants.js";
|
||||
import { DAYS_PER_YEAR, HOURS_PER_DAY } from "../shared/game_state/constants.js";
|
||||
|
||||
const BIG_BUDGET = 1.0e15;
|
||||
|
||||
@@ -182,6 +182,51 @@ export class GameStateBuildingsTest extends TestCase {
|
||||
this.assertGreater(state.getPlayerGdp(0), beforeGdp, "GDP bonus applied");
|
||||
}
|
||||
|
||||
test_a_building_order_gathers_its_materials_before_building() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const index = this.addBuilding(state, gdpBuilding({ baseCost: 1_000_000_000, costAlpha: 1.0 }));
|
||||
const city = cityOf(state, 0);
|
||||
// Drain every own store and cut the network, so nothing can be gathered.
|
||||
for (const own of state.cities) {
|
||||
if (own.civ !== 0) continue;
|
||||
const store = state.getCityResourceStock(own);
|
||||
store.steel = 0;
|
||||
store.hightech = 0;
|
||||
}
|
||||
state.roads.clear();
|
||||
state.railways.clear();
|
||||
this.assertTrue(state.requestBuild(city.id, index));
|
||||
const queue = state.getTraining(city.id);
|
||||
this.assertEqual(queue[0].phase, "materials", "the order gathers before it builds");
|
||||
this.assertEqual(queue[0].elapsedHours, 0, "the construction clock has not started");
|
||||
// Supply the steel; the next day the order can buy and start building.
|
||||
state.getCityResourceStock(city).steel = 1.0e9;
|
||||
for (let i = 0; i < HOURS_PER_DAY; i++) state.advanceHour();
|
||||
this.assertEqual(queue[0].phase, "construction", "the bill is gathered");
|
||||
this.advanceUntilIdle(state);
|
||||
this.assertEqual(state.getCityBuildingLevel(city, index), 1);
|
||||
}
|
||||
|
||||
test_a_stalled_building_order_waits_for_materials() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
const index = this.addBuilding(state, gdpBuilding({ baseCost: 1_000_000_000, costAlpha: 1.0 }));
|
||||
const city = cityOf(state, 0);
|
||||
// Nothing anywhere to buy: the order can only wait.
|
||||
for (const own of state.cities) {
|
||||
const store = state.getCityResourceStock(own);
|
||||
store.steel = 0;
|
||||
store.hightech = 0;
|
||||
}
|
||||
this.assertTrue(state.requestBuild(city.id, index));
|
||||
for (let i = 0; i < HOURS_PER_DAY * 3; i++) state.advanceHour();
|
||||
const entry = state.getTraining(city.id)[0];
|
||||
this.assertEqual(entry.phase, "materials", "it is still gathering");
|
||||
this.assertTrue(entry.stalled, "the entry reports being stalled");
|
||||
this.assertEqual(state.getCityBuildingLevel(city, index), 0, "nothing was built");
|
||||
}
|
||||
|
||||
test_building_and_unit_share_one_queue_and_are_produced_in_order() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
|
||||
@@ -47,6 +47,18 @@ export class GameStateTechnologyTest extends TestCase {
|
||||
this.assertEqual(state.getFocusedTechnology(0), -1, "the focus clears");
|
||||
}
|
||||
|
||||
test_research_is_never_gated_on_the_treasury() {
|
||||
const state = smallState();
|
||||
// A treasury deep in the red does not stop a nation focusing or accruing
|
||||
// research: the shortfall is central-bank debt, not a spending limit.
|
||||
state.budgets.set(0, -1_000_000);
|
||||
const index = this.index(state, "scientific_method");
|
||||
this.assertTrue(state.requestFocusTechnology(0, index), "the focus is accepted in debt");
|
||||
state._addResearch(0, 5);
|
||||
this.assertApprox(state.getTechnologyProgress(0), 5, 1e-9, "points still accrue");
|
||||
this.assertLess(state.getBudget(0), 0, "the treasury stayed in debt");
|
||||
}
|
||||
|
||||
test_switching_focus_keeps_committed_progress() {
|
||||
const state = smallState();
|
||||
this.complete(state, "scientific_method");
|
||||
|
||||
@@ -157,6 +157,19 @@ export class ImprovementsTest extends TestCase {
|
||||
this.assertTrue(state.roads.has(key(coords.x, coords.y)));
|
||||
}
|
||||
|
||||
test_a_negative_treasury_never_refuses_a_work() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, -1_000_000);
|
||||
stockMaterials(state);
|
||||
const coords = bareOwnedLand(state);
|
||||
// A treasury already in the red borrows still more from the central bank;
|
||||
// the order is never refused for want of money.
|
||||
this.assertTrue(state.requestBuildImprovement(0, coords, "road"), "the work is still ordered");
|
||||
this.assertLess(state.getBudget(0), -1_000_000, "the shortfall deepened the debt");
|
||||
completeSites(state);
|
||||
this.assertTrue(state.roads.has(key(coords.x, coords.y)), "the road was built");
|
||||
}
|
||||
|
||||
test_maintenance_counts_roads_and_railways_the_nation_holds() {
|
||||
const state = smallState();
|
||||
state.budgets.set(0, BIG_BUDGET);
|
||||
|
||||
+109
-6
@@ -166,11 +166,21 @@ export class MapModesTest extends TestCase {
|
||||
try {
|
||||
map.applySnapshot(state.snapshot(0));
|
||||
map.setMapMode("population");
|
||||
map.setMigrationGraph({ links: [[0, 0, 3, 3, 120], [3, 3, 0, 0, 40]] });
|
||||
map.setMigrationGraph({ links: [[0, 0, 3, 3, 120, 0], [3, 3, 0, 0, 40, 1]] });
|
||||
this.assertSize(
|
||||
env.$("#layer-resources .migration-arrow-head"), 2,
|
||||
"one arrowhead per migration flow"
|
||||
);
|
||||
const movers = env.$("#layer-resources .migrant-mover");
|
||||
this.assertSize(movers, 2, "a flagged disc per migration flow");
|
||||
this.assertTrue(
|
||||
movers.eq(0).css("background-image").includes("flag_france.png"),
|
||||
"the origin nation's flag textures the disc"
|
||||
);
|
||||
this.assertTrue(
|
||||
movers.eq(1).css("background-image").includes("flag_britain.png"),
|
||||
"a second flow carries its own flag"
|
||||
);
|
||||
this.assertEmpty(
|
||||
env.$("#layer-resources .resource-node"), "the delivery graph is not drawn"
|
||||
);
|
||||
@@ -181,19 +191,24 @@ export class MapModesTest extends TestCase {
|
||||
const labels = env.$("#map-legend-resources .map-legend-resource")
|
||||
.map((_, el) => env.$(el).text()).get();
|
||||
this.assertTrue(labels.some((t) => t.includes("People between cities")), "the flow is keyed");
|
||||
this.assertTrue(
|
||||
labels.some((t) => t.includes("flags show origin")),
|
||||
"the key explains the flag discs"
|
||||
);
|
||||
|
||||
// Hidden off the economic modes, and off the other economic mode.
|
||||
map.setMapMode("terrain");
|
||||
this.assertEmpty(env.$("#layer-resources .migration-arrow-head"), "hidden on terrain");
|
||||
this.assertEmpty(env.$("#layer-resources .migrant-mover"), "flag discs hidden too");
|
||||
map.setMapMode("gdp");
|
||||
map.setMigrationGraph({ links: [[0, 0, 3, 3, 120]] });
|
||||
map.setMigrationGraph({ links: [[0, 0, 3, 3, 120, 0]] });
|
||||
this.assertEmpty(env.$("#layer-resources .migration-arrow-head"), "not on the GDP map");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_the_delivery_graph_has_a_colour_legend() {
|
||||
async test_the_delivery_graph_has_an_icon_legend() {
|
||||
const state = smallState();
|
||||
const env = await setupDom();
|
||||
const map = this.buildMap(env);
|
||||
@@ -213,9 +228,12 @@ export class MapModesTest extends TestCase {
|
||||
this.assertTrue(labels.some((t) => t.includes("Steel")), "steel is keyed");
|
||||
this.assertTrue(labels.some((t) => t.includes("Food")), "food is keyed");
|
||||
this.assertTrue(labels.some((t) => t.includes("Storage node")), "the node marker is keyed");
|
||||
this.assertSize(
|
||||
env.$("#map-legend-resources .map-legend-swatch"), 2,
|
||||
"one swatch per commodity on the map"
|
||||
const icons = env.$("#map-legend-resources .map-legend-icon");
|
||||
this.assertSize(icons, 2, "one merchandise icon per commodity on the map");
|
||||
this.assertTrue(
|
||||
icons.map((_, el) => el.getAttribute("src")).get()
|
||||
.some((src) => src.endsWith("icon_steel.svg")),
|
||||
"the key shows the merchandise art"
|
||||
);
|
||||
map.setMapMode("terrain");
|
||||
map.setResourceGraph({ nodes: [[1, 1, 1]], links: [[0, 0, 1, 1, "steel", 10]] });
|
||||
@@ -228,6 +246,91 @@ export class MapModesTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_the_delivery_graph_uses_white_lines_and_merchandise_icons() {
|
||||
const state = smallState();
|
||||
const env = await setupDom();
|
||||
const map = this.buildMap(env);
|
||||
try {
|
||||
map.applySnapshot(state.snapshot(0));
|
||||
map.setMapMode("gdp");
|
||||
map.setResourceGraph({
|
||||
nodes: [[1, 1, 1]],
|
||||
links: [[0, 0, 1, 1, "steel", 10], [2, 2, 3, 3, "food", 5]],
|
||||
});
|
||||
const segments = env.$("#layer-resources .border-seg");
|
||||
this.assertSize(segments, 2, "one line per delivery leg");
|
||||
segments.each((_index, element) => {
|
||||
const background = element.style.background || "";
|
||||
this.assertTrue(
|
||||
/255, 255, 255|#ffffff/i.test(background),
|
||||
`the delivery line is white: ${background}`
|
||||
);
|
||||
});
|
||||
const movers = env.$("#layer-resources .resource-mover");
|
||||
this.assertSize(movers, 2, "one merchandise icon rides each leg");
|
||||
const sources = movers.map((_index, element) => element.getAttribute("src")).get();
|
||||
this.assertTrue(sources.some((src) => src.endsWith("icon_steel.svg")), "steel is carried");
|
||||
this.assertTrue(sources.some((src) => src.endsWith("icon_food.svg")), "food is carried");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_idle_routes_are_drawn_grey_beneath_the_active_legs() {
|
||||
const state = smallState();
|
||||
const env = await setupDom();
|
||||
const map = this.buildMap(env);
|
||||
try {
|
||||
map.applySnapshot(state.snapshot(0));
|
||||
map.setMapMode("gdp");
|
||||
map.setResourceGraph({
|
||||
nodes: [[1, 1, 1]],
|
||||
links: [[0, 0, 1, 1, "steel", 10]],
|
||||
edges: [[0, 0, 1, 1], [2, 2, 1, 1], [3, 3, 1, 1]],
|
||||
});
|
||||
const segments = env.$("#layer-resources .border-seg");
|
||||
this.assertSize(segments, 4, "one line per edge and per active leg");
|
||||
let white = 0;
|
||||
let grey = 0;
|
||||
segments.each((_index, element) => {
|
||||
const background = element.style.background || "";
|
||||
if (/255, 255, 255|#ffffff/i.test(background)) white += 1;
|
||||
else if (/123, 127, 133|#7b7f85/i.test(background)) grey += 1;
|
||||
});
|
||||
this.assertEqual(white, 1, "the active leg is white");
|
||||
this.assertEqual(grey, 3, "the idle routes are grey");
|
||||
const labels = env.$("#map-legend-resources .map-legend-resource")
|
||||
.map((_, el) => env.$(el).text()).get();
|
||||
this.assertTrue(labels.some((t) => t.includes("Idle route")), "the key names the idle route");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_the_merchandise_icons_animate_along_the_line() {
|
||||
const state = smallState();
|
||||
const env = await setupDom();
|
||||
const map = this.buildMap(env);
|
||||
const originalNow = performance.now;
|
||||
try {
|
||||
map.applySnapshot(state.snapshot(0));
|
||||
map.setMapMode("gdp");
|
||||
map.setResourceGraph({ nodes: [], links: [[0, 0, 4, 0, "steel", 10]] });
|
||||
const mover = env.$("#layer-resources .resource-mover");
|
||||
this.assertSize(mover, 1, "an icon rides the leg");
|
||||
performance.now = () => 0;
|
||||
map._animateDeliveryIcons();
|
||||
const first = mover[0].style.left;
|
||||
performance.now = () => 650; // a quarter of the 2600 ms cycle
|
||||
map._animateDeliveryIcons();
|
||||
const second = mover[0].style.left;
|
||||
this.assertNotEqual(first, second, "the icon moves over time");
|
||||
} finally {
|
||||
performance.now = originalNow;
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_fog_rebuilds_are_coalesced_between_frames() {
|
||||
const env = await setupDom();
|
||||
const map = this.buildMap(env);
|
||||
|
||||
@@ -47,6 +47,39 @@ export class MapViewTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_a_site_warns_only_when_no_supplier_is_in_reach() {
|
||||
const state = smallState();
|
||||
const { env, map } = await this.build(state);
|
||||
try {
|
||||
const coords = [2, 1];
|
||||
map.setConstructionSites([[{
|
||||
coords,
|
||||
phase: "materials",
|
||||
stalled: true,
|
||||
unreachable: [],
|
||||
elapsedHours: 0,
|
||||
totalHours: 10,
|
||||
}]]);
|
||||
let marker = env.$("#layer-entities .construction-marker").eq(0);
|
||||
this.assertEqual(marker.text(), "", "a reachable stalled site keeps no exclamation");
|
||||
this.assertEqual(marker.find(".gather-spinner").length, 1, "it keeps working");
|
||||
|
||||
map.setConstructionSites([[{
|
||||
coords,
|
||||
phase: "materials",
|
||||
stalled: true,
|
||||
unreachable: ["steel"],
|
||||
elapsedHours: 0,
|
||||
totalHours: 10,
|
||||
}]]);
|
||||
marker = env.$("#layer-entities .construction-marker").eq(0);
|
||||
this.assertEqual(marker.text(), "!", "only an unreachable site warns");
|
||||
this.assertTrue(marker.hasClass("stalled"), "and the marker is flagged stalled");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_dom_terrain_uses_the_tiling_land_textures() {
|
||||
const state = smallState();
|
||||
const { env, map } = await this.build(state);
|
||||
|
||||
@@ -124,6 +124,7 @@ export class MigrationTest extends TestCase {
|
||||
);
|
||||
this.assertNotNull(link, "the city-to-city flow was tallied");
|
||||
this.assertApprox(link[4], moved, 1, "the link carries everyone who moved");
|
||||
this.assertEqual(link[5], from.civ, "the link names the origin nation for its flag");
|
||||
}
|
||||
|
||||
test_the_migration_graph_rolls_over_a_month() {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState, cityOf } from "./framework/helpers.js";
|
||||
import { key } from "../shared/hex.js";
|
||||
|
||||
// Places a resource building on a road tile next to `city` and returns its
|
||||
// coords, so the trade BFS can reach it over the transport network.
|
||||
function placeReachableMill(state, city, civ = 0) {
|
||||
for (const neighbour of state.topology.neighbours(city.coords.x, city.coords.y)) {
|
||||
const k = key(neighbour.x, neighbour.y);
|
||||
if (!state._isLand(neighbour)) continue;
|
||||
if (state.cityAt(neighbour) || state.tileImprovements.has(k)) continue;
|
||||
state.tileImprovements.set(k, "steel_mill");
|
||||
state.tileImprovementOwner.set(k, civ);
|
||||
state.tileImprovementHp.set(k, 1000);
|
||||
state._ensureResourceStock(k);
|
||||
state.roads.add(k);
|
||||
return neighbour;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class TradeGraphTest extends TestCase {
|
||||
// The graph carries every internal route as an edge, whether or not a delivery
|
||||
// ran along it, so the map can draw the idle ones grey.
|
||||
test_owned_routes_appear_as_idle_edges() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
const mill = placeReachableMill(state, city);
|
||||
this.assertNotNull(mill, "the fixture found a tile for the mill");
|
||||
|
||||
const graph = state._serializeResourceGraph(0);
|
||||
this.assertHas(graph, "edges");
|
||||
const hasEdge = graph.edges.some(
|
||||
(edge) =>
|
||||
edge[0] === mill.x && edge[1] === mill.y &&
|
||||
edge[2] === city.coords.x && edge[3] === city.coords.y
|
||||
);
|
||||
this.assertTrue(hasEdge, "the mill-to-city route is an edge");
|
||||
this.assertEmpty(graph.links, "no delivery has run, so no link is active");
|
||||
this.assertTrue(
|
||||
graph.nodes.some((node) => node[0] === mill.x && node[1] === mill.y),
|
||||
"the mill is a storage node"
|
||||
);
|
||||
}
|
||||
|
||||
// A delivery out of the viewer's own city is an export and belongs on the
|
||||
// graph just as an import does, with the foreign buyer drawn as a node.
|
||||
test_exports_from_own_nodes_are_kept() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
const mill = placeReachableMill(state, city);
|
||||
const foreign = cityOf(state, 1);
|
||||
this.assertNotNull(foreign, "the fixture has a foreign city");
|
||||
state.resourceLinks = [{
|
||||
from: [mill.x, mill.y],
|
||||
to: [foreign.coords.x, foreign.coords.y],
|
||||
resource: "steel",
|
||||
amount: 12.5,
|
||||
}];
|
||||
state._resourceGraphCache = null;
|
||||
|
||||
const graph = state._serializeResourceGraph(0);
|
||||
const link = graph.links.find(
|
||||
(entry) => entry[0] === mill.x && entry[1] === mill.y &&
|
||||
entry[2] === foreign.coords.x && entry[3] === foreign.coords.y
|
||||
);
|
||||
this.assertNotNull(link, "the export is on the graph");
|
||||
this.assertEqual(link[4], "steel", "it names the commodity");
|
||||
this.assertTrue(
|
||||
graph.nodes.some((node) => node[0] === foreign.coords.x && node[1] === foreign.coords.y),
|
||||
"the foreign buyer is a node"
|
||||
);
|
||||
}
|
||||
|
||||
// A delivery into the viewer's own city (an import) still shows, as before.
|
||||
test_imports_into_own_cities_are_kept() {
|
||||
const state = smallState();
|
||||
const city = cityOf(state, 0);
|
||||
const foreign = cityOf(state, 1);
|
||||
state.resourceLinks = [{
|
||||
from: [foreign.coords.x, foreign.coords.y],
|
||||
to: [city.coords.x, city.coords.y],
|
||||
resource: "food",
|
||||
amount: 3,
|
||||
}];
|
||||
state._resourceGraphCache = null;
|
||||
|
||||
const graph = state._serializeResourceGraph(0);
|
||||
this.assertTrue(
|
||||
graph.links.some(
|
||||
(entry) => entry[2] === city.coords.x && entry[3] === city.coords.y && entry[4] === "food"
|
||||
),
|
||||
"the import is on the graph"
|
||||
);
|
||||
}
|
||||
|
||||
// A spectator gets no graph at all.
|
||||
test_a_spectator_sees_no_graph() {
|
||||
const state = smallState();
|
||||
const graph = state._serializeResourceGraph(-1);
|
||||
this.assertEmpty(graph.nodes);
|
||||
this.assertEmpty(graph.links);
|
||||
this.assertEmpty(graph.edges);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user