diff --git a/assets/images/civilization/civilization-icons.png b/assets/images/civilization/civilization-icons.png new file mode 100644 index 0000000..29b1392 Binary files /dev/null and b/assets/images/civilization/civilization-icons.png differ diff --git a/assets/images/civilization/civilization-icons.psd b/assets/images/civilization/civilization-icons.psd new file mode 100644 index 0000000..1b12198 Binary files /dev/null and b/assets/images/civilization/civilization-icons.psd differ diff --git a/assets/images/civilization/civilization-improvements.png b/assets/images/civilization/civilization-improvements.png new file mode 100644 index 0000000..5de9605 Binary files /dev/null and b/assets/images/civilization/civilization-improvements.png differ diff --git a/assets/images/civilization/civilization-improvements.psd b/assets/images/civilization/civilization-improvements.psd new file mode 100644 index 0000000..9fe46c3 Binary files /dev/null and b/assets/images/civilization/civilization-improvements.psd differ diff --git a/data/civilization-artwork.json b/data/civilization-artwork.json index 93e0a0e..b401eae 100644 --- a/data/civilization-artwork.json +++ b/data/civilization-artwork.json @@ -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 }, diff --git a/src/games/civilization/CivilizationCityMap.js b/src/games/civilization/CivilizationCityMap.js new file mode 100644 index 0000000..074ed28 --- /dev/null +++ b/src/games/civilization/CivilizationCityMap.js @@ -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)); +} diff --git a/src/games/civilization/CivilizationCityScreen.js b/src/games/civilization/CivilizationCityScreen.js index a41ad99..2e4c754 100644 --- a/src/games/civilization/CivilizationCityScreen.js +++ b/src/games/civilization/CivilizationCityScreen.js @@ -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(); diff --git a/src/games/civilization/CivilizationLogic.js b/src/games/civilization/CivilizationLogic.js index c268f57..8d74eaa 100644 --- a/src/games/civilization/CivilizationLogic.js +++ b/src/games/civilization/CivilizationLogic.js @@ -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; diff --git a/src/games/civilization/CivilizationMapView.js b/src/games/civilization/CivilizationMapView.js index 96dfed8..50d0fc1 100644 --- a/src/games/civilization/CivilizationMapView.js +++ b/src/games/civilization/CivilizationMapView.js @@ -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, diff --git a/src/games/civilization/CivilizationTooltips.js b/src/games/civilization/CivilizationTooltips.js index 7da0e14..b0bd8bb 100644 --- a/src/games/civilization/CivilizationTooltips.js +++ b/src/games/civilization/CivilizationTooltips.js @@ -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 diff --git a/tools/verifyCivilization.js b/tools/verifyCivilization.js index 031495e..c561bc5 100644 --- a/tools/verifyCivilization.js +++ b/tools/verifyCivilization.js @@ -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`); + } } // ---------------------------------------------------------------------------