diff --git a/src/games/civilization/CivilizationCityScreen.js b/src/games/civilization/CivilizationCityScreen.js index 2e4c754..5fdec0e 100644 --- a/src/games/civilization/CivilizationCityScreen.js +++ b/src/games/civilization/CivilizationCityScreen.js @@ -10,12 +10,151 @@ 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 { + describeUnitTooltip, describeBuildingTooltip, describeSpecialBuildTooltip, describeYieldRow, + describeSupportedUnitTooltip, +} from './CivilizationTooltips.js'; +import { drawIconRun, hasIcons } from './CivilizationIcons.js'; import { drawCityMap, MAP_H } from './CivilizationCityMap.js'; const FONT = '"Julius Sans One"'; const ROW_H = 38; +// The five yield rows. Each is a label, every point produced drawn as an icon, +// then a gap and the surplus/loss repeated as its own signed group — so "makes +// 5 trade, 2 of it eaten by corruption" is one glance rather than a sentence. +// Deduction groups vanish when there's nothing to lose; food's surplus group +// always renders, because an empty space where the growth number belongs reads +// as a bug rather than as break-even. +// Five rows of this height starting at the y below have to land clear of the +// worker-emphasis buttons at top + 270, or the last row's hover zone clips them. +const YIELD_ROW_H = 27; +const YIELD_TOP = 104; +const TOTAL_CAP = 12; // beyond this a group collapses to "icon xN" +const DELTA_CAP = 8; +const GROUP_GAP = 26; +const LABEL_W = 96; + +function drawYields(scene, rules, state, city, yields, parent, tooltip, x, y0) { + // Every sheet in this game is optional (data/civilization-artwork.json), and + // this one was unpainted until recently — the text rows have to keep working. + if (!hasIcons(scene)) { + parent.add(scene.add.text(x, y0, [ + `Food ${yields.food} (need ${yields.foodNeed}) surplus ${yields.foodSurplus >= 0 ? '+' : ''}${yields.foodSurplus}`, + `Shields ${yields.shield}${yields.waste ? ` waste −${yields.waste}` : ''}${yields.supportShields ? ` (support −${yields.supportShields})` : ''}`, + `Trade ${yields.trade} corruption −${yields.corruption}${yields.routeTrade ? ` routes +${yields.routeTrade}` : ''}`, + `Gold +${yields.gold} · Science +${yields.science} · Upkeep −${yields.upkeep}`, + ].join('\n'), { + fontFamily: FONT, fontSize: '20px', color: COLORS.textHex, lineSpacing: 8, + })); + return; + } + + const rows = [ + { id: 'food', label: 'Food', frame: 'food', total: yields.food, delta: yields.foodSurplus, alwaysDelta: true }, + { id: 'shield', label: 'Shields', frame: 'shield', total: yields.grossShield, delta: -(yields.waste + yields.supportShields) }, + { id: 'trade', label: 'Trade', frame: 'trade', total: yields.trade, delta: -yields.corruption }, + { id: 'gold', label: 'Gold', frame: 'gold', total: yields.gold, delta: -(yields.upkeep + yields.supportGold) }, + { id: 'science', label: 'Science', frame: 'beaker', total: yields.science, delta: 0 }, + ]; + + rows.forEach((row, i) => { + const cy = y0 + i * YIELD_ROW_H + YIELD_ROW_H / 2; + parent.add(scene.add.text(x, cy, row.label, { + fontFamily: FONT, fontSize: '19px', color: COLORS.mutedHex, + }).setOrigin(0, 0.5)); + + let cursor = x + LABEL_W; + cursor += drawIconRun(scene, parent, cursor, cy, row.frame, row.total, { cap: TOTAL_CAP }); + + if (row.delta || row.alwaysDelta) { + cursor += GROUP_GAP; + const positive = row.delta >= 0; + const color = row.delta === 0 ? COLORS.mutedHex : (positive ? '#6fd15f' : COLORS.dangerHex); + if (row.delta === 0) { + const zero = scene.add.text(cursor, cy, '0', { + fontFamily: FONT, fontSize: '19px', color, + }).setOrigin(0, 0.5); + parent.add(zero); + cursor += zero.width; + } else { + cursor += drawIconRun(scene, parent, cursor, cy, row.frame, Math.abs(row.delta), { + cap: DELTA_CAP, sign: positive ? '+' : '−', color, alpha: positive ? 1 : 0.55, + }); + } + } + + // Icons only means the numbers went away — hovering the whole row brings + // them back, with more detail than the old text lines carried. + const zone = scene.add.zone(x, cy - YIELD_ROW_H / 2, Math.max(cursor - x, LABEL_W + 60), YIELD_ROW_H) + .setOrigin(0, 0).setInteractive(); + tooltip.attachTo(zone, () => describeYieldRow(rules, state, city, yields, row.id)); + parent.add(zone); + }); +} + +// Supported units as a wrapped strip of sprite chips instead of a comma-joined +// list of names. Each chip is one unit instance, not a stacked count, because +// two Warriors homed here are genuinely different things — one may be a +// fortified veteran in the city and the other a wounded conscript six tiles +// away, and the tooltip says so. The border tells you at a glance which units +// this city is actually paying for. +const CHIP_W = 40; +const CHIP_H = 50; +const CHIP_STEP_X = 46; +const CHIP_STEP_Y = 56; +const CHIP_ROWS = 2; // 532..644 leaves the trade-routes line at 660 alone + +function drawSupportedUnits(scene, rules, state, city, support, parent, tooltip, x, y0) { + if (!support.entries.length) { + parent.add(scene.add.text(x, y0 + 6, '(none)', { + fontFamily: FONT, fontSize: '17px', color: COLORS.mutedHex, + })); + return; + } + const perRow = Math.floor(640 / CHIP_STEP_X); + const slots = perRow * CHIP_ROWS; + const overflow = Math.max(0, support.entries.length - slots); + // One slot goes to the "+N" marker when the strip can't hold everything. + const shown = overflow ? support.entries.slice(0, slots - 1) : support.entries; + const spriteMode = scene.textures.exists('civilization-units'); + + shown.forEach((entry, i) => { + const cx = x + (i % perRow) * CHIP_STEP_X + CHIP_W / 2; + const cy = y0 + Math.floor(i / perRow) * CHIP_STEP_Y + CHIP_H / 2; + const paid = entry.shield || entry.gold || entry.food; + const box = scene.add.rectangle(cx, cy, CHIP_W, CHIP_H, 0x181510) + .setStrokeStyle(2, paid ? COLORS.danger : COLORS.muted, paid ? 0.9 : 0.6); + parent.add(box); + if (spriteMode) { + parent.add(scene.add.image(cx, cy, 'civilization-units', entry.def.frame) + .setDisplaySize(CHIP_W - 8, CHIP_H - 4)); + } else { + parent.add(scene.add.text(cx, cy, entry.def.abbr, { + fontFamily: FONT, fontSize: '15px', color: COLORS.textHex, fontStyle: 'bold', + }).setOrigin(0.5)); + } + // Same gold dot the map uses for veterans (see CivilizationMapView). + if (entry.unit.vet) { + parent.add(scene.add.circle(cx + CHIP_W / 2 - 6, cy - CHIP_H / 2 + 6, 4, 0xd4a017) + .setStrokeStyle(1, 0x000000, 0.6)); + } + box.setInteractive(); + tooltip.attachTo(box, () => describeSupportedUnitTooltip(rules, state, city, entry, support.freeUnits)); + }); + + if (overflow) { + const i = slots - 1; + const cx = x + (i % perRow) * CHIP_STEP_X + CHIP_W / 2; + const cy = y0 + Math.floor(i / perRow) * CHIP_STEP_Y + CHIP_H / 2; + parent.add(scene.add.rectangle(cx, cy, CHIP_W, CHIP_H, 0x181510) + .setStrokeStyle(2, COLORS.muted, 0.6)); + parent.add(scene.add.text(cx, cy, `+${overflow + 1}`, { + fontFamily: FONT, fontSize: '16px', color: COLORS.mutedHex, + }).setOrigin(0.5)); + } +} + // 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). @@ -89,16 +228,10 @@ export function openCityScreen(scene, rules, state, city, onClose) { fontFamily: FONT, fontSize: '18px', color: COLORS.textHex, })); - // Yields breakdown. - const lines = [ - `Food ${y.food} (need ${y.foodNeed}) surplus ${y.foodSurplus >= 0 ? '+' : ''}${y.foodSurplus}`, - `Shields ${y.shield}${y.waste ? ` waste −${y.waste}` : ''}${y.supportShields ? ` (support −${y.supportShields})` : ''}`, - `Trade ${y.trade} corruption −${y.corruption}${y.routeTrade ? ` routes +${y.routeTrade}` : ''}`, - `Gold +${y.gold} · Science +${y.science} · Upkeep −${y.upkeep}`, - ]; - dynamic.add(scene.add.text(left, top + 112, lines.join('\n'), { - fontFamily: FONT, fontSize: '20px', color: COLORS.textHex, lineSpacing: 8, - })); + // Yields breakdown: a label, the whole production drawn as icons, then the + // surplus (or the loss) repeated as its own signed group. Hovering a row + // gives back the exact figures — see describeYieldRow. + drawYields(scene, rules, state, city, y, dynamic, tooltip, left, top + YIELD_TOP); // Emphasis toggle. dynamic.add(scene.add.text(left, top + 260, 'Worker emphasis:', { @@ -129,15 +262,16 @@ export function openCityScreen(scene, rules, state, city, onClose) { })); // Supported units + routes. - const supported = state.units.filter((u) => u.homeCity === city.id) - .map((u) => rules.units[u.type].name); - dynamic.add(scene.add.text(left, top + 500, `Supported units (${supported.length})`, { - fontFamily: FONT, fontSize: '21px', color: COLORS.accentHex, - })); - dynamic.add(scene.add.text(left, top + 535, supported.length ? supported.join(', ') : '(none)', { - fontFamily: FONT, fontSize: '17px', color: COLORS.textHex, - wordWrap: { width: 640 }, lineSpacing: 5, - })); + const support = Logic.citySupport(rules, state, city); + const upkeepBits = []; + if (support.supportShields) upkeepBits.push(`−${support.supportShields} shields`); + if (support.supportGold) upkeepBits.push(`−${support.supportGold} gold`); + if (support.settlerFood) upkeepBits.push(`−${support.settlerFood} food`); + dynamic.add(scene.add.text(left, top + 500, + `Supported units (${support.entries.length})${upkeepBits.length ? ` ${upkeepBits.join(' ')} per turn` : ''}`, { + fontFamily: FONT, fontSize: '21px', color: COLORS.accentHex, + })); + drawSupportedUnits(scene, rules, state, city, support, dynamic, tooltip, left, top + 532); const routes = city.routes.map((r) => { const other = Logic.cityById(state, r.cityId); return other ? `${other.name} (+${r.amount})` : `(lost city +${r.amount})`; diff --git a/src/games/civilization/CivilizationIcons.js b/src/games/civilization/CivilizationIcons.js new file mode 100644 index 0000000..7a45a53 --- /dev/null +++ b/src/games/civilization/CivilizationIcons.js @@ -0,0 +1,90 @@ +// Civilization — the shared HUD icon sheet (civilization-icons.png). +// +// One owner for the sheet so its frame numbers live in code rather than only in +// sprites.md. First consumer is the city screen's yields block; the HUD, the +// diplomacy mood dots and the spaceship screen are the obvious next ones. +// +// The sheet is optional like every other in data/civilization-artwork.json — +// callers must check hasIcons() and keep a text path for when it is absent. + +export const ICON_KEY = 'civilization-icons'; +export const ICON_FRAMES = 20; // 480x96 sheet at 48x48 = 10 columns x 2 rows + +// Frame map, pinned to the table in sprites.md section 6. Append-only: the +// government entries are also committed to in data/civilization-rules.json as +// governments[].frame, and verifyCivilization cross-checks the two. +export const ICON_FRAME = Object.freeze({ + food: 0, + shield: 1, + trade: 2, + gold: 3, + beaker: 4, + pop: 5, + 'ss-structural': 6, + 'ss-component': 7, + 'ss-module': 8, + vet: 9, + 'gov-despotism': 10, + 'gov-anarchy': 11, + 'gov-monarchy': 12, + 'gov-communism': 13, + 'gov-republic': 14, + 'gov-democracy': 15, + 'mood-happy': 16, + 'mood-idle': 17, + 'mood-upset': 18, +}); + +export function hasIcons(scene) { + return scene.textures.exists(ICON_KEY); +} + +// How to draw `count` of something: either that many icons, or — once the run +// would grow the row without bound — a single icon and a multiplier. Pure, so +// the boundary behaviour is checked headlessly in verifyCivilization. +export function iconRun(count, cap) { + const n = Math.max(0, Math.floor(count)); + if (n <= cap) return { drawn: n, multiplier: null }; + return { drawn: 1, multiplier: n }; +} + +// Draws one group of icons left to right starting at `x` (icons vertically +// centred on `y`), optionally prefixed by a sign glyph and followed by a "×N" +// when the count overflows the cap. Returns the width consumed so callers can +// place whatever comes next. +// +// `color` tints only the sign and multiplier TEXT, never the icons — these are +// painted, coloured sprites (a wheat sheaf, a coin) and tinting them red would +// just wreck the art. A group that represents something lost is dimmed with +// `alpha` instead, which reads as "this went away" while leaving the icon +// recognisable. +export function drawIconRun(scene, container, x, y, frameName, count, opts = {}) { + const { + size = 18, step = 20, cap = 12, sign = null, color = '#f2ead8', alpha = 1, + font = '"Julius Sans One"', + } = opts; + let cursor = x; + + if (sign) { + const glyph = scene.add.text(cursor, y, sign, { + fontFamily: font, fontSize: '19px', color, + }).setOrigin(0, 0.5); + container.add(glyph); + cursor += glyph.width + 5; + } + + const { drawn, multiplier } = iconRun(count, cap); + for (let i = 0; i < drawn; i += 1) { + container.add(scene.add.image(cursor + size / 2, y, ICON_KEY, ICON_FRAME[frameName]) + .setDisplaySize(size, size).setAlpha(alpha)); + cursor += step; + } + if (multiplier !== null) { + const mult = scene.add.text(cursor, y, `×${multiplier}`, { + fontFamily: font, fontSize: '17px', color, + }).setOrigin(0, 0.5); + container.add(mult); + cursor += mult.width + 4; + } + return cursor - x; +} diff --git a/src/games/civilization/CivilizationLogic.js b/src/games/civilization/CivilizationLogic.js index 8d74eaa..fb5a433 100644 --- a/src/games/civilization/CivilizationLogic.js +++ b/src/games/civilization/CivilizationLogic.js @@ -369,6 +369,41 @@ export function autoAssignTiles(rules, state, city) { city.worked = options.slice(0, city.size).map((o) => o.idx); } +// Per-unit upkeep for everything this city is the home of: which units the +// government's free allowance covers and which cost a shield (or a gold, under +// Democracy), plus the food settlers eat while out in the field. Free units are +// the FIRST combatants in state.units order, so the breakdown has to be +// computed in one pass rather than per unit on demand. +// +// cityYields sums this, and the city screen labels each supported-unit chip +// from the same entries — so a chip can never claim an upkeep the Shields row +// above it doesn't charge. +export function citySupport(rules, state, city) { + const gov = rules.governments[state.civs[city.civ].government]; + const entries = []; + let supportShields = 0; + let supportGold = 0; + let settlerFood = 0; + let combatants = 0; + for (const unit of state.units) { + if (unit.homeCity !== city.id) continue; + const def = rules.units[unit.type]; + const entry = { unit, def, food: 0, shield: 0, gold: 0 }; + if (def.flags.includes('settler')) { + entry.food = gov.settlerFood; + settlerFood += gov.settlerFood; + } + if (def.domain !== 'project' && !def.flags.includes('noncombat')) { + combatants += 1; + if (combatants > gov.freeUnits) { + if (gov.unitUpkeep === 'gold') { entry.gold = 1; supportGold += 1; } else { entry.shield = 1; supportShields += 1; } + } + } + entries.push(entry); + } + return { entries, supportShields, supportGold, settlerFood, freeUnits: gov.freeUnits }; +} + // 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 @@ -440,23 +475,7 @@ export function cityYields(rules, state, city) { // aiAggression handles war odds, this is the shield-output lever). if (!civ.human) shieldMult *= rules.difficulties[state.difficultyId].aiProdBonus; - // Unit support: shields per supported unit beyond the free allowance - // (Democracy pays gold instead), settlers also eat food. - const supported = state.units.filter((u) => u.homeCity === city.id); - let supportShields = 0; - let supportGold = 0; - let settlerFood = 0; - let combatants = 0; - for (const u of supported) { - const def = rules.units[u.type]; - if (def.flags.includes('settler')) settlerFood += gov.settlerFood; - if (def.domain === 'project' || def.flags.includes('noncombat')) continue; - combatants += 1; - if (combatants > gov.freeUnits) { - if (gov.unitUpkeep === 'gold') supportGold += 1; - else supportShields += 1; - } - } + const { supportShields, supportGold, settlerFood } = citySupport(rules, state, city); const grossShield = Math.floor(shield * shieldMult); // Waste — corruption's production sibling: the same capital-distance decay diff --git a/src/games/civilization/CivilizationTooltips.js b/src/games/civilization/CivilizationTooltips.js index b0bd8bb..7cba1e6 100644 --- a/src/games/civilization/CivilizationTooltips.js +++ b/src/games/civilization/CivilizationTooltips.js @@ -1,7 +1,8 @@ import { COLORS } from '../../config.js'; import { cityById, cityAt, tileIndex, defenderStrength, unitsOnBoat, IMP, SPECIAL_BUILDS, - tileYield, cityTileStatus, cityCentreYield, + tileYield, cityTileStatus, cityCentreYield, sizeCap, cheb, + FOOD_PER_CITIZEN, FOODBOX_PER_SIZE, } from './CivilizationLogic.js'; // Flavor/ability text for unit flags — data/civilization-rules.json has no @@ -310,6 +311,136 @@ export function describeCityTileTooltip(rules, state, city, x, y) { return { title: special ? `${special.name} (${terr.name})` : terr.name, lines }; } +// Hover tooltips for the city screen's five icon yield rows. The rows +// themselves are icons only (see CivilizationIcons.js), so these carry the +// exact figures the old text lines used to spell out — plus the derived facts +// the text never had, like how many turns until the city grows or starves. +// `yields` is a Logic.cityYields result; `row` is one of the ids below. +export const YIELD_ROWS = ['food', 'shield', 'trade', 'gold', 'science']; + +export function describeYieldRow(rules, state, city, yields, row) { + const civ = state.civs[city.civ]; + const gov = rules.governments[civ.government]; + const lines = []; + const head = (text) => lines.push({ text, color: COLORS.goldHex }); + const bullet = (text, color) => lines.push({ text: `• ${text}`, color }); + + switch (row) { + case 'food': { + const eaten = city.size * FOOD_PER_CITIZEN; + const settlerFood = yields.foodNeed - eaten; + head(`${yields.food} food from the tiles this city works`); + bullet(`${city.size} citizen${city.size === 1 ? '' : 's'} eat ${eaten}`); + if (settlerFood > 0) bullet(`Settlers in the field eat ${settlerFood}`); + const boxSize = (city.size + 1) * FOODBOX_PER_SIZE; + if (yields.foodSurplus > 0) { + bullet(`Surplus +${yields.foodSurplus} into the food store (${city.foodBox}/${boxSize})`); + const cap = sizeCap(rules, city); + if (city.size >= cap) { + bullet('The store is full — this city cannot grow further without an Aqueduct or Sewer System.', COLORS.dangerHex); + } else { + const turns = Math.max(1, Math.ceil((boxSize - city.foodBox) / yields.foodSurplus)); + bullet(`Grows in ${turns} turn${turns === 1 ? '' : 's'}`); + } + } else if (yields.foodSurplus < 0) { + bullet(`Shortfall −${-yields.foodSurplus} — the food store is draining (${city.foodBox}/${boxSize})`, COLORS.dangerHex); + const turns = Math.max(1, Math.ceil(city.foodBox / -yields.foodSurplus) + 1); + bullet(`Starves in ${turns} turn${turns === 1 ? '' : 's'} — a settler is lost first, otherwise a citizen`, COLORS.dangerHex); + } else { + bullet('Break-even: this city is neither growing nor starving.'); + } + return { title: 'Food', lines }; + } + case 'shield': { + head(`${yields.grossShield} shields produced`); + if (yields.waste) bullet(`Waste −${yields.waste} — distance from your palace`, COLORS.dangerHex); + if (yields.supportShields) bullet(`Unit support −${yields.supportShields} (${gov.name} supports ${gov.freeUnits} free)`, COLORS.dangerHex); + bullet(`${yields.shield} into the build box each turn`); + if (city.buildings.courthouse) bullet('A Courthouse is halving the waste.'); + return { title: 'Shields', lines }; + } + case 'trade': { + head(`${yields.trade} trade`); + if (yields.routeTrade) bullet(`Trade routes contribute +${yields.routeTrade}`); + if (yields.corruption) { + bullet(`Corruption −${yields.corruption} — distance from your palace`, COLORS.dangerHex); + } else { + bullet('No corruption here.'); + } + bullet(`${yields.netTrade} reaches your treasury, split evenly into gold and beakers`); + bullet(`That split gives ${yields.gold} gold and ${yields.science} science after this city's multipliers.`); + return { title: 'Trade', lines }; + } + case 'gold': { + head(`+${yields.gold} gold per turn`); + bullet(`Half of this city's ${yields.netTrade} net trade, before building multipliers`); + if (yields.upkeep) bullet(`Building upkeep −${yields.upkeep}/turn`, COLORS.dangerHex); + if (yields.supportGold) bullet(`Unit upkeep −${yields.supportGold}/turn (${gov.name} pays units in gold)`, COLORS.dangerHex); + const net = yields.gold - yields.upkeep - yields.supportGold; + bullet(`Net ${net >= 0 ? `+${net}` : `−${-net}`} gold per turn from this city`, + net < 0 ? COLORS.dangerHex : undefined); + return { title: 'Gold', lines }; + } + default: { + head(`+${yields.science} beakers per turn`); + if (gov.noScience) { + bullet(`${gov.name} produces no research at all.`, COLORS.dangerHex); + } else { + bullet(`Half of this city's ${yields.netTrade} net trade — science takes the odd point`); + } + const boosts = Object.keys(city.buildings) + .map((id) => rules.buildings[id]) + .filter((b) => b && b.effect === 'science'); + for (const b of boosts) bullet(`${b.name} adds +${Math.round(b.value * 100)}%`); + return { title: 'Science', lines }; + } + } +} + +// Hover tooltip for one of the supported-unit chips in the city screen. +// `entry` is a Logic.citySupport() entry, so what this says an individual unit +// costs is by construction the same arithmetic the Shields and Gold rows are +// showing — not a second opinion about the upkeep rules. +export function describeSupportedUnitTooltip(rules, state, city, entry, freeUnits) { + const { unit, def } = entry; + const gov = rules.governments[state.civs[city.civ].government]; + const lines = [ + { text: `Attack ${def.attack} · Defense ${def.defense} · Move ${def.move}`, color: COLORS.goldHex }, + ]; + + const dist = cheb(unit.x, unit.y, city.x, city.y); + if (dist === 0) lines.push({ text: '• Stationed in the city' }); + else lines.push({ text: `• ${dist} tile${dist === 1 ? '' : 's'} from home` }); + if (unit.vet) lines.push({ text: '• Veteran' }); + if (unit.fortified) lines.push({ text: '• Fortified' }); + if (unit.hp !== undefined && def.hp && unit.hp < def.hp) { + lines.push({ text: `• Damaged — ${unit.hp}/${def.hp} hit points`, color: COLORS.dangerHex }); + } + + const costs = []; + if (entry.shield) costs.push(`${entry.shield} shield`); + if (entry.gold) costs.push(`${entry.gold} gold`); + if (entry.food) costs.push(`${entry.food} food`); + if (costs.length) { + lines.push({ text: `• Costs ${city.name} ${costs.join(' and ')} per turn`, color: COLORS.dangerHex }); + } else if (def.domain === 'project' || def.flags.includes('noncombat')) { + // Noncombat units don't just happen to be free — they never take up one of + // the government's paid-support slots at all. + lines.push({ text: '• Noncombat — never counts against unit support', color: COLORS.mutedHex }); + } else { + lines.push({ + text: `• Supported free — ${gov.name} supports ${freeUnits} unit${freeUnits === 1 ? '' : 's'} at no cost`, + color: COLORS.mutedHex, + }); + } + + for (const flag of def.flags ?? []) { + const text = FLAG_TEXT[flag]; + if (text) lines.push({ text: `• ${text}` }); + } + return { title: def.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 c561bc5..1418acb 100644 --- a/tools/verifyCivilization.js +++ b/tools/verifyCivilization.js @@ -26,6 +26,9 @@ 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'; +// Icon frame map + the pure run/overflow maths behind the city screen's yield +// rows (the drawing half needs Phaser, the deciding half doesn't). +import * as Icons from '../src/games/civilization/CivilizationIcons.js'; const QUICK = process.argv.includes('--quick'); const root = join(dirname(fileURLToPath(import.meta.url)), '..'); @@ -906,6 +909,215 @@ if (RULES) { check('a fully-teched city offers more builds than the old 28-row cap', total > 28, `${total} options`); } + + // --- Icon yield rows (CivilizationIcons.js + the city screen's yields block) + { + const cap = 12; + const runs = [ + [0, { drawn: 0, multiplier: null }], + [1, { drawn: 1, multiplier: null }], + [cap - 1, { drawn: cap - 1, multiplier: null }], + [cap, { drawn: cap, multiplier: null }], + [cap + 1, { drawn: 1, multiplier: cap + 1 }], + [99, { drawn: 1, multiplier: 99 }], + ]; + let ok = true; + let why = ''; + for (const [n, want] of runs) { + const got = Icons.iconRun(n, cap); + if (got.drawn !== want.drawn || got.multiplier !== want.multiplier) { + ok = false; + why = `iconRun(${n}) = ${JSON.stringify(got)}, wanted ${JSON.stringify(want)}`; + } + if (got.drawn > cap) { ok = false; why = `iconRun(${n}) drew ${got.drawn} > cap`; } + } + check('iconRun collapses to a multiplier exactly past the cap', ok, why); + check('iconRun never draws a negative or fractional run', (() => { + const a = Icons.iconRun(-5, cap); + const b = Icons.iconRun(3.7, cap); + return a.drawn === 0 && a.multiplier === null && b.drawn === 3; + })()); + + // The frame map is duplicated in sprites.md and, for governments, in the + // rules JSON. Keep all three honest. + check('every icon frame exists on the 480x96 @ 48x48 sheet', + Object.values(Icons.ICON_FRAME).every((f) => Number.isInteger(f) && f >= 0 && f < Icons.ICON_FRAMES)); + check('icon frames are unique', + new Set(Object.values(Icons.ICON_FRAME)).size === Object.keys(Icons.ICON_FRAME).length); + check('government icon frames agree with governments[].frame in the rules', + RULES.governmentList.every((g) => Icons.ICON_FRAME[`gov-${g.id}`] === g.frame), + RULES.governmentList.map((g) => `${g.id}:${g.frame}/${Icons.ICON_FRAME[`gov-${g.id}`]}`).join(' ')); + } + + // Each row draws a total and then a deduction as though these identities + // hold. If cityYields is ever reworked they must fail here, rather than the + // display quietly showing the wrong number of icons. + { + let ok = true; + let why = ''; + for (const govId of ['despotism', 'monarchy', 'communism', 'republic', 'democracy']) { + for (const size of [1, 4, 12]) { + for (const extras of [false, true]) { + const st = makeFlatState({ cols: 24, rows: 24 }); + st.civs[0].government = govId; + const capital = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 2, 2, null)); + capital.buildings.palace = true; + // A second city far from the palace, so waste and corruption bite. + const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 18, 18, null)); + city.size = size; + if (extras) { + city.buildings.courthouse = true; + city.buildings.marketplace = true; + city.buildings.library = true; + for (let k = 0; k < 6; k += 1) Logic.spawnUnit(RULES, st, 0, 'warriors', 18, 18, city.id); + Logic.spawnUnit(RULES, st, 0, 'settlers', 18, 17, city.id); + } + Logic.autoAssignTiles(RULES, st, city); + const y = Logic.cityYields(RULES, st, city); + const label = `${govId}/size${size}${extras ? '/extras' : ''}`; + if (y.foodSurplus !== y.food - y.foodNeed) { ok = false; why = `${label}: food`; } + if (y.shield !== Math.max(0, y.grossShield - y.waste - y.supportShields)) { ok = false; why = `${label}: shields`; } + if (y.netTrade !== y.trade - y.corruption) { ok = false; why = `${label}: trade`; } + if (y.waste > y.grossShield || y.corruption > y.trade) { ok = false; why = `${label}: loss exceeds output`; } + if ([y.food, y.grossShield, y.trade, y.gold, y.science].some((v) => v < 0 || !Number.isInteger(v))) { + ok = false; + why = `${label}: non-integer or negative total`; + } + } + } + } + check('yield rows can draw total-minus-loss without lying about the engine', ok, why); + } + + // describeYieldRow gives the numbers back that the icons-only rows drop. + { + const build = ({ govId = 'despotism', size = 3, starve = false, democracy = false } = {}) => { + // Bare mountains grow nothing, so a city of any size on them starves no + // matter how autoAssignTiles reshuffles its citizens. + const st = makeFlatState({ cols: 24, rows: 24, terrain: starve ? 'mountains' : 'grassland' }); + st.civs[0].government = democracy ? 'democracy' : govId; + const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 12, 12, null)); + city.size = starve ? 6 : size; + if (starve) city.foodBox = 5; + if (democracy) for (let k = 0; k < 5; k += 1) Logic.spawnUnit(RULES, st, 0, 'warriors', 12, 12, city.id); + Logic.autoAssignTiles(RULES, st, city); + return { st, city, y: Logic.cityYields(RULES, st, city) }; + }; + const cases = [ + ['ordinary city', build()], + ['size-1 city', build({ size: 1 })], + ['starving city', build({ starve: true })], + ['anarchy (no science)', build({ govId: 'anarchy' })], + ['democracy (gold unit upkeep)', build({ democracy: true })], + ]; + let ok = true; + let why = ''; + for (const [label, { st, city, y }] of cases) { + for (const row of Tooltips.YIELD_ROWS) { + const tip = Tooltips.describeYieldRow(RULES, st, city, y, row); + const blob = `${tip.title}${tip.lines.map((l) => l.text).join('')}`; + if (!tip.title || !tip.lines.length) { ok = false; why = `${label}/${row}: empty`; } + if (/\{|undefined|NaN|Infinity/.test(blob)) { ok = false; why = `${label}/${row}: ${blob}`; } + } + } + check('every yield row tooltip renders for every kind of city', ok, why); + + const { st, city, y } = build({ starve: true }); + const food = Tooltips.describeYieldRow(RULES, st, city, y, 'food'); + check('a starving city is told how long it has', + y.foodSurplus < 0 && food.lines.some((l) => /Starves in \d+ turn/.test(l.text)), + food.lines.map((l) => l.text).join(' | ')); + + const growing = build(); + const growTip = Tooltips.describeYieldRow(RULES, growing.st, growing.city, growing.y, 'food'); + check('a growing city is told when it grows', + growing.y.foodSurplus > 0 && growTip.lines.some((l) => /Grows in \d+ turn/.test(l.text)), + growTip.lines.map((l) => l.text).join(' | ')); + } + + // --- Supported-unit chips. Each chip states what that one unit costs, so + // the per-entry costs have to add up to the totals the Shields/Gold rows + // charge — otherwise the strip and the rows above it disagree on screen. + { + const stage = (govId) => { + const st = makeFlatState({ cols: 20, rows: 20 }); + st.civs[0].government = govId; + const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 8, 8, null)); + for (let k = 0; k < 5; k += 1) Logic.spawnUnit(RULES, st, 0, 'warriors', 8, 8, city.id); + Logic.spawnUnit(RULES, st, 0, 'settlers', 9, 8, city.id); // eats food, never shields + Logic.spawnUnit(RULES, st, 0, 'explorer', 9, 9, city.id); // noncombat, always free + Logic.spawnUnit(RULES, st, 0, 'warriors', 8, 8, null); // homed nowhere — not ours + return { st, city, sup: Logic.citySupport(RULES, st, city), y: Logic.cityYields(RULES, st, city) }; + }; + let ok = true; + let why = ''; + for (const govId of RULES.governmentList.map((g) => g.id)) { + const { sup, y, city } = stage(govId); + const sum = (k) => sup.entries.reduce((a, e) => a + e[k], 0); + if (sum('shield') !== sup.supportShields) { ok = false; why = `${govId}: shield entries`; } + if (sum('gold') !== sup.supportGold) { ok = false; why = `${govId}: gold entries`; } + if (sum('food') !== sup.settlerFood) { ok = false; why = `${govId}: food entries`; } + if (y.supportShields !== sup.supportShields || y.supportGold !== sup.supportGold) { + ok = false; + why = `${govId}: cityYields disagrees with citySupport`; + } + if (y.foodNeed !== city.size * Logic.FOOD_PER_CITIZEN + sup.settlerFood) { + ok = false; + why = `${govId}: settler food missing from foodNeed`; + } + if (sup.entries.some((e) => e.shield && e.gold)) { ok = false; why = `${govId}: charged twice`; } + } + check('supported-unit chips add up to the upkeep the city is charged', ok, why); + + // Only the first `freeUnits` COMBATANTS ride free, in state.units order. + { + const { sup } = stage('despotism'); // freeUnits 3, shield upkeep + const combatants = sup.entries.filter((e) => e.def.domain !== 'project' + && !e.def.flags.includes('noncombat')); + const freeCount = combatants.filter((e) => !e.shield && !e.gold).length; + check('the free allowance covers the first combatants only', + freeCount === 3 && combatants.slice(0, 3).every((e) => !e.shield) + && combatants.slice(3).every((e) => e.shield === 1), + `${freeCount} free of ${combatants.length}`); + check('noncombat and settler units never cost shields', + sup.entries.filter((e) => e.def.flags.includes('noncombat')).every((e) => !e.shield && !e.gold)); + check('settlers in the field eat food', + sup.entries.some((e) => e.def.flags.includes('settler') && e.food > 0)); + } + { + const { sup } = stage('democracy'); // pays units in gold, 0 free + check('Democracy charges gold for units, not shields', + sup.supportGold > 0 && sup.supportShields === 0, `${sup.supportGold}g/${sup.supportShields}s`); + } + + // Every chip's tooltip must render — including the wounded, the veteran, + // the fortified and the far-from-home. + { + const { st, city, sup } = stage('monarchy'); + sup.entries[1].unit.vet = true; + sup.entries[2].unit.fortified = true; + sup.entries[3].unit.hp = 3; + let clean = true; + let bad = ''; + for (const entry of sup.entries) { + const tip = Tooltips.describeSupportedUnitTooltip(RULES, st, city, entry, sup.freeUnits); + const blob = `${tip.title}${tip.lines.map((l) => l.text).join('')}`; + if (!tip.title || tip.lines.length < 2 || /\{|undefined|NaN/.test(blob)) { clean = false; bad = blob; } + } + check('every supported-unit tooltip renders', clean, bad); + const vetTip = Tooltips.describeSupportedUnitTooltip(RULES, st, city, sup.entries[1], sup.freeUnits); + check('a veteran chip says so', vetTip.lines.some((l) => l.text.includes('Veteran'))); + const paid = sup.entries.find((e) => e.shield || e.gold); + const paidTip = Tooltips.describeSupportedUnitTooltip(RULES, st, city, paid, sup.freeUnits); + check('a chip that costs upkeep names the cost', + paidTip.lines.some((l) => /Costs .* per turn/.test(l.text)), + paidTip.lines.map((l) => l.text).join(' | ')); + const free = sup.entries.find((e) => !e.shield && !e.gold && !e.food); + const freeTip = Tooltips.describeSupportedUnitTooltip(RULES, st, city, free, sup.freeUnits); + check('a free chip explains the allowance', + freeTip.lines.some((l) => /Supported free/.test(l.text))); + } + } } // ---------------------------------------------------------------------------