feat(civilization): replace yield text with icon rows and unit chips on city screen
Replace the four-line text yield breakdown with five icon-based rows that draw total production as icons and show surplus/loss as a signed group. Hovering any row reveals exact figures and derived facts like growth/starvation timers. Replace the comma-separated supported units list with wrapped unit chips that show each unit individually, including veteran status (gold dot) and upkeep color coding. Chips display a "+N" overflow marker when units exceed the visible grid. Extract citySupport() from cityYields() to compute per-unit upkeep in one pass, ensuring the Shields/Gold rows and unit chips always agree on costs. Add describeYieldRow() and describeSupportedUnitTooltip() tooltips for the new visual elements. Add comprehensive headless tests covering icon run logic, yield identities, government upkeep rules, and tooltip rendering.
This commit is contained in:
parent
3122bb2f28
commit
09324f5b20
|
|
@ -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})`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue