Compare commits
3
Commits
4600b16399
...
31683be27d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31683be27d | ||
|
|
7dbbe8696f | ||
|
|
965f0bffa9 |
+51
-1
@@ -1580,6 +1580,12 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
stroke-dasharray: 3 3;
|
||||
}
|
||||
.line-chart-label { fill: var(--muted); font-size: 9px; }
|
||||
.line-chart-baseline-label { fill: rgba(255, 255, 255, 0.6); }
|
||||
.line-chart-month { fill: var(--text); font-size: 9px; }
|
||||
/* Alternating month columns behind the index lines, so a month is named once
|
||||
at the bottom rather than a date at each end. */
|
||||
.line-chart-band-even { fill: rgba(255, 255, 255, 0.03); }
|
||||
.line-chart-band-odd { fill: rgba(255, 255, 255, 0.1); }
|
||||
.inflation-table { margin-top: 4px; }
|
||||
.inflation-swatch {
|
||||
display: inline-block;
|
||||
@@ -1589,7 +1595,51 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
border-radius: 2px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.tax-table .tax-input { width: 52px; }
|
||||
/* The tax boxes: a compact themed stepper so the arrows never sit on the
|
||||
digits, with the percent sign as a suffix. A box the player has edited but
|
||||
not yet had confirmed glows amber. */
|
||||
.pct-stepper {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
vertical-align: middle;
|
||||
border: 1px solid rgba(160, 190, 230, 0.3);
|
||||
border-radius: 7px;
|
||||
background: rgba(8, 12, 20, 0.85);
|
||||
overflow: hidden;
|
||||
}
|
||||
.pct-stepper:focus-within { border-color: var(--accent); }
|
||||
.pct-stepper.unsaved { border-color: #ffcf7a; }
|
||||
.pct-stepper .pct-step {
|
||||
width: 22px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
.pct-stepper .pct-step:hover { background: rgba(110, 175, 255, 0.22); color: var(--text); }
|
||||
.pct-stepper input {
|
||||
width: 44px;
|
||||
padding: 4px 1px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.pct-stepper input::-webkit-outer-spin-button,
|
||||
.pct-stepper input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.pct-stepper .pct-sign {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.tax-table .tax-apply { padding: 2px 8px; }
|
||||
.subhead { margin-top: 10px; color: var(--muted); font-weight: 600; }
|
||||
|
||||
|
||||
+18
-18
@@ -1,6 +1,24 @@
|
||||
// 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": "7dbbe86",
|
||||
"date": "2026-09-23",
|
||||
"subject": "Gave the tax boxes a stepper and kept edits from the snapshot",
|
||||
"body": ""
|
||||
},
|
||||
{
|
||||
"hash": "965f0bf",
|
||||
"date": "2026-09-23",
|
||||
"subject": "Drew the cylindrical map as a repeating world",
|
||||
"body": ""
|
||||
},
|
||||
{
|
||||
"hash": "4600b16",
|
||||
"date": "2026-09-23",
|
||||
"subject": "Stockpiled a month of every material and cached the trade walks",
|
||||
"body": "Food, steel, luxury and high-tech now share one flat month-long reserve. A region keeps bidding for the shortfall while its store is thin, so a war that empties a pantry refills it from the grid instead of starving; the harvest is served before the city load, unmet food bids the price up, the army's rations count as demand, and a converter that cannot cover its power bill idles instead of making a loss.\n\nThe delivery graph walks sea lanes any distance, so its walks are cached across days behind a road/railway/work/territory stamp and only dropped by a wartime unit move. Units are indexed per tile, market access and node populations are memoised for the day, and the server can settle a fresh world before serving (--warmup, 150 days by default).\n\nRoadmap records the remaining follow-ups -- the politics/migration hot path and the luxury/high-tech price plateau -- and that freight's heavy energy draw is intended."
|
||||
},
|
||||
{
|
||||
"hash": "3418e80",
|
||||
"date": "2026-09-23",
|
||||
@@ -42,23 +60,5 @@ export const DEVLOG = [
|
||||
"date": "2026-09-22",
|
||||
"subject": "Showed idle trade routes and flagged migrant flows on the economic maps",
|
||||
"body": "The delivery graph now ships every route the viewer's own storage nodes could trade over, not just the legs that ran, and keeps exports as well as imports so goods leaving the nation appear. The map draws the idle routes grey beneath the active white legs, with a key entry for them.\n\nThe population map replaces its bare flow arrows with the arrows plus a disc textured with the origin nation's flag drifting from city to city, mirroring the merchandise that rides the trade graph."
|
||||
},
|
||||
{
|
||||
"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": ""
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1944,10 +1944,12 @@ export const panelMethods = {
|
||||
takeTax: stats.taxTake || { sales: 0, export: 0, import: 0 },
|
||||
// The world commodity price move of the last day, for the central bank.
|
||||
commodityInflation: stats.commodityInflation || 0,
|
||||
// The monthly world-price series and each commodity's index and
|
||||
// monthly/yearly inflation, for the central bank's chart.
|
||||
// The daily world-price index series, each commodity's index and
|
||||
// monthly/yearly inflation, for the central bank's chart. The clock dates
|
||||
// the chart's rolling window.
|
||||
resourcePriceHistory: (this.snapshot && this.snapshot.resourcePriceHistory) || [],
|
||||
resourceInflation: (this.snapshot && this.snapshot.resourceInflation) || [],
|
||||
totalHours: (this.snapshot && this.snapshot.totalHours) || 0,
|
||||
cities: (this.snapshot && this.snapshot.cityStats) || [],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -60,6 +60,9 @@ export class MapView {
|
||||
this.resourceGraph = { nodes: [], links: [], edges: [] };
|
||||
// The merchandise icons riding the delivery legs, repositioned every frame.
|
||||
this._deliveryMovers = [];
|
||||
// Sibling containers that mirror each icon layer at whole-period offsets so
|
||||
// a cylindrical world is drawn repeated (see `_syncWrapCopies`).
|
||||
this._wrapCopies = [];
|
||||
// City-to-city migration over the last month, drawn instead of the delivery
|
||||
// graph while the population map is active.
|
||||
this.migrationGraph = { links: [] };
|
||||
@@ -243,6 +246,10 @@ export class MapView {
|
||||
this.capabilities = null;
|
||||
if (this.rendererMode !== "dom") this._setupGLRenderer();
|
||||
|
||||
// The repeat copies are icon-layer siblings; set them up once the renderer
|
||||
// (and therefore whether the ground repeats on its own) is known.
|
||||
this._setupWrapCopies();
|
||||
|
||||
this._setupCameraInput();
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,13 @@ export function politicalMode(zoom) {
|
||||
// battle icon between them. The side offset is in pixels.
|
||||
export const BATTLE_SIDE_OFFSET = 15;
|
||||
|
||||
// How many copies of the world are drawn at once on a cylindrical map. The
|
||||
// camera sits in one copy and its immediate neighbours fill the rest of the
|
||||
// viewport, so the world repeats under the player instead of every icon being
|
||||
// folded onto the single copy nearest the camera. Matches the WebGL renderer's
|
||||
// cap; on maps whose period is wider than the viewport this is just one.
|
||||
export const WRAP_COPIES = 4;
|
||||
|
||||
// The detailed view is built from square blocks of tiles. Only the blocks that
|
||||
// intersect the viewport (plus CHUNK_MARGIN blocks of slack on every side) are
|
||||
// materialised, so panning costs the same no matter how big the map is. Tiles
|
||||
|
||||
@@ -153,7 +153,7 @@ export const entityMethods = {
|
||||
bar.appendChild(fill);
|
||||
el.appendChild(bar);
|
||||
}
|
||||
el.style.left = `${local.x + this._wrapShift(local.x)}px`;
|
||||
el.style.left = `${local.x}px`;
|
||||
el.style.top = `${local.y * CAMERA_TILT}px`;
|
||||
container.appendChild(el);
|
||||
this._siteViews.set(`${x},${y}`, el);
|
||||
@@ -173,7 +173,7 @@ export const entityMethods = {
|
||||
el.dataset.owner = String(info.owner);
|
||||
el.appendChild(img);
|
||||
}
|
||||
el.style.left = `${local.x + this._wrapShift(local.x)}px`;
|
||||
el.style.left = `${local.x}px`;
|
||||
el.style.top = `${local.y * CAMERA_TILT}px`;
|
||||
container.appendChild(el);
|
||||
this._warfareViews.set(`${kind}:${k}`, el);
|
||||
@@ -345,17 +345,18 @@ export const entityMethods = {
|
||||
view._localX = local.x;
|
||||
view._localY = local.y;
|
||||
// The sprite layers are not tilted, so a city stands on the foreshortened
|
||||
// ground position rather than the raw world Y.
|
||||
const wrappedX = local.x + this._wrapShift(local.x);
|
||||
// ground position rather than the raw world Y. The repeat copies draw it
|
||||
// in the neighbouring periods (see `_syncWrapCopies`).
|
||||
const groundX = local.x;
|
||||
const groundY = local.y * CAMERA_TILT;
|
||||
view.style.left = `${wrappedX}px`;
|
||||
view.style.left = `${groundX}px`;
|
||||
view.style.top = `${groundY}px`;
|
||||
view.dataset.cityId = String(id);
|
||||
view.dataset.civ = String(data.civ);
|
||||
const label = view._label;
|
||||
label.textContent = data.name;
|
||||
label.classList.toggle("capital", !!data.isCapital);
|
||||
label.style.left = `${wrappedX}px`;
|
||||
label.style.left = `${groundX}px`;
|
||||
label.style.top = `${groundY}px`;
|
||||
const marker = view.querySelector(".marker");
|
||||
marker.style.background = this.civilisations[data.civ]
|
||||
|
||||
+19
-13
@@ -41,7 +41,10 @@ export const inputMethods = {
|
||||
this.$world.css("transform", `translate(${tx}px, ${ty}px) scale(${zoom})`);
|
||||
// A zoom step may have crossed into or out of the political map.
|
||||
this._updatePoliticalMode();
|
||||
if (applyWrap) this._syncChunks();
|
||||
if (applyWrap) {
|
||||
this._syncChunks();
|
||||
this._syncWrapCopies();
|
||||
}
|
||||
if (this._selectedTile) this._positionHighlight();
|
||||
this._positionPoliticalLabels();
|
||||
this._glRender();
|
||||
@@ -315,13 +318,13 @@ export const inputMethods = {
|
||||
this._highlightHidden = hidden;
|
||||
},
|
||||
|
||||
// Keeps the (already measured) outline on the wrapped copy nearest the camera.
|
||||
// Puts the (already measured) outline on the selected tile's raw map
|
||||
// position; the repeat copies draw it in the neighbouring periods.
|
||||
_positionHighlight() {
|
||||
if (!this._highlightEl || !this._selectedTile) return;
|
||||
const local = mapToLocal(this._selectedTile.x, this._selectedTile.y);
|
||||
const wrapped = this._wrappedPosition(local);
|
||||
this._highlightEl.style.left = `${wrapped.x - HEX_W / 2}px`;
|
||||
this._highlightEl.style.top = `${wrapped.y - HEX_H / 2}px`;
|
||||
this._highlightEl.style.left = `${local.x - HEX_W / 2}px`;
|
||||
this._highlightEl.style.top = `${local.y - HEX_H / 2}px`;
|
||||
},
|
||||
|
||||
// ------------------------------------------------------- hit-testing --
|
||||
@@ -336,8 +339,10 @@ export const inputMethods = {
|
||||
? this._positionAlongPath(motion)
|
||||
: mapToLocal(motion.coords.x, motion.coords.y);
|
||||
const offset = motion.offset || { x: 0, y: 0 };
|
||||
const wrapped = this._wrappedPosition(position);
|
||||
if (Math.hypot(wrapped.x + offset.x - local.x, wrapped.y + offset.y - local.y) < HEX_W * 0.5) {
|
||||
// Fold the unit back onto the canonical copy so a click on any repeat of
|
||||
// it (the world is drawn repeated) still finds it.
|
||||
const x = this._canonicalX(position.x);
|
||||
if (Math.hypot(x + offset.x - local.x, position.y + offset.y - local.y) < HEX_W * 0.5) {
|
||||
result.push(id);
|
||||
}
|
||||
}
|
||||
@@ -352,18 +357,19 @@ export const inputMethods = {
|
||||
for (const [id, view] of this._cityViews) {
|
||||
if (view.style.display === "none") continue;
|
||||
const local = this._clickLocal(coords);
|
||||
const cx = parseFloat(view.style.left);
|
||||
// City views are placed on the foreshortened ground.
|
||||
const cy = parseFloat(view.style.top);
|
||||
// Fold the city onto the canonical copy so a click on any repeat of it
|
||||
// finds it; city views are placed on the foreshortened ground.
|
||||
const cx = this._canonicalX(view._localX);
|
||||
const cy = view._localY * CAMERA_TILT;
|
||||
if (Math.hypot(cx - local.x, cy - local.y * CAMERA_TILT) < HEX_W * 0.5) return { id };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// World position of a clicked tile at the wrapped copy nearest the camera, so
|
||||
// hit-testing works across the seam.
|
||||
// Canonical world position of a clicked tile, so hit-testing works across the
|
||||
// seam and on any repeat copy the world is drawn at.
|
||||
_clickLocal(coords) {
|
||||
return this._wrappedPosition(mapToLocal(coords.x, coords.y));
|
||||
return mapToLocal(coords.x, coords.y);
|
||||
},
|
||||
|
||||
tileAtScreen(clientX, clientY) {
|
||||
|
||||
+20
-11
@@ -127,6 +127,14 @@ export const modeMethods = {
|
||||
this._renderGraphLegend();
|
||||
},
|
||||
|
||||
// The far end of a leg: the copy of `to` nearest `from`, so a route between
|
||||
// two neighbours across the cylindrical seam is drawn one tile long and its
|
||||
// endpoints land on nodes the repeat copies actually draw. The whole overlay
|
||||
// is duplicated per period by `_syncWrapCopies`, so the leg repeats too.
|
||||
_legEnd(from, to) {
|
||||
return this._nearestCopy(to, from);
|
||||
},
|
||||
|
||||
// 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
|
||||
@@ -136,14 +144,14 @@ export const modeMethods = {
|
||||
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)
|
||||
);
|
||||
const a = mapToLocal(fromX, fromY);
|
||||
const b = this._legEnd(a, mapToLocal(toX, toY));
|
||||
fragment.appendChild(makeSegment(a, b, 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);
|
||||
const b = this._legEnd(a, mapToLocal(toX, toY));
|
||||
fragment.appendChild(makeSegment(a, b, DELIVERY_COLOUR, 3));
|
||||
const meta = RESOURCE_META[resource];
|
||||
if (!meta || !meta.icon) continue;
|
||||
@@ -167,8 +175,6 @@ export const modeMethods = {
|
||||
const point = mapToLocal(node[0], node[1]);
|
||||
const dot = document.createElement("div");
|
||||
dot.className = "resource-node";
|
||||
dot.style.left = `${point.x}px`;
|
||||
dot.style.top = `${point.y}px`;
|
||||
fragment.appendChild(dot);
|
||||
}
|
||||
this.$resources.empty()[0].appendChild(fragment);
|
||||
@@ -177,8 +183,9 @@ export const modeMethods = {
|
||||
},
|
||||
|
||||
// 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.
|
||||
// flags on the migration ones), riding the same drawn leg, 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;
|
||||
@@ -186,8 +193,10 @@ export const modeMethods = {
|
||||
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`;
|
||||
const a = mover.a;
|
||||
const b = mover.b;
|
||||
mover.el.style.left = `${a.x + (b.x - a.x) * t}px`;
|
||||
mover.el.style.top = `${a.y + (b.y - 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);
|
||||
@@ -205,7 +214,7 @@ export const modeMethods = {
|
||||
for (const link of links) {
|
||||
const [fromX, fromY, toX, toY, people, civ] = link;
|
||||
const a = mapToLocal(fromX, fromY);
|
||||
const b = mapToLocal(toX, toY);
|
||||
const b = this._legEnd(a, 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));
|
||||
|
||||
@@ -96,15 +96,16 @@ export const motionMethods = {
|
||||
this._drawPaths();
|
||||
this._drawStrikeTargets();
|
||||
this._animateDeliveryIcons();
|
||||
this._syncWrapCopies();
|
||||
this._flushFog();
|
||||
this._glRender();
|
||||
},
|
||||
|
||||
// Repositions every unit and city at the wrapped copy nearest the camera.
|
||||
// Only writes a coordinate when it actually changed: most of the army is idle
|
||||
// on any given frame, and rewriting identical left/top values for every unit
|
||||
// still forces style recalculation, which a high-refresh display pays for
|
||||
// every frame.
|
||||
// Places every unit and city at its raw map coordinate; the repeat copies
|
||||
// (see `_syncWrapCopies`) show it in the neighbouring periods. Only writes a
|
||||
// coordinate when it actually changed: most of the army is idle on any given
|
||||
// frame, and rewriting identical left/top values for every unit still forces
|
||||
// style recalculation, which a high-refresh display pays for every frame.
|
||||
_applyEntityWrap() {
|
||||
for (const [id, motion] of this._unitMotion) {
|
||||
const view = this._unitViews.get(id);
|
||||
@@ -112,32 +113,25 @@ export const motionMethods = {
|
||||
const position = motion.path.length >= 2
|
||||
? this._positionAlongPath(motion)
|
||||
: mapToLocal(motion.coords.x, motion.coords.y);
|
||||
// Choose the wrapped copy from the tile centre, then apply the stack
|
||||
// offset, so members of one stack never split across the wrap seam.
|
||||
const wrapped = this._wrappedPosition(position);
|
||||
const offset = motion.offset || { x: 0, y: 0 };
|
||||
// Sprites stand on the tilted ground, so only the world Y is
|
||||
// foreshortened; the stack offset stays a plain pixel spread.
|
||||
this._setPosition(
|
||||
view,
|
||||
`${wrapped.x + offset.x}px`,
|
||||
`${wrapped.y * CAMERA_TILT + offset.y}px`
|
||||
`${position.x + offset.x}px`,
|
||||
`${position.y * CAMERA_TILT + offset.y}px`
|
||||
);
|
||||
}
|
||||
for (const view of this._cityViews.values()) {
|
||||
if (view._localX === undefined) continue;
|
||||
const left = `${view._localX + this._wrapShift(view._localX)}px`;
|
||||
const left = `${view._localX}px`;
|
||||
const top = `${view._localY * CAMERA_TILT}px`;
|
||||
this._setPosition(view, left, top);
|
||||
if (view._label) this._setPosition(view._label, left, top);
|
||||
}
|
||||
for (const marker of this._battleViews.values()) {
|
||||
if (marker._localX === undefined) continue;
|
||||
this._setPosition(
|
||||
marker,
|
||||
`${marker._localX + this._wrapShift(marker._localX)}px`,
|
||||
`${marker._localY * CAMERA_TILT}px`
|
||||
);
|
||||
this._setPosition(marker, `${marker._localX}px`, `${marker._localY * CAMERA_TILT}px`);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -204,9 +198,7 @@ export const motionMethods = {
|
||||
const delta = this.topology.pixelDelta(from, to);
|
||||
const next = { x: previous.x + delta.x, y: previous.y + delta.y };
|
||||
const start = i === index + 1 ? current : previous;
|
||||
const a = this._wrappedPosition(start);
|
||||
const b = this._wrappedPosition(next);
|
||||
fragment.appendChild(makeSegment(a, b, "#ffffff", 4));
|
||||
fragment.appendChild(makeSegment(start, next, "#ffffff", 4));
|
||||
previous = next;
|
||||
}
|
||||
}
|
||||
@@ -286,8 +278,8 @@ export const motionMethods = {
|
||||
? this._unitRenderPosition(targetId)
|
||||
: mapToLocal(target.x, target.y);
|
||||
if (!toPoint) continue;
|
||||
const a = this._wrappedPosition(fromPoint);
|
||||
const b = this._wrappedPosition(toPoint);
|
||||
const a = fromPoint;
|
||||
const b = this._nearestCopy(toPoint, fromPoint);
|
||||
const distance = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
// A quadratic Bezier lobs the shell: the control point is lifted above the
|
||||
// midpoint, its height compensated for the layer's Y foreshortening so the
|
||||
|
||||
@@ -298,7 +298,7 @@ export const politicalMethods = {
|
||||
if (!this._labelEntries) return;
|
||||
const tilt = CAMERA_TILT;
|
||||
for (const { el, label } of this._labelEntries) {
|
||||
const x = label.x + this._wrapShift(label.x);
|
||||
const x = label.x;
|
||||
const y = label.y * tilt;
|
||||
const angle = Math.atan2(Math.sin(label.angle) * tilt, Math.cos(label.angle));
|
||||
const degrees = (angle * 180) / Math.PI;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
CAMERA_TILT,
|
||||
FOG_REBUILD_MS,
|
||||
FOG_BAND_MARGIN,
|
||||
WRAP_COPIES,
|
||||
} from "./constants.js";
|
||||
import { terrainLayers, textureRepeat } from "./textures.js";
|
||||
import { bootLog } from "../loading.js";
|
||||
@@ -650,6 +651,138 @@ export const terrainMethods = {
|
||||
return { x: point.x + this._wrapShift(point.x), y: point.y };
|
||||
},
|
||||
|
||||
// The copy of `point` nearest a reference point, used to draw a route or line
|
||||
// between two places as their short wrapped link instead of across the seam.
|
||||
_nearestCopy(point, reference) {
|
||||
if (!this._period) return { x: point.x, y: point.y };
|
||||
const k = Math.round((reference.x - point.x) / this._period);
|
||||
return { x: point.x + k * this._period, y: point.y };
|
||||
},
|
||||
|
||||
// The horizontal offsets (whole periods) at which the repeating world is
|
||||
// drawn. The first is the copy the camera sits in, the rest its neighbours,
|
||||
// capped at WRAP_COPIES. A flat map has a single offset, 0.
|
||||
_visibleWrapOffsets() {
|
||||
if (!this._period) return [0];
|
||||
const width = this.$viewport.width();
|
||||
const zoom = this.camera.zoom || 1;
|
||||
if (!width) return [0];
|
||||
const half = width / (2 * zoom);
|
||||
const viewMin = this.camera.x - half;
|
||||
const viewMax = this.camera.x + half;
|
||||
const worldMin = this._worldMinX;
|
||||
const worldMax = this._worldMinX + this._worldWidth;
|
||||
const period = this._period;
|
||||
const kMin = Math.ceil((viewMin - worldMax) / period);
|
||||
const kMax = Math.floor((viewMax - worldMin) / period);
|
||||
if (kMax < kMin) return [0];
|
||||
const centre = Math.max(kMin, Math.min(kMax, Math.round(this.camera.x / period)));
|
||||
const offsets = [centre * period];
|
||||
for (let d = 1; offsets.length < WRAP_COPIES && (centre - d >= kMin || centre + d <= kMax); d++) {
|
||||
if (centre - d >= kMin) offsets.push((centre - d) * period);
|
||||
if (offsets.length < WRAP_COPIES && centre + d <= kMax) offsets.push((centre + d) * period);
|
||||
}
|
||||
return offsets;
|
||||
},
|
||||
|
||||
// The map-local X of a world pixel, folded back into one period so a point
|
||||
// just past the seam matches the tile it is drawn over. Used by hit-testing,
|
||||
// which resolves clicks by tile rather than by which copy was clicked.
|
||||
_canonicalX(x) {
|
||||
if (!this._period) return x;
|
||||
const base = COL_STEP * this.topology.originX;
|
||||
const period = this._period;
|
||||
return ((x - base) % period + period) % period + base;
|
||||
},
|
||||
|
||||
// ---------------------------------------------------- repeating layers --
|
||||
//
|
||||
// A cylindrical world is drawn several times across the viewport: the source
|
||||
// layers hold one copy at raw map coordinates and each sibling container
|
||||
// mirrors them at a whole-period offset. Cloning the built nodes (rather than
|
||||
// folding each one onto the copy nearest the camera) keeps every leg, border
|
||||
// and marker attached to the copy it belongs to, and repeats tile
|
||||
// improvements and units in every copy the WebGL ground already draws.
|
||||
|
||||
_setupWrapCopies() {
|
||||
this._wrapCopies = [];
|
||||
const layers = [
|
||||
// With WebGL the ground repeats in the shader; the DOM fallback must
|
||||
// repeat its chunked terrain the same way the icon layers are repeated.
|
||||
...(this.glRenderer
|
||||
? []
|
||||
: [
|
||||
{ source: this.$terrain, interactive: false },
|
||||
{ source: this.$roads, interactive: false },
|
||||
{ source: this.$borders, interactive: false },
|
||||
{ source: this.$fog, interactive: false },
|
||||
]),
|
||||
{ source: this.$highlight, interactive: false },
|
||||
{ source: this.$paths, interactive: false },
|
||||
{ source: this.$targets, interactive: false },
|
||||
{ source: this.$resources, interactive: false },
|
||||
{ source: this.$entities, interactive: true },
|
||||
{ source: this.$labels, interactive: true },
|
||||
{ source: this.$politicalLabels, interactive: false },
|
||||
];
|
||||
for (const { source, interactive } of layers) {
|
||||
if (!source || !source.length) continue;
|
||||
const copies = [];
|
||||
for (let i = 1; i < WRAP_COPIES; i++) {
|
||||
const el = document.createElement("div");
|
||||
el.className = source[0].className;
|
||||
el.style.display = "none";
|
||||
if (!interactive) el.style.pointerEvents = "none";
|
||||
source[0].after(el);
|
||||
copies.push(el);
|
||||
}
|
||||
this._wrapCopies.push({ source, copies, interactive });
|
||||
}
|
||||
},
|
||||
|
||||
// Positions the live layers and mirrors their content into each extra copy.
|
||||
// Runs once per frame; when the viewport fits inside one period (the common
|
||||
// case on a large map) there are no extra copies and the clones are empty.
|
||||
_syncWrapCopies() {
|
||||
if (!this._wrapCopies || this._wrapCopies.length === 0) return;
|
||||
const offsets = this._visibleWrapOffsets();
|
||||
const primary = offsets[0];
|
||||
const extra = offsets.slice(1);
|
||||
for (const entry of this._wrapCopies) {
|
||||
const source = entry.source[0];
|
||||
this._setWrapTransform(source, primary);
|
||||
for (let i = 0; i < entry.copies.length; i++) {
|
||||
const copy = entry.copies[i];
|
||||
const offset = extra[i];
|
||||
if (offset === undefined) {
|
||||
if (copy.style.display !== "none") {
|
||||
copy.style.display = "none";
|
||||
copy.textContent = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
copy.style.display = "";
|
||||
this._setWrapTransform(copy, offset);
|
||||
this._mirrorLayer(source, copy);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// The horizontal offset is applied with a transform so panning never costs a
|
||||
// layout pass. Tilted layers keep their ground foreshortening in the same
|
||||
// transform, since an inline transform would otherwise drop the class rule.
|
||||
_setWrapTransform(el, offset) {
|
||||
const tilt = el.classList.contains("tilted") ? ` scaleY(${CAMERA_TILT})` : "";
|
||||
el.style.transform = `translateX(${offset}px)${tilt}`;
|
||||
},
|
||||
|
||||
_mirrorLayer(source, copy) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const node of source.childNodes) fragment.appendChild(node.cloneNode(true));
|
||||
copy.textContent = "";
|
||||
copy.appendChild(fragment);
|
||||
},
|
||||
|
||||
_computeMaxStepLength() {
|
||||
let longest = 0;
|
||||
for (const neighbour of this.topology.neighbours(0, 0)) {
|
||||
|
||||
@@ -118,7 +118,8 @@ export function makeSegment(a, b, colour, width, offset = 0) {
|
||||
|
||||
// A shaft plus a triangular head, from `a` to `b`, with the tip landing on
|
||||
// `b`. `width` sets the shaft's thickness; the head grows with it so a thick
|
||||
// arrow still reads as an arrow.
|
||||
// arrow still reads as an arrow. A leg shorter than its head is drawn as just
|
||||
// the head, so the shaft never pokes out behind it.
|
||||
export function makeArrow(a, b, colour, width) {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
|
||||
+119
-42
@@ -14,6 +14,7 @@ import {
|
||||
RESOURCES,
|
||||
RESOURCE_IDS,
|
||||
resourceById,
|
||||
PRICE_INDEX_WINDOW_DAYS,
|
||||
} from "../../../shared/data.js";
|
||||
import { formatResourceAmount } from "../../../shared/resources.js";
|
||||
import { renderMonthlyBudget } from "./budget.js";
|
||||
@@ -26,11 +27,7 @@ import { lineChart } from "../ui/line_chart.js";
|
||||
import { trendArrow } from "../ui/trend.js";
|
||||
import { currencySymbol, currencyFor, rateToNational } from "../currency.js";
|
||||
import { HOURS_PER_DAY } from "../../../shared/game_state/constants.js";
|
||||
import { MONTH_NAMES } from "../../../shared/game_clock.js";
|
||||
|
||||
// The central bank charts a rolling window of the last two years, so its
|
||||
// monthly commodity index stays 24 points instead of growing with the game.
|
||||
const PRICE_INDEX_WINDOW_MONTHS = 24;
|
||||
import { MONTH_NAMES, unixTime } from "../../../shared/game_clock.js";
|
||||
|
||||
// Each research theme borrows the building-card icon of its field, so a
|
||||
// technology card reads with the same visual grammar as a building.
|
||||
@@ -168,6 +165,11 @@ export class NationModal {
|
||||
for (const row of this.technologyRows) this.$technologyList.append(row.card.$card);
|
||||
this._buildTechnologyThemes();
|
||||
this._buildResourceSubtabs();
|
||||
// Reopening the panel shows the server's rates, not an unsent edit left
|
||||
// over from a previous open.
|
||||
this.$economyTaxes.find(".tax-input").each((_, element) =>
|
||||
this._clearTaxDirty($(element))
|
||||
);
|
||||
this._buildEconomySubtabs();
|
||||
this.refresh(currentGovernment, researched, gdp, budget, researchRate, finances, diplomacy, conflicts, politics, techInfo, resourcesInfo, moneyInfo);
|
||||
this.$modal.removeClass("hidden");
|
||||
@@ -611,26 +613,31 @@ export class NationModal {
|
||||
}
|
||||
$head.append($("<th></th>").text(""));
|
||||
$table.append($("<thead></thead>").append($head));
|
||||
// One box: a themed stepper whose +/- buttons never sit on the digits,
|
||||
// with the percent sign as a suffix. The input keeps the `tax-input`
|
||||
// classes the refresh and the tests address it by.
|
||||
const makeField = (kind, resource) => {
|
||||
const $input = $("<input type='number' min='0' max='100' step='1'>")
|
||||
.addClass("tax-input")
|
||||
.attr("data-kind", kind);
|
||||
if (resource) $input.addClass("tax-override").attr("data-resource", resource);
|
||||
const $dec = $("<button type='button' class='pct-step'></button>")
|
||||
.attr("data-dir", "-1").text("−");
|
||||
const $inc = $("<button type='button' class='pct-step'></button>")
|
||||
.attr("data-dir", "1").text("+");
|
||||
$input.on("input", () => this._markTaxDirty($input));
|
||||
$dec.on("click", () => this._stepTaxField($input, -1));
|
||||
$inc.on("click", () => this._stepTaxField($input, 1));
|
||||
return $("<span class='pct-stepper'></span>")
|
||||
.append($dec, $input, $("<span class='pct-sign'></span>").text("%"), $inc);
|
||||
};
|
||||
const $body = $("<tbody></tbody>");
|
||||
for (const kind of kinds) {
|
||||
const $tr = $("<tr></tr>");
|
||||
$tr.append($("<td class='name'></td>").text(kind.label));
|
||||
$tr.append(
|
||||
$("<td class='numeric'></td>").append(
|
||||
$("<input type='number' min='0' max='100' step='1'>")
|
||||
.addClass("tax-input")
|
||||
.attr("data-kind", kind.id)
|
||||
)
|
||||
);
|
||||
$tr.append($("<td class='numeric'></td>").append(makeField(kind.id, null)));
|
||||
for (const proto of RESOURCES) {
|
||||
$tr.append(
|
||||
$("<td class='numeric'></td>").append(
|
||||
$("<input type='number' min='0' max='100' step='1'>")
|
||||
.addClass("tax-input tax-override")
|
||||
.attr("data-kind", kind.id)
|
||||
.attr("data-resource", proto.id)
|
||||
)
|
||||
);
|
||||
$tr.append($("<td class='numeric'></td>").append(makeField(kind.id, proto.id)));
|
||||
}
|
||||
const $apply = $("<button type='button' class='aero tax-apply'></button>").text("Set");
|
||||
$apply.on("click", () => this._applyTaxRow(kind.id));
|
||||
@@ -641,29 +648,69 @@ export class NationModal {
|
||||
this.$economyTaxes.append($table);
|
||||
this._taxesBuilt = true;
|
||||
}
|
||||
// Refresh the boxes, leaving the one being typed in alone.
|
||||
// Refresh the boxes from the snapshot. The box being typed in, and any box
|
||||
// the player has edited but not yet had confirmed, is left alone so the
|
||||
// 10 Hz snapshot stream cannot undo work in progress.
|
||||
const active = document.activeElement;
|
||||
this.$economyTaxes.find(".tax-input").each((_, element) => {
|
||||
if (element === active) return;
|
||||
const $input = $(element);
|
||||
const kind = $input.data("kind");
|
||||
const resource = $input.data("resource");
|
||||
const entry = taxes[kind] || { rate: 0, resources: {} };
|
||||
const value = resource ? entry.resources[resource] : entry.rate;
|
||||
$input.val(value === undefined || value === null ? "" : (value * 100).toFixed(2));
|
||||
const text = value === undefined || value === null ? "" : (value * 100).toFixed(2);
|
||||
if ($input.data("dirty")) {
|
||||
// Release the box once the server agrees, then show its clean value.
|
||||
const shown = value === undefined || value === null ? null : value * 100;
|
||||
if (element !== active && shown !== null && Math.abs(Number($input.val()) - shown) < 0.005) {
|
||||
this._clearTaxDirty($input);
|
||||
$input.val(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (element === active) return;
|
||||
$input.val(text);
|
||||
});
|
||||
}
|
||||
|
||||
// Marks a tax box as holding an unsent edit so the snapshot refresh leaves it
|
||||
// alone; the amber outline tells the player it is not confirmed yet.
|
||||
_markTaxDirty($input) {
|
||||
$input.data("dirty", true);
|
||||
$input.closest(".pct-stepper").addClass("unsaved");
|
||||
}
|
||||
|
||||
_clearTaxDirty($input) {
|
||||
$input.data("dirty", false);
|
||||
$input.closest(".pct-stepper").removeClass("unsaved");
|
||||
}
|
||||
|
||||
// One click of a stepper button nudges the rate by a whole percent.
|
||||
_stepTaxField($input, delta) {
|
||||
const current = Number($input.val());
|
||||
const base = Number.isFinite(current) ? current : 0;
|
||||
const next = Math.max(0, Math.min(100, base + delta));
|
||||
$input.val(next.toFixed(2));
|
||||
this._markTaxDirty($input);
|
||||
}
|
||||
|
||||
// Sends every box on one tax row that has a value, the default and each
|
||||
// per-resource override.
|
||||
_applyTaxRow(kind) {
|
||||
const $row = this.$economyTaxes.find(`.tax-input[data-kind="${kind}"]`).closest("tr");
|
||||
const send = ($input, resource) => {
|
||||
const raw = $input.val();
|
||||
if (raw === "" || raw === null || raw === undefined) return;
|
||||
// A blank box has nothing to send (there is no "remove override" order),
|
||||
// so drop the edit and let the next snapshot refill it.
|
||||
if (raw === "" || raw === null || raw === undefined) {
|
||||
this._clearTaxDirty($input);
|
||||
return;
|
||||
}
|
||||
const percent = Number(raw);
|
||||
if (!Number.isFinite(percent) || percent < 0) return;
|
||||
this.onSetTaxRate(kind, percent / 100, resource || null);
|
||||
const clamped = Math.max(0, Math.min(100, percent));
|
||||
if (clamped !== percent) $input.val(clamped.toFixed(2));
|
||||
this.onSetTaxRate(kind, clamped / 100, resource || null);
|
||||
};
|
||||
send($row.find(".tax-input").not(".tax-override"));
|
||||
$row.find(".tax-override").each((_, element) => {
|
||||
@@ -760,10 +807,13 @@ export class NationModal {
|
||||
$reserves.append($table);
|
||||
}
|
||||
|
||||
// The commodity price index chart and the per-commodity inflation table. Each
|
||||
// series is a commodity's world price rebased to 100 in January 2000, with the
|
||||
// live point appended so the current month reads to today. Only the last two
|
||||
// years are plotted, so the window rolls instead of the chart growing.
|
||||
// The commodity price index chart and the per-commodity inflation table. The
|
||||
// server ships each commodity's world price already rebased to 100 in January
|
||||
// 2000, one point per day. The plot has a fixed ninety-day axis, so a young
|
||||
// game leaves its future days blank rather than stretching a short line across
|
||||
// the width; once the series is full it rolls, dropping the oldest day. The
|
||||
// axis is shaded in alternating month columns, each named once along the
|
||||
// bottom, so no exact date has to compete for space at the ends.
|
||||
_renderCommodityChart(money) {
|
||||
const $container = this.$economyCentralBank.find(".central-bank-prices");
|
||||
if (!$container.length) return;
|
||||
@@ -771,32 +821,52 @@ export class NationModal {
|
||||
const inflation = new Map((money.resourceInflation || []).map((entry) => [entry.id, entry]));
|
||||
const series = [];
|
||||
const rows = [];
|
||||
// The month the plotted window starts in, as an offset from January 2000.
|
||||
let windowStart = 0;
|
||||
let count = 0;
|
||||
for (const [id, prices] of history) {
|
||||
const resource = resourceById(id);
|
||||
if (!resource || !prices || prices.length === 0) continue;
|
||||
const base = prices[0] > 0 ? prices[0] : 1;
|
||||
const values = prices.map((price) => (price / base) * 100);
|
||||
const info = inflation.get(id) || { index: 100, monthly: 0, yearly: 0 };
|
||||
if (Math.abs(info.index - values[values.length - 1]) > 1e-6) values.push(info.index);
|
||||
windowStart = Math.max(windowStart, values.length - PRICE_INDEX_WINDOW_MONTHS);
|
||||
const values = prices.slice(-PRICE_INDEX_WINDOW_DAYS);
|
||||
count = Math.max(count, values.length);
|
||||
series.push({ label: resource.name, colour: resource.colour, values });
|
||||
rows.push({ resource, info });
|
||||
rows.push({ resource, info: inflation.get(id) || { index: 100, monthly: 0, yearly: 0 } });
|
||||
}
|
||||
for (const entry of series) entry.values = entry.values.slice(windowStart);
|
||||
$container.empty();
|
||||
$container.append($("<div class='subhead'></div>").text("Commodity prices"));
|
||||
$container.append(
|
||||
$("<div class='desc'></div>").text(
|
||||
"Each commodity's world price as an index, 100 in January 2000."
|
||||
"Each commodity's world price as an index (100 in January 2000), " +
|
||||
"one point a day over the last ninety days."
|
||||
)
|
||||
);
|
||||
if (series.length === 0) return;
|
||||
// The window's first day: the chart's last point is today, so the leftmost
|
||||
// slot is that many days back. Slots past the newest point are future days.
|
||||
const lastDay = Math.floor((money.totalHours || 0) / HOURS_PER_DAY);
|
||||
const firstDay = lastDay - (count - 1);
|
||||
// A slot's calendar month, keyed year*12+month so a band breaks only when
|
||||
// the month really changes (and across a year boundary).
|
||||
const slotDate = (i) => new Date(unixTime((firstDay + i) * HOURS_PER_DAY) * 1000);
|
||||
const slotMonth = (i) => {
|
||||
const date = slotDate(i);
|
||||
return date.getUTCFullYear() * 12 + date.getUTCMonth();
|
||||
};
|
||||
// One band per month in the fixed window, named once at the bottom. A
|
||||
// partial month at either edge still gets a full column and its name.
|
||||
const xBands = [];
|
||||
let bandStart = 0;
|
||||
for (let i = 1; i <= PRICE_INDEX_WINDOW_DAYS; i++) {
|
||||
if (i < PRICE_INDEX_WINDOW_DAYS && slotMonth(i) === slotMonth(bandStart)) continue;
|
||||
xBands.push({
|
||||
start: bandStart,
|
||||
end: i - 1,
|
||||
label: MONTH_NAMES[slotDate(bandStart).getUTCMonth()].slice(0, 3),
|
||||
});
|
||||
bandStart = i;
|
||||
}
|
||||
const options = {
|
||||
xLabel: (i) =>
|
||||
`${MONTH_NAMES[(windowStart + i) % 12].slice(0, 3)} ` +
|
||||
`${2000 + Math.floor((windowStart + i) / 12)}`,
|
||||
window: PRICE_INDEX_WINDOW_DAYS,
|
||||
min: 0,
|
||||
xBands,
|
||||
};
|
||||
// The panel scrolls, but the chart must not swallow it: size it to the
|
||||
// panel's width and cap it at half the visible height, so the inflation
|
||||
@@ -1271,6 +1341,12 @@ export class NationModal {
|
||||
|
||||
refresh(currentGovernment, researched, gdp, budget, researchRate, finances = {}, diplomacy = null, conflicts = null, politics = null, techInfo = null, resourcesInfo = null, moneyInfo = null) {
|
||||
this.currentGovernment = currentGovernment;
|
||||
// A snapshot rebuilds the open panels in place; hold each scroll container
|
||||
// where the player left it so the panel does not jump back to the top under
|
||||
// them (the central bank's foreign reserves sit below the fold).
|
||||
const scrolls = this.$modal.find(".modal-body")
|
||||
.map((_, el) => ({ el, top: el.scrollTop }))
|
||||
.get();
|
||||
if (techInfo) this.techInfo = techInfo;
|
||||
if (resourcesInfo) this.resources = resourcesInfo;
|
||||
this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number));
|
||||
@@ -1367,6 +1443,7 @@ export class NationModal {
|
||||
this._renderDiplomacy();
|
||||
this._renderConflicts();
|
||||
this._renderPolitics();
|
||||
for (const entry of scrolls) entry.el.scrollTop = entry.top;
|
||||
}
|
||||
|
||||
// Lists every other nation's economy in a sortable table, with its relation
|
||||
|
||||
@@ -6,6 +6,12 @@ const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
// Builds the chart. `series` is [{ label, colour, values }] with values oldest
|
||||
// first; every series shares one x axis. `options.baseline` draws the reference
|
||||
// line (100 for a price index), and `options.xLabel(i)` labels the ends.
|
||||
// `options.window` fixes the number of x slots: the plot spans that many values
|
||||
// so a short series sits at the left of a fixed window instead of stretching to
|
||||
// fill it, leaving slots the game has not reached yet blank.
|
||||
// `options.xBands` is [{ start, end, label }] in slot indices. Each band is
|
||||
// shaded, alternating lighter and darker, and the label is centred below it, so
|
||||
// months read as tinted columns named once instead of a date at each end.
|
||||
export function lineChart(series, options = {}) {
|
||||
const width = options.width || 320;
|
||||
const height = options.height || 150;
|
||||
@@ -27,6 +33,9 @@ export function lineChart(series, options = {}) {
|
||||
if (value > max) max = value;
|
||||
}
|
||||
}
|
||||
// A caller may pin the floor (the commodity index always bottoms out at 0);
|
||||
// otherwise the axis fits the data around the baseline.
|
||||
if (options.min !== undefined) min = options.min;
|
||||
if (max - min < 1e-6) {
|
||||
min -= 1;
|
||||
max += 1;
|
||||
@@ -34,8 +43,37 @@ export function lineChart(series, options = {}) {
|
||||
const span = max - min;
|
||||
const plotW = width - pad.left - pad.right;
|
||||
const plotH = height - pad.top - pad.bottom;
|
||||
const x = (i) => pad.left + (count <= 1 ? plotW / 2 : (i / (count - 1)) * plotW);
|
||||
// The x slots: the fixed window when given, otherwise one per value so a
|
||||
// caller that does not care about future slots keeps filling the plot.
|
||||
const slots = options.window && options.window > 1 ? options.window : count;
|
||||
const x = (i) => pad.left + (slots <= 1 ? plotW / 2 : (i / (slots - 1)) * plotW);
|
||||
const y = (value) => pad.top + (1 - (value - min) / span) * plotH;
|
||||
// A band covers its slots plus half a slot on each side, so neighbouring
|
||||
// bands meet exactly and the edge bands reach the plot's edges.
|
||||
const half = slots > 1 ? plotW / (slots - 1) / 2 : 0;
|
||||
const band = (a, b) => ({
|
||||
left: Math.max(pad.left, x(a) - half),
|
||||
right: Math.min(width - pad.right, x(b) + half),
|
||||
});
|
||||
|
||||
// The month bands sit behind everything else so the lines read over them.
|
||||
const bands = options.xBands || [];
|
||||
bands.forEach((entry, i) => {
|
||||
const { left, right } = band(entry.start, entry.end);
|
||||
if (right - left <= 0) return;
|
||||
const rect = document.createElementNS(SVG_NS, "rect");
|
||||
rect.setAttribute("x", String(left));
|
||||
rect.setAttribute("y", String(pad.top));
|
||||
rect.setAttribute("width", String(right - left));
|
||||
rect.setAttribute("height", String(plotH));
|
||||
// The fill here is a fallback for a page whose stylesheet has not been
|
||||
// refreshed; the classes let the theme override it.
|
||||
rect.setAttribute("fill", i % 2 === 0
|
||||
? "rgba(255, 255, 255, 0.03)"
|
||||
: "rgba(255, 255, 255, 0.1)");
|
||||
rect.setAttribute("class", i % 2 === 0 ? "line-chart-band-even" : "line-chart-band-odd");
|
||||
svg.appendChild(rect);
|
||||
});
|
||||
|
||||
const baseLine = document.createElementNS(SVG_NS, "line");
|
||||
baseLine.setAttribute("x1", String(pad.left));
|
||||
@@ -45,12 +83,22 @@ export function lineChart(series, options = {}) {
|
||||
baseLine.setAttribute("class", "line-chart-baseline");
|
||||
svg.appendChild(baseLine);
|
||||
|
||||
for (const [value, anchor] of [[max, "start"], [min, "end"]]) {
|
||||
// The axis always names its floor and ceiling; the baseline gets its own
|
||||
// label when it sits strictly between them (a price index reads its 100).
|
||||
const ticks = [[max, "start", false], [min, "end", false]];
|
||||
if (baseline > min && baseline < max) ticks.push([baseline, "start", true]);
|
||||
for (const [value, anchor, isBaseline] of ticks) {
|
||||
const label = document.createElementNS(SVG_NS, "text");
|
||||
label.setAttribute("x", String(pad.left - 5));
|
||||
label.setAttribute("y", String(y(value) + (anchor === "start" ? 3 : 0)));
|
||||
label.setAttribute("text-anchor", "end");
|
||||
label.setAttribute("class", "line-chart-label");
|
||||
label.setAttribute(
|
||||
"class",
|
||||
isBaseline ? "line-chart-label line-chart-baseline-label" : "line-chart-label"
|
||||
);
|
||||
// Fallback fill so the baseline label still reads if the stylesheet is a
|
||||
// cached older copy without the class.
|
||||
if (isBaseline) label.setAttribute("fill", "rgba(255, 255, 255, 0.6)");
|
||||
label.textContent = String(Math.round(value));
|
||||
svg.appendChild(label);
|
||||
}
|
||||
@@ -68,7 +116,20 @@ export function lineChart(series, options = {}) {
|
||||
svg.appendChild(polyline);
|
||||
}
|
||||
|
||||
if (options.xLabel) {
|
||||
if (bands.length > 0) {
|
||||
for (const entry of bands) {
|
||||
const { left, right } = band(entry.start, entry.end);
|
||||
if (right - left < 24) continue;
|
||||
const label = document.createElementNS(SVG_NS, "text");
|
||||
label.setAttribute("x", String((left + right) / 2));
|
||||
label.setAttribute("y", String(height - 6));
|
||||
label.setAttribute("text-anchor", "middle");
|
||||
label.setAttribute("fill", "#e8eef7");
|
||||
label.setAttribute("class", "line-chart-month");
|
||||
label.textContent = entry.label;
|
||||
svg.appendChild(label);
|
||||
}
|
||||
} else if (options.xLabel) {
|
||||
for (const i of [0, count - 1]) {
|
||||
if (count <= 1 && i !== 0) continue;
|
||||
const label = document.createElementNS(SVG_NS, "text");
|
||||
|
||||
@@ -131,16 +131,25 @@ export const RESOURCE_MARKET_BASE = {
|
||||
hightech: 13_600, // per unit
|
||||
};
|
||||
|
||||
// How strongly a day's world-wide imbalance moves the price, and how far it may
|
||||
// drift from the base. The market is a smoothing device, not a real auction:
|
||||
// it lets a shortage raise the cost of imports and a glut cheapen them.
|
||||
// How the world market finds its price each day. The market is a smoothing
|
||||
// device, not a real auction: the day's imbalance sets an equilibrium, the
|
||||
// price that clears supply against demand, and the price eases toward it. There
|
||||
// is no price band, so a sustained shortage lifts a good well past its base
|
||||
// while a glut cheapens it, but the equilibrium keeps the price anchored: it
|
||||
// settles rather than compounding without bound. `maxRatio` bounds how sharp a
|
||||
// shortage the ratio may read, so a rounding-error supply cannot run away, and
|
||||
// is also the ratio a day with no supply at all is treated as.
|
||||
export const RESOURCE_MARKET = {
|
||||
base: RESOURCE_MARKET_BASE,
|
||||
adjustment: 0.2,
|
||||
minMultiplier: 0.5,
|
||||
maxMultiplier: 1.8,
|
||||
maxRatio: 10,
|
||||
};
|
||||
|
||||
// The central bank charts a rolling window of the world commodity price index:
|
||||
// one sample per day for this many days. The model keeps that many daily
|
||||
// samples and the browser draws the same window.
|
||||
export const PRICE_INDEX_WINDOW_DAYS = 90;
|
||||
|
||||
// A region whose food store runs dry and cannot feed its people is in famine:
|
||||
// the whole world is told the day it begins, and the region loses this share of
|
||||
// its people every day until food comes back.
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
RESOURCE_RULES,
|
||||
RESOURCE_MARKET_BASE,
|
||||
RESOURCE_TRADE_RADIUS,
|
||||
PRICE_INDEX_WINDOW_DAYS,
|
||||
MIDDLEMAN,
|
||||
FAMINE,
|
||||
tileImprovementById,
|
||||
@@ -93,11 +94,21 @@ export const resourceMethods = {
|
||||
this.resourceMarketStats.set(id, { supply: 0, demand: 0, price: RESOURCE_MARKET_BASE[id] });
|
||||
}
|
||||
// The world price of every commodity at the start of each month, oldest
|
||||
// first, so the central bank can chart an index from 100 in January 2000 and
|
||||
// read month-on-month and year-on-year inflation. The first sample is the
|
||||
// opening price, the index's base.
|
||||
this.resourcePriceHistory = new Map();
|
||||
for (const id of RESOURCE_IDS) this.resourcePriceHistory.set(id, [RESOURCE_MARKET_BASE[id]]);
|
||||
// first, so the central bank can read month-on-month and year-on-year
|
||||
// inflation. The first sample is the opening price, the index's base.
|
||||
this.resourceMonthlyPrices = new Map();
|
||||
for (const id of RESOURCE_IDS) {
|
||||
this.resourceMonthlyPrices.set(id, [RESOURCE_MARKET_BASE[id]]);
|
||||
}
|
||||
// One world price per day, newest last, for the central bank's index chart.
|
||||
// The series rolls: once it holds a full window the oldest day drops as a
|
||||
// new one is sampled, so it never grows with the game. The chart is drawn
|
||||
// against this fixed window, so days the game has not reached yet are left
|
||||
// blank rather than stretched across the plot.
|
||||
this.resourceDailyPrices = new Map();
|
||||
for (const id of RESOURCE_IDS) {
|
||||
this.resourceDailyPrices.set(id, [RESOURCE_MARKET_BASE[id]]);
|
||||
}
|
||||
this.resourceLinks = [];
|
||||
this._resourceShortages = new Map();
|
||||
// The viewer-keyed delivery graph is rebuilt when the resource day rolls
|
||||
@@ -508,13 +519,27 @@ export const resourceMethods = {
|
||||
},
|
||||
|
||||
// Snapshots every commodity's world price at the start of a new month. Called
|
||||
// from the monthly rollover, so the series holds one opening price per month.
|
||||
// from the monthly rollover, so the series holds one opening price per month
|
||||
// for the month-on-month and year-on-year inflation readings.
|
||||
_sampleResourcePrices() {
|
||||
if (!this.resourcePriceHistory) return;
|
||||
if (!this.resourceMonthlyPrices) return;
|
||||
for (const id of RESOURCE_IDS) {
|
||||
const series = this.resourcePriceHistory.get(id) || [];
|
||||
const series = this.resourceMonthlyPrices.get(id) || [];
|
||||
series.push(this.getResourcePrice(id));
|
||||
this.resourcePriceHistory.set(id, series);
|
||||
this.resourceMonthlyPrices.set(id, series);
|
||||
}
|
||||
},
|
||||
|
||||
// Adds today's world prices to the daily series the central bank charts, and
|
||||
// drops the oldest once the rolling window is full. Called at the end of every
|
||||
// daily resource tick, after the day's prices have been set.
|
||||
_sampleDailyResourcePrices() {
|
||||
if (!this.resourceDailyPrices) return;
|
||||
for (const id of RESOURCE_IDS) {
|
||||
const series = this.resourceDailyPrices.get(id) || [];
|
||||
series.push(this.getResourcePrice(id));
|
||||
while (series.length > PRICE_INDEX_WINDOW_DAYS) series.shift();
|
||||
this.resourceDailyPrices.set(id, series);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -524,7 +549,7 @@ export const resourceMethods = {
|
||||
// a year earlier; through 2000 there is no earlier year, so the year's first
|
||||
// month stands in and yearly reads as growth since the start of the game.
|
||||
resourceInflation(id) {
|
||||
const series = this.resourcePriceHistory ? this.resourcePriceHistory.get(id) : null;
|
||||
const series = this.resourceMonthlyPrices ? this.resourceMonthlyPrices.get(id) : null;
|
||||
const price = this.getResourcePrice(id);
|
||||
if (!series || series.length === 0) {
|
||||
return { index: 100, monthly: 0, yearly: 0 };
|
||||
@@ -1609,6 +1634,8 @@ export const resourceMethods = {
|
||||
stats.price = price;
|
||||
}
|
||||
this._recordCommodityInflation(market, previousPrices);
|
||||
// Today's prices join the central bank's daily index series.
|
||||
this._sampleDailyResourcePrices();
|
||||
|
||||
this._applyResourceShortages(ledger);
|
||||
this._pruneResourceStock();
|
||||
|
||||
@@ -98,8 +98,8 @@ export const serializationMethods = {
|
||||
// The global resource market: today's price and the supply and demand
|
||||
// that moved it, for the resource panel.
|
||||
resourceMarket: this._serializeResourceMarket(),
|
||||
// The monthly world-price series per commodity and the inflation each one
|
||||
// shows, for the central bank's index chart and its monthly/yearly rates.
|
||||
// The daily world-price index series per commodity, for the central
|
||||
// bank's chart, and the monthly/yearly inflation each one shows.
|
||||
resourcePriceHistory: this._serializeResourcePriceHistory(),
|
||||
resourceInflation: this._serializeResourceInflation(),
|
||||
// Every nation's imports and exports of each resource over the last day.
|
||||
@@ -654,14 +654,19 @@ export const serializationMethods = {
|
||||
});
|
||||
},
|
||||
|
||||
// The opening world price of every commodity at the start of each month,
|
||||
// [id, [price, ...]], oldest first. World-wide, so it travels once.
|
||||
// The daily world-price index of every commodity, [id, [index, ...]], oldest
|
||||
// first, 100 at January 2000's opening price. The daily series rolls, so the
|
||||
// array never exceeds the chart window; world-wide, so it travels once.
|
||||
_serializeResourcePriceHistory() {
|
||||
return RESOURCE_IDS.map((id) => [
|
||||
id,
|
||||
(this.resourcePriceHistory ? this.resourcePriceHistory.get(id) || [] : [])
|
||||
.map((price) => roundTo(price, 3)),
|
||||
]);
|
||||
return RESOURCE_IDS.map((id) => {
|
||||
const series = this.resourceDailyPrices ? this.resourceDailyPrices.get(id) || [] : [];
|
||||
const monthly = this.resourceMonthlyPrices ? this.resourceMonthlyPrices.get(id) || [] : [];
|
||||
const base = monthly[0] > 0 ? monthly[0] : (series[0] > 0 ? series[0] : 1);
|
||||
return [
|
||||
id,
|
||||
series.map((price) => roundTo((price / base) * 100, 3)),
|
||||
];
|
||||
});
|
||||
},
|
||||
|
||||
// Each commodity's index (100 in January 2000) and its month-on-month and
|
||||
|
||||
+11
-9
@@ -235,15 +235,17 @@ export function producerEnergyPerDay(id, outputPerDay) {
|
||||
|
||||
// ----------------------------------------------------------------- market --
|
||||
|
||||
// Moves a price toward what the day's world-wide supply and demand imply. A
|
||||
// glut pushes it down, a shortage up, and the clamp keeps the market sane. A
|
||||
// zero-supply day is treated as the sharpest shortage.
|
||||
// Eases a price toward the equilibrium the day's world-wide supply and demand
|
||||
// imply: the price that clears supply against demand is `base * demand/supply`,
|
||||
// and the price travels a fixed share of the way there each day. A sustained
|
||||
// shortage therefore lifts a good above its base and a glut cheapens it, but the
|
||||
// price settles at the equilibrium instead of compounding without bound, so no
|
||||
// price band is needed to keep it sane. A day with no supply at all is treated as
|
||||
// the sharpest shortage the ratio may read, and the ratio is capped so a
|
||||
// rounding-error supply cannot run the equilibrium away.
|
||||
export function nextMarketPrice(basePrice, price, supply, demand) {
|
||||
if (!(demand > 0)) return basePrice;
|
||||
const ratio = supply > 0 ? demand / supply : RESOURCE_MARKET.maxMultiplier;
|
||||
const pressure = (ratio - 1) * RESOURCE_MARKET.adjustment;
|
||||
const moved = price * (1 + pressure);
|
||||
const min = basePrice * RESOURCE_MARKET.minMultiplier;
|
||||
const max = basePrice * RESOURCE_MARKET.maxMultiplier;
|
||||
return Math.max(min, Math.min(max, moved));
|
||||
const ratio = supply > 0 ? demand / supply : RESOURCE_MARKET.maxRatio;
|
||||
const equilibrium = basePrice * Math.min(ratio, RESOURCE_MARKET.maxRatio);
|
||||
return price + (equilibrium - price) * RESOURCE_MARKET.adjustment;
|
||||
}
|
||||
|
||||
+30
-2
@@ -1,6 +1,6 @@
|
||||
import { TestCase } from "./framework/test_case.js";
|
||||
import { smallState } from "./framework/helpers.js";
|
||||
import { RESOURCE_IDS, RESOURCE_MARKET_BASE } from "../shared/data.js";
|
||||
import { RESOURCE_IDS, RESOURCE_MARKET_BASE, PRICE_INDEX_WINDOW_DAYS } from "../shared/data.js";
|
||||
|
||||
export class InflationHistoryTest extends TestCase {
|
||||
test_the_index_starts_at_one_hundred() {
|
||||
@@ -13,6 +13,34 @@ export class InflationHistoryTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
test_the_daily_series_rolls_over_the_window() {
|
||||
const state = smallState();
|
||||
const base = RESOURCE_MARKET_BASE.steel;
|
||||
// One sample a day for longer than the window, each 1% dearer than the last.
|
||||
for (let day = 1; day <= PRICE_INDEX_WINDOW_DAYS + 5; day++) {
|
||||
state.setResourcePrice("steel", base * (1 + 0.01 * day));
|
||||
state._sampleDailyResourcePrices();
|
||||
}
|
||||
const series = state.resourceDailyPrices.get("steel");
|
||||
this.assertSize(series, PRICE_INDEX_WINDOW_DAYS, "the daily series holds the window");
|
||||
this.assertApprox(series[0], base * 1.06, 1e-6, "the oldest five days have rolled off");
|
||||
this.assertApprox(series[series.length - 1], base * 1.95, 1e-6, "the newest day is today");
|
||||
}
|
||||
|
||||
test_the_snapshot_ships_daily_indices_rebased_on_the_opening_price() {
|
||||
const state = smallState();
|
||||
const base = RESOURCE_MARKET_BASE.food;
|
||||
state.setResourcePrice("food", base * 1.25);
|
||||
state._sampleDailyResourcePrices();
|
||||
const shared = state.serializeShared();
|
||||
const food = shared.resourcePriceHistory.find(([id]) => id === "food");
|
||||
this.assertSize(food[1], 2, "the opening price and today's point");
|
||||
this.assertApprox(food[1][0], 100, 1e-6, "the series opens at the index base");
|
||||
this.assertApprox(food[1][1], 125, 1e-6, "today's point carries the move");
|
||||
const inflation = shared.resourceInflation.find((entry) => entry.id === "food");
|
||||
this.assertApprox(inflation.index, 125, 1e-6, "the table's live index agrees");
|
||||
}
|
||||
|
||||
test_monthly_inflation_compares_with_last_month() {
|
||||
const state = smallState();
|
||||
const base = RESOURCE_MARKET_BASE.steel;
|
||||
@@ -48,7 +76,7 @@ export class InflationHistoryTest extends TestCase {
|
||||
this.assertSize(shared.resourceInflation, RESOURCE_IDS.length);
|
||||
for (const [id, prices] of shared.resourcePriceHistory) {
|
||||
this.assertTrue(RESOURCE_IDS.includes(id));
|
||||
this.assertSize(prices, 1, "one opening price at the start");
|
||||
this.assertSize(prices, 1, "one opening index at the start");
|
||||
}
|
||||
const steel = shared.resourceInflation.find((entry) => entry.id === "steel");
|
||||
this.assertApprox(steel.index, 100, 1e-9);
|
||||
|
||||
@@ -28,6 +28,39 @@ export class LineChartTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_chart_shades_and_names_month_bands() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const svg = lineChart(
|
||||
[{ values: [100, 110] }],
|
||||
{
|
||||
width: 400,
|
||||
height: 160,
|
||||
window: 4,
|
||||
min: 0,
|
||||
xBands: [
|
||||
{ start: 0, end: 1, label: "Jan" },
|
||||
{ start: 2, end: 3, label: "Feb" },
|
||||
],
|
||||
}
|
||||
);
|
||||
this.assertSize(
|
||||
svg.querySelectorAll(".line-chart-band-even, .line-chart-band-odd"),
|
||||
2,
|
||||
"one shaded column per month band"
|
||||
);
|
||||
const labels = svg.querySelectorAll(".line-chart-month");
|
||||
this.assertSize(labels, 2, "each month is named once");
|
||||
this.assertEqual(labels[0].textContent, "Jan");
|
||||
this.assertEqual(labels[1].textContent, "Feb");
|
||||
const axisLabels = [...svg.querySelectorAll(".line-chart-label")].map((el) => el.textContent);
|
||||
this.assertTrue(axisLabels.includes("0"), "a pinned floor puts zero at the bottom");
|
||||
this.assertTrue(axisLabels.includes("100"), "the baseline is labelled 100");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_chart_defaults_to_a_small_size() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
economicFraction,
|
||||
} from "../client/js/map_view/economic.js";
|
||||
import { smallState, mapLayers } from "./framework/helpers.js";
|
||||
import { mapToLocal } from "../shared/hex.js";
|
||||
|
||||
function cssRgb(hex) {
|
||||
const value = hex.replace("#", "");
|
||||
@@ -307,6 +308,99 @@ export class MapModesTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_the_graph_follows_the_repeating_world_across_the_seam() {
|
||||
const state = smallState();
|
||||
const env = await setupDom();
|
||||
const map = this.buildMap(env);
|
||||
try {
|
||||
map.applySnapshot(state.snapshot(0));
|
||||
map.setMapMode("gdp");
|
||||
const { width, originX, originY } = map.topology;
|
||||
this.assertTrue(map.topology.cylindrical, "the fixture map wraps");
|
||||
const period = map.topology.periodPixels();
|
||||
const row = originY + 1;
|
||||
const west = originX;
|
||||
// The east edge is a neighbour of the west edge over the seam.
|
||||
const east = originX + width - 1;
|
||||
|
||||
map.setResourceGraph({
|
||||
nodes: [[west, row, 1], [east, row, 0]],
|
||||
links: [[west, row, east, row, "steel", 10]],
|
||||
edges: [[west, row, east, row]],
|
||||
});
|
||||
|
||||
// The seam leg is drawn one tile long at any camera, not across the map:
|
||||
// the far end snaps to the copy of its neighbour across the seam.
|
||||
map.camera.x = mapToLocal(Math.round((west + east) / 2), row).x;
|
||||
map.animate(0);
|
||||
const segments = env.$("#layer-resources .border-seg");
|
||||
this.assertSize(segments, 2, "the idle route and the active leg are both drawn");
|
||||
segments.each((_index, el) => {
|
||||
this.assertLess(parseFloat(el.style.width), period / 4,
|
||||
"the seam leg is redrawn one tile long, not a map period");
|
||||
});
|
||||
|
||||
// Zoomed out the viewport spans more than one period, so the whole world
|
||||
// (graph included) is drawn repeated in each copy instead of folded.
|
||||
map.camera.zoom = 0.5;
|
||||
map.camera.x = 0;
|
||||
map.camera.y = 0;
|
||||
map.animate(0);
|
||||
const offsets = map._visibleWrapOffsets();
|
||||
this.assertGreater(offsets.length, 1, "several world copies are on screen");
|
||||
const entry = map._wrapCopies.find((e) => e.source.is("#layer-resources"));
|
||||
this.assertNotNull(entry, "the resource layer repeats");
|
||||
const liveCopy = entry.copies.find((el) => el.style.display !== "none");
|
||||
this.assertNotNull(liveCopy, "an extra copy is drawn");
|
||||
this.assertGreater(
|
||||
liveCopy.querySelectorAll(".border-seg").length, 0, "the delivery legs repeat"
|
||||
);
|
||||
this.assertGreater(
|
||||
liveCopy.querySelectorAll(".resource-node").length, 0, "the storage nodes repeat"
|
||||
);
|
||||
const matched = /translateX\((-?[\d.]+)px\)/.exec(liveCopy.style.transform);
|
||||
this.assertNotNull(matched, `the copy is offset horizontally: ${liveCopy.style.transform}`);
|
||||
this.assertEqual(
|
||||
Math.abs(parseFloat(matched[1])) % period, 0,
|
||||
"the copy sits a whole number of periods from the live one"
|
||||
);
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_tile_improvements_repeat_with_the_world() {
|
||||
const state = smallState();
|
||||
const env = await setupDom();
|
||||
const map = this.buildMap(env);
|
||||
try {
|
||||
map.applySnapshot(state.snapshot(0));
|
||||
map.setWarfare([], [[0, 0, "mine", 0, 100]], 1);
|
||||
// On the single copy the marker sits at its raw map position.
|
||||
const marker = env.$("#layer-entities .tile-improvement-marker").eq(0);
|
||||
this.assertSize(marker, 1, "the improvement is drawn once");
|
||||
this.assertApprox(parseFloat(marker[0].style.left), mapToLocal(0, 0).x, 1e-6,
|
||||
"at its raw position, not folded to the camera");
|
||||
|
||||
// Zoom out until the viewport spans more than one period: the marker must
|
||||
// be repeated in each copy, like the terrain the WebGL ground repeats.
|
||||
map.camera.zoom = 0.5;
|
||||
map.camera.x = 0;
|
||||
map.camera.y = 0;
|
||||
map.animate(0);
|
||||
this.assertGreater(map._visibleWrapOffsets().length, 1, "several copies on screen");
|
||||
const entry = map._wrapCopies.find((e) => e.source.is("#layer-entities"));
|
||||
const liveCopy = entry.copies.find((el) => el.style.display !== "none");
|
||||
this.assertNotNull(liveCopy, "an extra entity copy is drawn");
|
||||
this.assertGreater(
|
||||
liveCopy.querySelectorAll(".tile-improvement-marker").length, 0,
|
||||
"the tile improvement repeats in the copy"
|
||||
);
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_the_merchandise_icons_animate_along_the_line() {
|
||||
const state = smallState();
|
||||
const env = await setupDom();
|
||||
|
||||
+68
-9
@@ -1121,13 +1121,13 @@ export class NationModalTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_central_bank_chart_rolls_a_two_year_window() {
|
||||
async test_central_bank_chart_rolls_a_ninety_day_window() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const modal = new NationModal();
|
||||
// Thirty monthly openings, so six must fall off the twenty-four month
|
||||
// window. The live index matches the last sample, so no point is appended.
|
||||
const energy = Array.from({ length: 30 }, (_, i) => 100 + i * 10);
|
||||
// One hundred and twenty daily points, so thirty fall off the ninety-day
|
||||
// window. The clock is day 120, so the window's first day is day 31.
|
||||
const energy = Array.from({ length: 120 }, (_, i) => 100 + i);
|
||||
const money = {
|
||||
viewerCiv: 0,
|
||||
currency: { code: "FRF", symbol: "₣", name: "French Franc" },
|
||||
@@ -1144,8 +1144,9 @@ export class NationModalTest extends TestCase {
|
||||
currencyHoldings: null,
|
||||
privateCash: 0,
|
||||
cities: [],
|
||||
totalHours: 120 * 24,
|
||||
resourcePriceHistory: [["energy", energy]],
|
||||
resourceInflation: [{ id: "energy", index: 390, monthly: 0.03, yearly: 0.25 }],
|
||||
resourceInflation: [{ id: "energy", index: 219, monthly: 0.03, yearly: 0.25 }],
|
||||
};
|
||||
const resourcesInfo = {
|
||||
summary: {
|
||||
@@ -1163,10 +1164,68 @@ export class NationModalTest extends TestCase {
|
||||
env.$("#modal-nation .tab[data-tab=economy]").click();
|
||||
env.$("#economy-subtabs .subtab[data-view=centralbank]").click();
|
||||
const points = env.$("#economy-central-bank .line-chart-line").attr("points") || "";
|
||||
this.assertEqual(points.trim().split(/\s+/).length, 24, "the window holds 24 months");
|
||||
const labels = env.$("#economy-central-bank .line-chart-label");
|
||||
this.assertEqual(labels.eq(-2).text(), "Jul 2000", "the window starts two years back");
|
||||
this.assertEqual(labels.eq(-1).text(), "Jun 2002", "the window ends at the latest month");
|
||||
this.assertEqual(points.trim().split(/\s+/).length, 90, "the window holds 90 days");
|
||||
const months = env.$("#economy-central-bank .line-chart-month");
|
||||
this.assertEqual(months.length, 3, "one label per month in the window");
|
||||
this.assertEqual(months.eq(0).text(), "Feb", "the window starts in February");
|
||||
this.assertEqual(months.eq(1).text(), "Mar");
|
||||
this.assertEqual(months.eq(2).text(), "Apr", "and ends in April");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_central_bank_chart_leaves_future_days_blank() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const modal = new NationModal();
|
||||
// Ten daily points: the line must cover ten of the ninety slots, with the
|
||||
// rest of the fixed axis left for days the game has not reached.
|
||||
const energy = Array.from({ length: 10 }, (_, i) => 100 + i);
|
||||
const money = {
|
||||
viewerCiv: 0,
|
||||
currency: { code: "FRF", symbol: "₣", name: "French Franc" },
|
||||
currencies: [{ civ: 0, code: "FRF", symbol: "₣", name: "French Franc", value: 1 }],
|
||||
exchangeRates: [{ civ: 0, code: "FRF", symbol: "₣", name: "French Franc", rate: 1 }],
|
||||
centralBank: { rate: 0.02, reserves: [], loans: 0 },
|
||||
taxes: {
|
||||
sales: { rate: 0.2, resources: {} },
|
||||
export: { rate: 0.05, resources: {} },
|
||||
import: { rate: 0.05, resources: {} },
|
||||
},
|
||||
takeTax: { sales: 0, export: 0, import: 0 },
|
||||
commodityInflation: 0,
|
||||
currencyHoldings: null,
|
||||
privateCash: 0,
|
||||
cities: [],
|
||||
totalHours: 9 * 24,
|
||||
resourcePriceHistory: [["energy", energy]],
|
||||
resourceInflation: [{ id: "energy", index: 109, monthly: 0.01, yearly: 0.09 }],
|
||||
};
|
||||
const resourcesInfo = {
|
||||
summary: {
|
||||
stock: {}, need: {}, production: {}, prices: {},
|
||||
consumed: { steel: 0, food: 0, luxury: 0, hightech: 0, energy: 0 },
|
||||
external: { buy: {}, sell: {} },
|
||||
},
|
||||
market: [],
|
||||
trade: [],
|
||||
};
|
||||
modal.show(
|
||||
GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6,
|
||||
{ months: [], cities: [] }, null, [], null, null, resourcesInfo, money
|
||||
);
|
||||
env.$("#modal-nation .tab[data-tab=economy]").click();
|
||||
env.$("#economy-subtabs .subtab[data-view=centralbank]").click();
|
||||
const chart = env.$("#economy-central-bank .line-chart").get(0);
|
||||
const viewBox = (chart.getAttribute("viewBox") || "").split(/\s+/);
|
||||
const points = env.$("#economy-central-bank .line-chart-line").attr("points") || "";
|
||||
const last = points.trim().split(/\s+/).pop().split(",");
|
||||
const plotW = Number(viewBox[2]) - 40 - 10;
|
||||
this.assertApprox(
|
||||
Number(last[0]), 40 + (9 / 89) * plotW, 0.5,
|
||||
"ten points sit on the first ten of the ninety fixed slots, not stretched"
|
||||
);
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { key } from "../shared/hex.js";
|
||||
import {
|
||||
RESOURCE_RULES,
|
||||
RESOURCE_MARKET_BASE,
|
||||
RESOURCE_MARKET,
|
||||
RESOURCE_BUILDINGS,
|
||||
EFFECT_RESEARCH,
|
||||
technologyCost,
|
||||
@@ -95,6 +96,21 @@ export class ResourceRulesTest extends TestCase {
|
||||
this.assertGreater(scarce, base);
|
||||
this.assertLess(glut, base);
|
||||
}
|
||||
|
||||
test_market_price_settles_at_its_equilibrium_without_a_clamp() {
|
||||
const base = RESOURCE_MARKET_BASE.steel;
|
||||
// A persistent shortage of half the supply settles at twice the base price:
|
||||
// a price above the old 1.8x band, reached without a clamp and without
|
||||
// compounding away to infinity.
|
||||
let price = base;
|
||||
for (let day = 0; day < 200; day++) price = nextMarketPrice(base, price, 10, 20);
|
||||
this.assertApprox(price, base * 2, base * 1e-6, "the price eases to base * demand/supply");
|
||||
// A day with no supply at all reads as the sharpest shortage the ratio may
|
||||
// express, so the price settles at that multiple rather than running away.
|
||||
let scarce = base;
|
||||
for (let day = 0; day < 200; day++) scarce = nextMarketPrice(base, scarce, 0, 10);
|
||||
this.assertApprox(scarce, base * RESOURCE_MARKET.maxRatio, base * 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
export class ResourceModelTest extends TestCase {
|
||||
|
||||
Reference in New Issue
Block a user