feat(civilization): city screen overhaul with fat-cross map, scrollable builds, and yield tracking

- Add CivilizationCityMap.js: draws the 21-tile fat cross in the city screen
  with terrain, specials, city sprite, pips, borders, units, and veiled tiles
- Replace fixed 28-row build list with a scrollable window; add shield cost
  and turn-estimate annotations per build choice
- Add garrison display: fortified units shown as clickable roundels
- Refactor tileYield() to accept a `notes` array that records each rule's
  delta, enabling accurate tooltips that can never drift from engine math
- Extract paintTerrainDiamond() and specialColor() from CivilizationMapView
  so the city map and main map share identical rendering
- Add cityCentreYield() enforcing the 1-shield/1-trade market-economy floor
- Add cityTileStatus() for consistent worked/idle/taken/offmap classification
- Add describeCityTileTooltip() with terrain title, yield breakdown, missing
  improvement explanations, and status line
- Wire improvementSheet and iconSheet paths in civilization-artwork.json
- Add comprehensive verifyCivilization checks for yield notes, centre floor,
  tile status, tooltips, and build list capacity
This commit is contained in:
Brian Fertig 2026-07-23 19:21:54 -06:00
parent 4fcae92e59
commit 3122bb2f28
11 changed files with 789 additions and 138 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -10,9 +10,9 @@
],
"terrainSheet": { "key": "civilization-terrain", "path": "assets/images/civilization/civilization-terrain.png", "frameWidth": 128, "frameHeight": 96 },
"resourceSheet": { "key": "civilization-resources", "path": "assets/images/civilization/civilization-resources.png", "frameWidth": 64, "frameHeight": 64 },
"improvementSheet": { "key": "civilization-improvements", "path": null, "frameWidth": 64, "frameHeight": 64 },
"improvementSheet": { "key": "civilization-improvements", "path": "assets/images/civilization/civilization-improvements.png", "frameWidth": 64, "frameHeight": 64 },
"unitSheet": { "key": "civilization-units", "path": "assets/images/civilization/civilization-units.png", "frameWidth": 64, "frameHeight": 96 },
"iconSheet": { "key": "civilization-icons", "path": null, "frameWidth": 48, "frameHeight": 48 },
"iconSheet": { "key": "civilization-icons", "path": "assets/images/civilization/civilization-icons.png", "frameWidth": 48, "frameHeight": 48 },
"citySheets": {
"classic": { "key": "civilization-cities-classic", "path": "assets/images/civilization/civilization-cities-classic.png", "frameWidth": 128, "frameHeight": 96 },
"cyberpunk": { "key": "civilization-cities-cyberpunk", "path": "assets/images/civilization/civilization-cities-cyberpunk.png", "frameWidth": 128, "frameHeight": 96 },

View File

@ -0,0 +1,254 @@
// Civilization — the fat-cross city map drawn in the city screen's lower-right
// quarter: the 21 tiles this city can reach, what's on them, which ones its
// citizens are working, and why the rest are off limits.
//
// This is a small, static, display-only view — not a second CivilizationMapView.
// The main map is a chunked-RenderTexture system built for panning a whole
// world; twenty-one tiles in a modal want plain game objects. What the two DO
// share is the tile art itself (paintTerrainDiamond, specialColor, the frame
// offsets and the city-sprite tier maths), so the inset can never drift from
// the map it is a window onto.
//
// Tiles are drawn front-to-back by (dx + dy) exactly as CivilizationMapView
// orders them, and each tile's overlays (border, pips, units) are drawn with
// that tile rather than in a later pass — so a mountain in front correctly
// occludes the tile behind it through its 32px of frame headroom.
import * as Phaser from 'phaser';
import { COLORS } from '../../config.js';
import * as Logic from './CivilizationLogic.js';
import { shieldGrassAt } from './CivilizationWorldGen.js';
import {
TILE_W, TILE_H, FRAME_H, paintTerrainDiamond, specialColor,
} from './CivilizationMapView.js';
import { describeCityTileTooltip } from './CivilizationTooltips.js';
const FONT = '"Julius Sans One"';
// The fat cross spans 3 tiles either side of centre in iso space, so its art
// is 4 tiles wide and (with the frame headroom) 4 tiles + 32px tall.
export const MAP_W = TILE_W * 4;
export const MAP_H = TILE_H * 4 + (FRAME_H - TILE_H);
const PIP_MAX = 5; // more than this on one tile is unreachable in practice
const PIP_GAP = 7;
const PIP_Y = 9; // below the diamond's centre, clear of the special marker
const DIAMOND = [
[0, -TILE_H / 2], [TILE_W / 2, 0], [0, TILE_H / 2], [-TILE_W / 2, 0],
];
function diamondPath(g) {
g.beginPath();
g.moveTo(DIAMOND[0][0], DIAMOND[0][1]);
for (const [x, y] of DIAMOND.slice(1)) g.lineTo(x, y);
g.closePath();
}
// Draws the map centred so the CITY's own tile sits at (cx, cy). Adds every
// object to `parent` and attaches hover tooltips to the shared `tooltip`.
export function drawCityMap(scene, rules, state, city, parent, tooltip, cx, cy) {
const { world } = state;
const civ = state.civs[city.civ];
const civColor = Phaser.Display.Color.HexStringToColor(civ.color).color;
const human = state.humanIndex;
const tiles = [...Logic.CITY_RADIUS]
.sort(([ax, ay], [bx, by]) => (ax + ay) - (bx + by) || ax - bx);
for (const [dx, dy] of tiles) {
const tx = city.x + dx;
const ty = city.y + dy;
const dcx = cx + (dx - dy) * (TILE_W / 2);
const dcy = cy + (dx + dy) * (TILE_H / 2);
const status = Logic.cityTileStatus(state, city, tx, ty);
if (status.kind === 'offmap') {
const g = scene.add.graphics({ x: dcx, y: dcy });
g.fillStyle(0x05070a, 1);
diamondPath(g);
g.fillPath();
g.lineStyle(1, COLORS.muted, 0.3);
g.strokePath();
parent.add(g);
} else {
drawTerrain(scene, rules, state, parent, world, tx, ty, dcx, dcy);
if (status.kind === 'centre') drawCitySprite(scene, parent, civ, city, dcx, dcy);
else drawSpecial(scene, rules, parent, world, tx, ty, dcx, dcy);
// Unavailable tiles are veiled rather than hidden: the player still sees
// the wheat they can't reach, which is the point of showing it at all.
if (status.kind === 'taken' || status.kind === 'city') {
const veil = scene.add.graphics({ x: dcx, y: dcy });
veil.fillStyle(0x000000, 0.45);
diamondPath(veil);
veil.fillPath();
parent.add(veil);
} else {
drawPips(scene, rules, state, city, parent, tx, ty, dcx, dcy, status.kind === 'centre');
}
drawBorder(scene, parent, dcx, dcy, status.kind, civColor);
drawUnits(scene, rules, state, parent, human, tx, ty, dcx, dcy);
}
// Exact diamond hit area so neighbouring tiles never steal each other's
// hover. Hit-area coordinates are relative to the object's top-left, hence
// the +TILE_W/2 / +TILE_H/2 shift off the DIAMOND points.
const zone = scene.add.zone(dcx, dcy, TILE_W, TILE_H).setInteractive(
new Phaser.Geom.Polygon(DIAMOND.map(([x, y]) => [x + TILE_W / 2, y + TILE_H / 2]).flat()),
Phaser.Geom.Polygon.Contains,
);
tooltip.attachTo(zone, () => describeCityTileTooltip(rules, state, city, tx, ty));
parent.add(zone);
}
drawFooter(scene, rules, state, city, parent, cx, cy);
}
function drawTerrain(scene, rules, state, parent, world, tx, ty, dcx, dcy) {
const idx = Logic.tileIndex(world, tx, ty);
const terr = rules.terrainList[world.terrain[idx]];
const shieldGrass = shieldGrassAt(tx, ty) && world.special[idx] < 0;
if (scene.textures.exists('civilization-terrain')) {
const frame = terr.id === 'grassland' && shieldGrass ? rules.grasslandShieldFrame : terr.frame;
// Same offsets as CivilizationMapView.paintTile: the frame's bottom edge
// is the diamond's bottom vertex, with 32px of headroom above.
parent.add(scene.add.image(dcx - TILE_W / 2, dcy - TILE_H / 2 - (FRAME_H - TILE_H),
'civilization-terrain', frame).setOrigin(0, 0));
return;
}
const g = scene.add.graphics({ x: dcx, y: dcy - TILE_H / 2 });
paintTerrainDiamond(g, terr, shieldGrass);
parent.add(g);
}
function drawSpecial(scene, rules, parent, world, tx, ty, dcx, dcy) {
const idx = Logic.tileIndex(world, tx, ty);
if (world.special[idx] < 0) return;
const spec = rules.specialList[world.special[idx]];
if (scene.textures.exists('civilization-resources')) {
parent.add(scene.add.image(dcx - 32, dcy - TILE_H / 2, 'civilization-resources', spec.frame)
.setOrigin(0, 0));
return;
}
const g = scene.add.graphics({ x: dcx, y: dcy });
g.fillStyle(0xffffff, 0.85);
g.fillCircle(0, -6, 9);
g.fillStyle(specialColor(spec.id), 1);
g.fillCircle(0, -6, 7);
parent.add(g);
}
// Mirrors CivilizationMapView.drawCity's tier/walled frame maths so the inset
// shows the same city the map does.
function drawCitySprite(scene, parent, civ, city, dcx, dcy) {
const tier = city.size >= 13 ? 3 : city.size >= 8 ? 2 : city.size >= 4 ? 1 : 0;
const walled = !!city.buildings.citywalls;
const themeKey = `civilization-cities-${civ.citySheet ?? 'classic'}`;
if (scene.textures.exists(themeKey)) {
parent.add(scene.add.image(dcx, dcy - TILE_H / 2 - (FRAME_H - TILE_H) + 48,
themeKey, walled ? 4 + tier : tier).setOrigin(0.5, 0));
return;
}
const g = scene.add.graphics({ x: dcx, y: dcy });
g.fillStyle(0x6b6255, 1);
for (let i = 0; i <= tier; i += 1) {
const bw = 26 - i * 3;
const bh = 16 + i * 8;
g.fillRect(-24 + i * 16, 4 - bh, bw, bh);
g.fillStyle(0x7d7466, 1);
}
if (walled) {
g.lineStyle(3, 0x9a8866, 1);
g.strokeRect(-34, -14, 68, 22);
}
parent.add(g);
}
// Food/shield/trade marks on the tile itself, so tiles can be compared without
// hovering every one. Sits on a dark pill because the pips have to read against
// desert and ocean alike.
function drawPips(scene, rules, state, city, parent, tx, ty, dcx, dcy, isCentre) {
const out = isCentre
? Logic.cityCentreYield(rules, state, city)
: Logic.tileYield(rules, state, city.civ, city, tx, ty);
const marks = [
...Array(Math.min(PIP_MAX, out.food)).fill('food'),
...Array(Math.min(PIP_MAX, out.shield)).fill('shield'),
...Array(Math.min(PIP_MAX, out.trade)).fill('trade'),
];
if (!marks.length) return;
const g = scene.add.graphics({ x: dcx, y: dcy });
const w = marks.length * PIP_GAP + 6;
g.fillStyle(0x000000, 0.5);
g.fillRoundedRect(-w / 2, PIP_Y - 6, w, 12, 6);
marks.forEach((kind, i) => {
const x = -w / 2 + 3 + PIP_GAP / 2 + i * PIP_GAP;
if (kind === 'food') {
g.fillStyle(0x6fd15f, 1);
g.fillCircle(x, PIP_Y, 2.5);
} else if (kind === 'shield') {
g.fillStyle(0xc9cdd4, 1);
g.fillRect(x - 2.5, PIP_Y - 2.5, 5, 5);
} else {
g.fillStyle(0xe8c84a, 1);
g.fillTriangle(x, PIP_Y - 3, x + 3, PIP_Y + 2.5, x - 3, PIP_Y + 2.5);
}
});
parent.add(g);
}
function drawBorder(scene, parent, dcx, dcy, kind, civColor) {
const g = scene.add.graphics({ x: dcx, y: dcy });
const worked = kind === 'worked' || kind === 'centre';
g.lineStyle(worked ? 3 : 1, worked ? civColor : COLORS.muted, worked ? 1 : 0.45);
diamondPath(g);
g.strokePath();
parent.add(g);
}
// Small civ-coloured roundels rather than the 64x96 unit sprites: at this size
// a real unit frame would cover the tile it is standing on, and the point of
// showing units here is "someone is sitting on my wheat", not unit detail.
function drawUnits(scene, rules, state, parent, human, tx, ty, dcx, dcy) {
if (!state.explored[human]?.[Logic.tileIndex(state.world, tx, ty)]) return;
const units = Logic.unitsAt(state, tx, ty); // already excludes carried units
if (!units.length) return;
const civ = state.civs[units[0].civ];
const color = Phaser.Display.Color.HexStringToColor(civ.color).color;
const g = scene.add.graphics({ x: dcx, y: dcy });
g.fillStyle(color, 0.95);
g.fillCircle(0, -10, 11);
g.lineStyle(2, 0xffffff, 0.85);
g.strokeCircle(0, -10, 11);
parent.add(g);
parent.add(scene.add.text(dcx, dcy - 10, rules.units[units[0].type].abbr, {
fontFamily: FONT, fontSize: '11px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5));
if (units.length > 1) {
parent.add(scene.add.circle(dcx + 12, dcy - 19, 7, 0x000000, 0.85));
parent.add(scene.add.text(dcx + 12, dcy - 19, `${units.length}`, {
fontFamily: FONT, fontSize: '10px', color: '#ffffff',
}).setOrigin(0.5));
}
}
// Ties the map back to the yields panel on the left. Explicitly "before routes
// and corruption" — those are city-wide, not per-tile, so the numbers here
// would otherwise look wrong next to the panel's.
function drawFooter(scene, rules, state, city, parent, cx, cy) {
const total = Logic.cityCentreYield(rules, state, city);
for (const idx of city.worked) {
const tx = idx % state.world.cols;
const ty = (idx / state.world.cols) | 0;
const out = Logic.tileYield(rules, state, city.civ, city, tx, ty);
total.food += out.food;
total.shield += out.shield;
total.trade += out.trade;
}
parent.add(scene.add.text(cx, cy + TILE_H * 2 + 22,
`Worked ${city.worked.length}/${city.size} tiles · ${total.food} food · ${total.shield} shields · ${total.trade} trade (before routes and corruption)`, {
fontFamily: FONT, fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5, 0));
}

View File

@ -1,13 +1,35 @@
// Civilization — city detail modal: growth, yields, worked-tile emphasis,
// build queue with buy, buildings, supported units and trade routes.
// Civilization — city detail modal.
//
// Left half: growth, yields, worker emphasis, improvements, supported units,
// trade routes. Right half is split — the scrollable build picker above, and
// the fat-cross city map (CivilizationCityMap.js) below, so the numbers on the
// left have visible land behind them.
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { Tooltip } from '../../ui/Tooltip.js';
import * as Logic from './CivilizationLogic.js';
import { describeUnitTooltip, describeBuildingTooltip, describeSpecialBuildTooltip } from './CivilizationTooltips.js';
import { drawCityMap, MAP_H } from './CivilizationCityMap.js';
const FONT = '"Julius Sans One"';
const ROW_H = 38;
// Right-hand annotation on a build-list row: what the choice costs in shields
// and how long it would take at this city's current output, or the per-turn
// conversion rate for Coinage/Public Works (which never complete).
function buildRowNote(rules, city, yields, choice) {
if (choice.type === 'gold' || choice.type === 'food') {
const spec = Logic.SPECIAL_BUILDS[choice.type];
const gain = Math.floor(yields.shield / spec.den) * spec.num;
return `+${gain} ${choice.type === 'gold' ? 'gold' : 'food'}/turn`;
}
const cost = choice.ruleObj.cost;
if (yields.shield <= 0) return `${cost} shields · ∞`;
const turns = Math.max(1, Math.ceil(cost / yields.shield));
return `${cost} shields · ${turns} turn${turns === 1 ? '' : 's'}`;
}
export function openCityScreen(scene, rules, state, city, onClose) {
const root = scene.add.container(0, 0).setDepth(65);
@ -25,8 +47,17 @@ export function openCityScreen(scene, rules, state, city, onClose) {
root.add(dynamic);
const tooltip = new Tooltip(scene, { depth: 70 });
// The build list's clipping mask is made with add:false, so it isn't a child
// of `root` and won't be swept up by destroy(true) — it's tracked here and
// released on every redraw and on close.
let listMask = null;
const close = () => { tooltip.destroy(); root.destroy(true); onClose(); };
const close = () => {
tooltip.destroy();
listMask?.destroy();
root.destroy(true);
onClose();
};
const closeBtn = new Button(scene, px + W / 2 - 80, py - H / 2 + 40, '✕', close,
{ width: 60, height: 44, fontSize: 22, variant: 'ghost' });
root.add(closeBtn);
@ -111,82 +142,21 @@ export function openCityScreen(scene, rules, state, city, onClose) {
const other = Logic.cityById(state, r.cityId);
return other ? `${other.name} (+${r.amount})` : `(lost city +${r.amount})`;
});
dynamic.add(scene.add.text(left, top + 660, `Trade routes: ${routes.length ? routes.join(', ') : '(none)'}`, {
const routesTxt = scene.add.text(left, top + 660, `Trade routes: ${routes.length ? routes.join(', ') : '(none)'}`, {
fontFamily: FONT, fontSize: '18px', color: COLORS.textHex, wordWrap: { width: 640 },
}));
// --- Build picker (right column)
const rx = px + 60;
const isSpecialBuild = city.build.type === 'gold' || city.build.type === 'food';
if (isSpecialBuild) {
const spec = Logic.SPECIAL_BUILDS[city.build.type];
const gain = Math.floor((city.shieldBox + y.shield) / spec.den) * spec.num;
const unitLabel = city.build.type === 'gold' ? 'gold' : 'food';
dynamic.add(scene.add.text(rx, top + 64,
`Producing: ${spec.name} +${gain} ${unitLabel}/turn`, {
fontFamily: FONT, fontSize: '22px', color: COLORS.textHex,
}));
} else {
const cost = Logic.buildCost(rules, city);
const cur = city.build.type === 'unit' ? rules.units[city.build.id] : rules.buildings[city.build.id];
const shownShields = Math.min(city.shieldBox, cost);
const turns = y.shield > 0 ? Math.max(0, Math.ceil((cost - shownShields) / y.shield)) : '∞';
dynamic.add(scene.add.text(rx, top + 64,
`Building: ${cur.name} ${shownShields}/${cost} shields (${turns} turns)`, {
fontFamily: FONT, fontSize: '22px', color: COLORS.textHex,
}));
const buyPrice = Logic.buyCost(rules, city);
const buyBtn = new Button(scene, rx + 560, top + 120, `BUY (${buyPrice}g)`, () => {
if (Logic.buyBuild(rules, state, city)) redraw();
}, { width: 190, height: 46, fontSize: 18, variant: civ.gold >= buyPrice && !city.boughtThisTurn ? 'solid' : 'ghost' });
dynamic.add(buyBtn);
}
dynamic.add(scene.add.text(rx, top + 108, `Treasury: ${civ.gold} gold`, {
fontFamily: FONT, fontSize: '19px', color: COLORS.goldHex,
}));
// Choices: Coinage/Public Works, then units, then buildings, two columns of rows.
const specialChoices = Object.entries(Logic.SPECIAL_BUILDS)
.map(([type, spec]) => ({ type, id: spec.id, label: spec.name }));
const unitChoices = Logic.availableUnits(rules, state, civ, city)
.map((u) => ({ type: 'unit', id: u.id, label: `${u.name} (${u.cost})`, ruleObj: u }));
const bldChoices = Logic.availableBuildings(rules, state, civ, city)
.map((b) => ({ type: 'building', id: b.id, label: `${b.name} (${b.cost})`, ruleObj: b }));
const choices = [...specialChoices, ...unitChoices, ...bldChoices];
const colW = 330;
const rowH = 36;
const perCol = 14; // leaves room below for the garrison strip
choices.slice(0, perCol * 2).forEach((ch, i) => {
const colX = rx + Math.floor(i / perCol) * (colW + 20);
const rowY = top + 170 + (i % perCol) * rowH;
const active = city.build.type === ch.type && city.build.id === ch.id;
const rect = scene.add.rectangle(colX + colW / 2, rowY, colW, rowH - 4,
active ? 0x3a3222 : 0x181510)
.setStrokeStyle(1, active ? COLORS.gold : COLORS.muted, active ? 1 : 0.5);
const rowColor = ch.type === 'unit' ? '#cfe3ff' : (ch.type === 'building' ? '#ffe9bd' : '#c9f2c0');
const txt = scene.add.text(colX + 10, rowY, ch.label, {
fontFamily: FONT, fontSize: '16px', color: rowColor,
}).setOrigin(0, 0.5);
rect.setInteractive({ useHandCursor: true });
rect.on('pointerdown', () => {
Logic.setBuild(rules, state, city, ch.type, ch.id);
redraw();
});
tooltip.attachTo(rect, () => {
if (ch.type === 'unit') return describeUnitTooltip(rules, ch.ruleObj);
if (ch.type === 'building') return describeBuildingTooltip(rules, ch.ruleObj);
return describeSpecialBuildTooltip(ch.type);
});
dynamic.add(rect);
dynamic.add(txt);
});
dynamic.add(routesTxt);
// Garrison — units fortified here are hidden on the map (see
// CivilizationMapView); clicking one un-fortifies it and closes the city.
// Lives under the left column now that the right half belongs to the build
// picker and the city map.
const garrison = Logic.unitsAt(state, city.x, city.y).filter((u) => u.fortified);
if (garrison.length) {
const gTop = top + 170 + perCol * rowH + 20;
dynamic.add(scene.add.text(rx, gTop, 'Garrison (click to unfortify)', {
// Measured off the routes line rather than a fixed y: a city with three
// trade routes wraps that text onto extra lines.
const gTop = Math.max(top + 700, routesTxt.y + routesTxt.height + 20);
dynamic.add(scene.add.text(left, gTop, 'Garrison (click to unfortify)', {
fontFamily: FONT, fontSize: '16px', color: COLORS.mutedHex,
}));
const boxW = 150;
@ -196,7 +166,7 @@ export function openCityScreen(scene, rules, state, city, onClose) {
garrison.forEach((u, i) => {
const col = i % perRow;
const row = (i / perRow) | 0;
const bx = rx + col * (boxW + gap) + boxW / 2;
const bx = left + col * (boxW + gap) + boxW / 2;
const by = gTop + 30 + row * (boxH + gap) + boxH / 2;
const rect = scene.add.rectangle(bx, by, boxW, boxH, 0x181510)
.setStrokeStyle(2, COLORS.gold, 1);
@ -209,6 +179,134 @@ export function openCityScreen(scene, rules, state, city, onClose) {
dynamic.add(label);
});
}
// --- Right column, upper half: what this city is building, and the full
// scrollable list of everything it could build instead.
const rx = px + 40;
const rW = W / 2 - 80;
const isSpecialBuild = city.build.type === 'gold' || city.build.type === 'food';
if (isSpecialBuild) {
const spec = Logic.SPECIAL_BUILDS[city.build.type];
const gain = Math.floor((city.shieldBox + y.shield) / spec.den) * spec.num;
const unitLabel = city.build.type === 'gold' ? 'gold' : 'food';
dynamic.add(scene.add.text(rx, top + 58,
`Producing: ${spec.name} +${gain} ${unitLabel}/turn`, {
fontFamily: FONT, fontSize: '22px', color: COLORS.textHex,
}));
} else {
const cost = Logic.buildCost(rules, city);
const cur = city.build.type === 'unit' ? rules.units[city.build.id] : rules.buildings[city.build.id];
const shownShields = Math.min(city.shieldBox, cost);
const turns = y.shield > 0 ? Math.max(0, Math.ceil((cost - shownShields) / y.shield)) : '∞';
dynamic.add(scene.add.text(rx, top + 58,
`Building: ${cur.name} ${shownShields}/${cost} shields (${turns} turns)`, {
fontFamily: FONT, fontSize: '22px', color: COLORS.textHex,
}));
const buyPrice = Logic.buyCost(rules, city);
const buyBtn = new Button(scene, rx + rW - 100, top + 104, `BUY (${buyPrice}g)`, () => {
if (Logic.buyBuild(rules, state, city)) redraw();
}, { width: 190, height: 46, fontSize: 18, variant: civ.gold >= buyPrice && !city.boughtThisTurn ? 'solid' : 'ghost' });
dynamic.add(buyBtn);
}
dynamic.add(scene.add.text(rx, top + 96, `Treasury: ${civ.gold} gold`, {
fontFamily: FONT, fontSize: '19px', color: COLORS.goldHex,
}));
dynamic.add(scene.add.text(rx, top + 134, 'WHAT TO BUILD', {
fontFamily: FONT, fontSize: '19px', color: COLORS.accentHex,
}));
// Coinage/Public Works first, then units, then buildings — the whole list,
// scrolled rather than truncated (a fully-teched civ has far more options
// than fit, and anything cut off was simply unbuildable through this UI).
const choices = [
...Object.entries(Logic.SPECIAL_BUILDS).map(([type, spec]) => ({ type, id: spec.id, name: spec.name })),
...Logic.availableUnits(rules, state, civ, city).map((u) => ({ type: 'unit', id: u.id, name: u.name, ruleObj: u })),
...Logic.availableBuildings(rules, state, civ, city).map((b) => ({ type: 'building', id: b.id, name: b.name, ruleObj: b })),
];
const listX = rx;
const listY = top + 160;
const listH = 268;
const contentH = choices.length * ROW_H + 12;
const listBg = scene.add.rectangle(listX + rW / 2, listY + listH / 2, rW, listH, 0x0a0d12)
.setStrokeStyle(2, COLORS.muted, 0.7);
dynamic.add(listBg);
const listLayer = scene.add.container(0, 0);
dynamic.add(listLayer);
listMask?.destroy();
listMask = scene.make.graphics({ add: false });
listMask.fillStyle(0xffffff);
listMask.fillRect(listX, listY, rW, listH);
listLayer.setMask(listMask.createGeometryMask());
const rows = [];
choices.forEach((ch, i) => {
const rowY = listY + 6 + i * ROW_H + ROW_H / 2;
const active = city.build.type === ch.type && city.build.id === ch.id;
const rect = scene.add.rectangle(listX + rW / 2, rowY, rW - 22, ROW_H - 4,
active ? 0x3a3222 : 0x181510)
.setStrokeStyle(1, active ? COLORS.gold : COLORS.muted, active ? 1 : 0.5);
const rowColor = ch.type === 'unit' ? '#cfe3ff' : (ch.type === 'building' ? '#ffe9bd' : '#c9f2c0');
const name = scene.add.text(listX + 22, rowY, ch.name, {
fontFamily: FONT, fontSize: '17px', color: rowColor,
}).setOrigin(0, 0.5);
const note = scene.add.text(listX + rW - 22, rowY, buildRowNote(rules, city, y, ch), {
fontFamily: FONT, fontSize: '15px', color: COLORS.mutedHex,
}).setOrigin(1, 0.5);
rect.setInteractive({ useHandCursor: true });
rect.on('pointerdown', () => {
Logic.setBuild(rules, state, city, ch.type, ch.id);
redraw();
});
tooltip.attachTo(rect, () => {
if (ch.type === 'unit') return describeUnitTooltip(rules, ch.ruleObj);
if (ch.type === 'building') return describeBuildingTooltip(rules, ch.ruleObj);
return describeSpecialBuildTooltip(ch.type);
});
listLayer.add([rect, name, note]);
rows.push({ rect, rowY });
});
// Scrollbar, and the wheel handler scoped to this window rather than the
// whole scene (see the diplomacy chat panel in CivilizationScreens.js).
const maxScroll = Math.max(0, contentH - listH);
let scrollY = 0;
const thumbH = maxScroll ? Math.max(30, listH * (listH / contentH)) : 0;
const thumb = maxScroll
? scene.add.rectangle(listX + rW - 8, listY, 6, thumbH, COLORS.accent, 0.8).setOrigin(0.5, 0)
: null;
if (thumb) dynamic.add(thumb);
// The geometry mask clips pixels, not input: a row scrolled out of the
// window would still swallow hovers over the city map below it, so input
// is toggled per row to match what is actually visible.
const syncRows = () => {
listLayer.y = -scrollY;
for (const r of rows) {
const screenY = r.rowY - scrollY;
r.rect.input.enabled = screenY >= listY && screenY <= listY + listH;
}
if (thumb) thumb.y = listY + (listH - thumbH) * (scrollY / maxScroll);
};
listBg.setInteractive();
listBg.on('wheel', (pointer, dx, dy) => {
scrollY = Phaser.Math.Clamp(scrollY + dy * 0.5, 0, maxScroll);
// A row disabled mid-hover never fires pointerout, so its tooltip would
// hang around over the list.
tooltip.hide();
syncRows();
});
syncRows();
// --- Right column, lower half: the land this city works. The fat cross is
// MAP_W x MAP_H (512x288) of tile art at 1:1 scale — it fits this column
// without resampling, which is why the tiles stay as crisp as on the map.
const mapCx = rx + rW / 2;
const mapCy = top + 630;
dynamic.add(scene.add.text(mapCx, mapCy - MAP_H / 2 - 42, 'THE CITY AND ITS LAND', {
fontFamily: FONT, fontSize: '19px', color: COLORS.accentHex,
}).setOrigin(0.5, 0));
drawCityMap(scene, rules, state, city, dynamic, tooltip, mapCx, mapCy);
}
redraw();

View File

@ -257,7 +257,14 @@ export function isCoastal(rules, state, city) {
// ---------------------------------------------------------------------------
// Tile yields
export function tileYield(rules, state, civIdx, city, x, y) {
// `notes`, when an array is passed, collects the provenance of the yields —
// one { label, food, shield, trade } entry per contributing rule, each holding
// the delta it actually made. That way a multiplier (farmland) or a penalty
// (despotism) is described as accurately as a flat bonus, and the entries
// always sum to the returned totals — the city screen's tile tooltip reads
// them straight out (see describeCityTileTooltip) instead of re-deriving the
// rules and drifting from them. verifyCivilization asserts the sum.
export function tileYield(rules, state, civIdx, city, x, y, notes = null) {
const { world } = state;
const i = tileIndex(world, x, y);
const terr = rules.terrainList[world.terrain[i]];
@ -265,32 +272,71 @@ export function tileYield(rules, state, civIdx, city, x, y) {
let food = special ? special.food : terr.food;
let shield = special ? special.shield : terr.shield;
let trade = special ? special.trade : terr.trade;
let pf = food;
let ps = shield;
let pt = trade;
if (notes) notes.push({ label: special ? special.name : terr.name, food, shield, trade });
const note = (label) => {
if (!notes || (food === pf && shield === ps && trade === pt)) return;
notes.push({ label, food: food - pf, shield: shield - ps, trade: trade - pt });
pf = food; ps = shield; pt = trade;
};
if (!special && terr.id === 'grassland' && shieldGrassAt(x, y)) shield += 1;
note('Shield grassland');
const imp = world.improvements[i];
if ((imp & IMP.IRRIGATION) && terr.irrigate) food += terr.irrigate;
note('Irrigation');
if ((imp & IMP.MINE) && terr.mine) shield += terr.mine;
note('Mine');
if ((imp & IMP.ROAD) && !terr.water && terr.move === 1) trade += 1;
note('Road');
if ((imp & IMP.RAILROAD) && shield >= 1) shield += 1;
note('Railroad');
if ((imp & IMP.FARMLAND) && city && city.buildings.supermarket) {
food = Math.floor(food * 1.5);
}
note('Farmland (Supermarket)');
if (city) {
if (terr.water && city.buildings.harbor) food += 1;
note('Harbor');
if (terr.water && city.buildings.offshoreplatform) shield += 1;
note('Offshore Platform');
}
const civ = state.civs[civIdx];
const gov = rules.governments[civ.government];
if (gov.tradeBonus && trade >= 1) trade += gov.tradeBonus;
note(`${gov.name} trade bonus`);
if (gov.despotPenalty) {
if (food >= 3) food -= 1;
if (shield >= 3) shield -= 1;
if (trade >= 3) trade -= 1;
}
note(`${gov.name} penalty`);
return { food, shield, trade };
}
// How a tile inside a city's fat cross relates to that city. The city screen's
// map uses it to decide borders and dimming, and its tooltip to explain them,
// so both agree by construction. 'taken'/'city' are the two cases
// autoAssignTiles silently skips below — the reasons a city can look poorer
// than its land suggests.
export function cityTileStatus(state, city, x, y) {
if (!inBounds(state.world, x, y)) return { kind: 'offmap' };
if (x === city.x && y === city.y) return { kind: 'centre' };
const here = cityAt(state, x, y);
if (here) return { kind: 'city', other: here };
const idx = tileIndex(state.world, x, y);
if (city.worked.includes(idx)) return { kind: 'worked' };
for (const other of state.cities) {
if (other.id !== city.id && other.worked.includes(idx)) return { kind: 'taken', other };
}
return { kind: 'idle' };
}
const EMPHASIS_WEIGHTS = {
balanced: { food: 3, shield: 2, trade: 1 },
food: { food: 6, shield: 1, trade: 1 },
@ -323,13 +369,31 @@ export function autoAssignTiles(rules, state, city) {
city.worked = options.slice(0, city.size).map((o) => o.idx);
}
// The city's own tile as the city actually counts it: the centre always makes
// at least 1 shield and 1 trade (the market-economy floor — without it a city
// founded on the wrong terrain researches nothing forever). Shared with the
// city screen so the map shows the same number the yields panel does.
export function cityCentreYield(rules, state, city, notes = null) {
const raw = tileYield(rules, state, city.civ, city, city.x, city.y, notes);
const out = { food: raw.food, shield: Math.max(1, raw.shield), trade: Math.max(1, raw.trade) };
if (notes && (out.shield !== raw.shield || out.trade !== raw.trade)) {
notes.push({
label: 'City centre minimum',
food: 0,
shield: out.shield - raw.shield,
trade: out.trade - raw.trade,
});
}
return out;
}
export function cityYields(rules, state, city) {
const civ = state.civs[city.civ];
const gov = rules.governments[civ.government];
const centre = tileYield(rules, state, city.civ, city, city.x, city.y);
const centre = cityCentreYield(rules, state, city);
let food = centre.food;
let shield = Math.max(1, centre.shield); // city tile always makes 1 shield...
let trade = Math.max(1, centre.trade); // ...and 1 trade (the market economy floor)
let shield = centre.shield;
let trade = centre.trade;
for (const idx of city.worked) {
const x = idx % state.world.cols;
const y = (idx / state.world.cols) | 0;

View File

@ -35,6 +35,71 @@ const SEL_RING_W = SEL_RING_H * (TILE_W / TILE_H);
const ZOOMS = [0.5, 0.75, 1.0, 1.5, 2.0];
// Draws one terrain tile — filled diamond plus its per-terrain glyphs — into
// `g` at the graphics object's own origin, with the diamond's centre at
// (0, TILE_H / 2). Neither clears nor positions `g`, so callers own both:
// this view stamps it into a chunk RenderTexture, while the city screen's
// fat-cross map (CivilizationCityMap.js) places a Graphics per tile. This is
// the fallback path used when data/civilization-artwork.json has no terrain
// sheet painted; keeping it in one place stops the two views from drifting.
export function paintTerrainDiamond(g, terr, shieldGrass = false) {
const base = Phaser.Display.Color.HexStringToColor(terr.color).color;
const cy = TILE_H / 2;
g.fillStyle(base, 1);
g.beginPath();
g.moveTo(0, cy - TILE_H / 2);
g.lineTo(TILE_W / 2, cy);
g.lineTo(0, cy + TILE_H / 2);
g.lineTo(-TILE_W / 2, cy);
g.closePath();
g.fillPath();
g.lineStyle(1, 0x000000, 0.18);
g.strokePath();
// Simple per-terrain glyphs.
const darker = Phaser.Display.Color.ValueToColor(base).darken(25).color;
const lighter = Phaser.Display.Color.ValueToColor(base).lighten(20).color;
if (terr.id === 'forest' || terr.id === 'jungle') {
g.fillStyle(darker, 1);
for (const [tx, ty] of [[-24, 0], [0, -8], [22, 2]]) {
g.fillTriangle(tx - 9, cy + ty + 8, tx + 9, cy + ty + 8, tx, cy + ty - 12);
}
} else if (terr.id === 'mountains') {
g.fillStyle(darker, 1);
g.fillTriangle(-28, cy + 12, 4, cy + 12, -12, cy - 22);
g.fillTriangle(-4, cy + 14, 30, cy + 14, 13, cy - 16);
g.fillStyle(0xffffff, 0.9);
g.fillTriangle(-16, cy - 14, -8, cy - 14, -12, cy - 22);
} else if (terr.id === 'hills') {
g.fillStyle(darker, 1);
g.fillEllipse(-16, cy + 4, 34, 16);
g.fillEllipse(14, cy + 8, 38, 18);
} else if (terr.id === 'ocean') {
g.lineStyle(2, lighter, 0.7);
for (const [tx, ty] of [[-24, -6], [8, 2], [-8, 10]]) {
g.beginPath();
g.moveTo(tx, cy + ty);
g.lineTo(tx + 14, cy + ty);
g.strokePath();
}
} else if (terr.id === 'swamp') {
g.lineStyle(2, darker, 0.8);
for (const [tx, ty] of [[-20, 4], [4, -4], [18, 8]]) {
g.beginPath();
g.moveTo(tx, cy + ty);
g.lineTo(tx, cy + ty - 8);
g.strokePath();
}
} else if (terr.id === 'desert') {
g.fillStyle(darker, 0.6);
g.fillEllipse(-14, cy + 4, 10, 4);
g.fillEllipse(12, cy - 4, 12, 4);
} else if (terr.id === 'grassland' && shieldGrass) {
g.fillStyle(lighter, 1);
g.fillCircle(18, cy - 6, 5);
}
}
export class CivilizationMapView {
constructor(scene, rules, state, opponentsData, callbacks = {}) {
this.scene = scene;
@ -281,64 +346,8 @@ export class CivilizationMapView {
paintProceduralTile(c, r, terr, x, y) {
const g = this.stamp;
g.clear();
const base = Phaser.Display.Color.HexStringToColor(terr.color).color;
const cy = TILE_H / 2; // stamp-local diamond centre
g.fillStyle(base, 1);
g.beginPath();
g.moveTo(0, cy - TILE_H / 2);
g.lineTo(TILE_W / 2, cy);
g.lineTo(0, cy + TILE_H / 2);
g.lineTo(-TILE_W / 2, cy);
g.closePath();
g.fillPath();
g.lineStyle(1, 0x000000, 0.18);
g.strokePath();
// Simple per-terrain glyphs.
const darker = Phaser.Display.Color.ValueToColor(base).darken(25).color;
const lighter = Phaser.Display.Color.ValueToColor(base).lighten(20).color;
if (terr.id === 'forest' || terr.id === 'jungle') {
g.fillStyle(darker, 1);
for (const [tx, ty] of [[-24, 0], [0, -8], [22, 2]]) {
g.fillTriangle(tx - 9, cy + ty + 8, tx + 9, cy + ty + 8, tx, cy + ty - 12);
}
} else if (terr.id === 'mountains') {
g.fillStyle(darker, 1);
g.fillTriangle(-28, cy + 12, 4, cy + 12, -12, cy - 22);
g.fillTriangle(-4, cy + 14, 30, cy + 14, 13, cy - 16);
g.fillStyle(0xffffff, 0.9);
g.fillTriangle(-16, cy - 14, -8, cy - 14, -12, cy - 22);
} else if (terr.id === 'hills') {
g.fillStyle(darker, 1);
g.fillEllipse(-16, cy + 4, 34, 16);
g.fillEllipse(14, cy + 8, 38, 18);
} else if (terr.id === 'ocean') {
g.lineStyle(2, lighter, 0.7);
for (const [tx, ty] of [[-24, -6], [8, 2], [-8, 10]]) {
g.beginPath();
g.moveTo(tx, cy + ty);
g.lineTo(tx + 14, cy + ty);
g.strokePath();
}
} else if (terr.id === 'swamp') {
g.lineStyle(2, darker, 0.8);
for (const [tx, ty] of [[-20, 4], [4, -4], [18, 8]]) {
g.beginPath();
g.moveTo(tx, cy + ty);
g.lineTo(tx, cy + ty - 8);
g.strokePath();
}
} else if (terr.id === 'desert') {
g.fillStyle(darker, 0.6);
g.fillEllipse(-14, cy + 4, 10, 4);
g.fillEllipse(12, cy - 4, 12, 4);
} else if (terr.id === 'grassland') {
const idx = tileIndex(this.state.world, c, r);
if (shieldGrassAt(c, r) && this.state.world.special[idx] < 0) {
g.fillStyle(lighter, 1);
g.fillCircle(18, cy - 6, 5);
}
}
const idx = tileIndex(this.state.world, c, r);
paintTerrainDiamond(g, terr, shieldGrassAt(c, r) && this.state.world.special[idx] < 0);
this.drawStampAt(g, x, y);
}
@ -1065,7 +1074,7 @@ export class CivilizationMapView {
}
}
function specialColor(id) {
export function specialColor(id) {
const map = {
buffalo: 0x8a5a2a, wheat: 0xe8c84a, pheasant: 0xc06030, silk: 0xe8e8f0,
coal: 0x30302e, wine: 0x7a2050, gold: 0xffd700, iron: 0x8a8a92,

View File

@ -1,6 +1,7 @@
import { COLORS } from '../../config.js';
import {
cityById, cityAt, tileIndex, defenderStrength, unitsOnBoat, IMP, SPECIAL_BUILDS,
tileYield, cityTileStatus, cityCentreYield,
} from './CivilizationLogic.js';
// Flavor/ability text for unit flags — data/civilization-rules.json has no
@ -238,6 +239,77 @@ export function describeCityTooltip(rules, civ, opponentsData, city) {
};
}
// Hover tooltip for one tile of a city's fat cross in the city screen. The
// yield breakdown is not re-derived here — tileYield fills a `notes` array as
// it applies each rule, so this can only ever say what the engine actually
// did. IMP bits that changed nothing still get a line, since "why does my road
// give me nothing?" is exactly the question this tooltip exists to answer.
const IMP_BITS = [
['irrigation', IMP.IRRIGATION], ['farmland', IMP.FARMLAND], ['mine', IMP.MINE],
['road', IMP.ROAD], ['railroad', IMP.RAILROAD], ['fortress', IMP.FORTRESS],
];
function yieldDelta({ food, shield, trade }) {
const parts = [];
const add = (n, label) => { if (n) parts.push(`${n > 0 ? '+' : ''}${Math.abs(n)} ${label}`); };
add(food, 'food');
add(shield, 'shields');
add(trade, 'trade');
return parts.length ? parts.join(', ') : 'nothing';
}
export function describeCityTileTooltip(rules, state, city, x, y) {
const status = cityTileStatus(state, city, x, y);
if (status.kind === 'offmap') {
return {
title: 'Beyond the map',
lines: [{ text: 'The world ends here — this tile can never be worked.', color: COLORS.mutedHex }],
};
}
const { world } = state;
const i = tileIndex(world, x, y);
const terr = rules.terrainList[world.terrain[i]];
const special = world.special[i] >= 0 ? rules.specialList[world.special[i]] : null;
const notes = [];
const out = status.kind === 'centre'
? cityCentreYield(rules, state, city, notes)
: tileYield(rules, state, city.civ, city, x, y, notes);
const lines = [{
text: `Food ${out.food} · Shields ${out.shield} · Trade ${out.trade}`,
color: COLORS.goldHex,
}];
if (status.kind === 'centre') {
lines.push({ text: 'The city centre is always worked, on top of the tiles its citizens work.' });
}
for (const n of notes) lines.push({ text: `${n.label}: ${yieldDelta(n)}` });
// Improvements that produced nothing here (a road on hills, a mine on
// grassland, a fortress) get their blurb so the absence is explained.
// Prefix match, not equality: tileYield labels the farmland step
// "Farmland (Supermarket)" to name what made it work.
const labels = notes.map((n) => n.label.toLowerCase());
for (const [id, bit] of IMP_BITS) {
if (!(world.improvements[i] & bit)) continue;
const name = rules.improvements[id]?.name ?? id;
if (labels.some((l) => l.startsWith(name.toLowerCase()))) continue;
const text = IMPROVEMENT_TEXT[id];
lines.push({ text: `${name} — no effect here.${text ? ` ${text}` : ''}`, color: COLORS.mutedHex });
}
const civ = state.civs[city.civ];
const STATUS = {
centre: { text: `The heart of ${city.name}`, color: civ.color },
worked: { text: `Worked by ${city.name}`, color: civ.color },
idle: { text: 'Not worked — this city has no citizen to spare for it.', color: COLORS.mutedHex },
taken: { text: `Worked by ${status.other?.name} — one city may work a tile.`, color: COLORS.mutedHex },
city: { text: `${status.other?.name} stands here.`, color: COLORS.mutedHex },
};
lines.push(STATUS[status.kind] ?? STATUS.idle);
return { title: special ? `${special.name} (${terr.name})` : terr.name, lines };
}
// Hover tooltip for a tech-tree entry: prerequisites (only worth showing
// once — omitted for techs the civ already knows) followed by everything
// the tech unlocks, grouped by kind. `civ` is used only to decide whether

View File

@ -23,6 +23,9 @@ import * as Logic from '../src/games/civilization/CivilizationLogic.js';
import * as AI from '../src/games/civilization/CivilizationAI.js';
import * as Diplo from '../src/games/civilization/CivilizationDiplomacy.js';
import * as Chat from '../src/games/civilization/CivilizationChat.js';
// Pure data module (no Phaser) even though its consumers are UI — the city
// screen's tooltips are checkable headlessly, and worth checking.
import * as Tooltips from '../src/games/civilization/CivilizationTooltips.js';
const QUICK = process.argv.includes('--quick');
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
@ -752,6 +755,157 @@ if (RULES) {
check('idle AI city settles on Coinage or Public Works',
city.build.type === 'gold' || city.build.type === 'food');
}
// --- City-screen data layer (the fat-cross map + its tile tooltips).
//
// The drawing is Phaser and can't run here, but everything it reads can.
// The load-bearing check is the first one: the tooltip explains a tile's
// yields from tileYield's own `notes`, so if those ever stop summing to the
// returned totals the tooltip starts lying.
{
const terrains = ['grassland', 'plains', 'hills', 'mountains', 'forest', 'desert', 'swamp', 'ocean'];
const impSets = [
0, Logic.IMP.ROAD, Logic.IMP.IRRIGATION, Logic.IMP.MINE, Logic.IMP.ROAD | Logic.IMP.RAILROAD,
Logic.IMP.IRRIGATION | Logic.IMP.FARMLAND,
Logic.IMP.IRRIGATION | Logic.IMP.FARMLAND | Logic.IMP.ROAD | Logic.IMP.RAILROAD,
Logic.IMP.MINE | Logic.IMP.ROAD | Logic.IMP.FORTRESS,
];
let sumOk = true;
let labelsOk = true;
let why = '';
for (const terrId of terrains) {
for (const gov of ['despotism', 'republic', 'democracy']) {
for (const imps of impSets) {
for (const withBuildings of [false, true]) {
// Grassland everywhere so the city can be founded, with only the
// measured tile switched to the terrain under test — that keeps
// the city tile's implicit road out of the measurement too.
const st = makeFlatState();
st.civs[0].government = gov;
const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null));
if (withBuildings) {
city.buildings.supermarket = true;
city.buildings.harbor = true;
city.buildings.offshoreplatform = true;
}
const tx = 6;
const ty = 5;
const i = Logic.tileIndex(st.world, tx, ty);
st.world.terrain[i] = T(terrId);
st.world.improvements[i] = imps;
if (withBuildings && RULES.specialList.length) st.world.special[i] = 0;
const notes = [];
const out = Logic.tileYield(RULES, st, 0, city, tx, ty, notes);
const sum = notes.reduce((a, n) => ({
food: a.food + n.food, shield: a.shield + n.shield, trade: a.trade + n.trade,
}), { food: 0, shield: 0, trade: 0 });
if (sum.food !== out.food || sum.shield !== out.shield || sum.trade !== out.trade) {
sumOk = false;
why = `${terrId}/${gov}/imp${imps}: notes ${JSON.stringify(sum)} vs ${JSON.stringify(out)}`;
}
if (notes.some((n) => !n.label || (!n.food && !n.shield && !n.trade && notes.indexOf(n) > 0))) {
labelsOk = false;
why = why || `${terrId}/${gov}: empty note`;
}
}
}
}
}
check('tile yield notes sum to the yields they explain', sumOk, why);
check('tile yield notes are labelled and never empty', labelsOk, why);
}
// The city centre's 1-shield/1-trade floor is applied in one place, so the
// map, the tooltip and the yields panel can't disagree about it.
{
// Swamp yields 0 shields and 0 trade, and at move 2 the city tile's
// implicit road adds no trade either — so both floors have to do work.
const st = makeFlatState({ terrain: 'swamp' });
const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null));
const raw = Logic.tileYield(RULES, st, 0, city, 5, 5);
const centre = Logic.cityCentreYield(RULES, st, city);
check('city centre never yields under 1 shield / 1 trade',
centre.shield >= 1 && centre.trade >= 1, `${JSON.stringify(centre)}`);
const notes = [];
Logic.cityCentreYield(RULES, st, city, notes);
const sum = notes.reduce((a, n) => ({
shield: a.shield + n.shield, trade: a.trade + n.trade,
}), { shield: 0, trade: 0 });
check('centre floor is itself explained in the breakdown',
sum.shield === centre.shield && sum.trade === centre.trade,
`raw ${JSON.stringify(raw)} centre ${JSON.stringify(centre)} notes ${JSON.stringify(sum)}`);
}
// cityTileStatus drives both the map's borders/dimming and the tooltip's
// closing line — every case a fat cross can actually contain.
{
const st = makeFlatState({ cols: 16, rows: 16 });
const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null));
const neighbour = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 8, 5, null));
city.size = 3;
Logic.autoAssignTiles(RULES, st, city);
Logic.autoAssignTiles(RULES, st, neighbour);
const kindAt = (x, yy) => Logic.cityTileStatus(st, city, x, yy).kind;
check('centre tile reads as the city itself', kindAt(5, 5) === 'centre');
check('a tile holding another city is flagged', kindAt(8, 5) === 'city');
const workedIdx = city.worked[0];
check('worked tiles read as worked',
kindAt(workedIdx % st.world.cols, (workedIdx / st.world.cols) | 0) === 'worked');
const takenIdx = neighbour.worked.find((idx) => {
const x = idx % st.world.cols;
const yy = (idx / st.world.cols) | 0;
return Math.max(Math.abs(x - city.x), Math.abs(yy - city.y)) <= 2;
});
if (takenIdx !== undefined) {
check('a neighbour\'s tile inside our radius is flagged as taken',
kindAt(takenIdx % st.world.cols, (takenIdx / st.world.cols) | 0) === 'taken');
}
// Found at the map edge so part of the fat cross falls off the world.
const edge = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 0, 0, null));
check('tiles past the map edge are flagged',
Logic.cityTileStatus(st, edge, -2, 0).kind === 'offmap');
}
// The tooltip itself: title, the yield line, and the right closing status.
{
const st = makeFlatState({ cols: 16, rows: 16 });
const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null));
city.size = 2;
Logic.autoAssignTiles(RULES, st, city);
const tip = Tooltips.describeCityTileTooltip(RULES, st, city, 6, 5);
const out = Logic.tileYield(RULES, st, 0, city, 6, 5);
check('tile tooltip is titled with its terrain', tip.title === RULES.terrains.grassland.name);
check('tile tooltip leads with the yields it explains',
tip.lines[0].text === `Food ${out.food} · Shields ${out.shield} · Trade ${out.trade}`,
tip.lines[0].text);
check('tile tooltip closes with a status line', tip.lines.length >= 2
&& /Worked|Not worked|heart of/.test(tip.lines[tip.lines.length - 1].text),
tip.lines[tip.lines.length - 1].text);
check('centre tooltip names the city', /heart of/.test(
Tooltips.describeCityTileTooltip(RULES, st, city, 5, 5).lines.at(-1).text,
));
const off = Tooltips.describeCityTileTooltip(RULES, st, city, -1, 5);
check('off-map tooltip degrades gracefully', off.title === 'Beyond the map' && off.lines.length > 0);
// Every template must resolve — a stray {token} would ship to the player.
let clean = true;
for (const [dx, dy] of Logic.CITY_RADIUS) {
const t = Tooltips.describeCityTileTooltip(RULES, st, city, city.x + dx, city.y + dy);
if (/\{|undefined|NaN/.test(t.title + t.lines.map((l) => l.text).join(''))) clean = false;
}
check('no tile tooltip in the fat cross renders a hole', clean);
}
// The build list used to be sliced to 28 rows; a developed city has more
// options than that, so the scroll window is load-bearing, not decoration.
{
const st = makeFlatState();
const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null));
for (const t of RULES.techList) if (!t.repeatable) st.civs[0].known[t.id] = true;
const total = 2 + Logic.availableUnits(RULES, st, st.civs[0], city).length
+ Logic.availableBuildings(RULES, st, st.civs[0], city).length;
check('a fully-teched city offers more builds than the old 28-row cap',
total > 28, `${total} options`);
}
}
// ---------------------------------------------------------------------------