fix: cap city shields at build cost to prevent overflow display

When a city cannot complete a build (e.g., size < 2 for settlers),
shields were accumulating past the build cost, showing confusing
values like "60/40 shields" or negative turn counts.

- Cap shieldBox at buildCost each turn in processCity
- Clamp displayed shields to min(shieldBox, cost) in city screen
- Add tests verifying shield capping and stalled build completion
This commit is contained in:
Brian Fertig 2026-07-15 23:36:53 -06:00
parent 325692fa35
commit 4d864503f2
3 changed files with 35 additions and 4 deletions

View File

@ -114,9 +114,10 @@ export function openCityScreen(scene, rules, state, city, onClose) {
const rx = px + 60;
const cost = Logic.buildCost(rules, city);
const cur = city.build.type === 'unit' ? rules.units[city.build.id] : rules.buildings[city.build.id];
const turns = y.shield > 0 ? Math.ceil((cost - city.shieldBox) / y.shield) : '∞';
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} ${city.shieldBox}/${cost} shields (${turns} turns)`, {
`Building: ${cur.name} ${shownShields}/${cost} shields (${turns} turns)`, {
fontFamily: FONT, fontSize: '22px', color: COLORS.textHex,
}));
const buyPrice = Logic.buyCost(rules, city);

View File

@ -548,8 +548,11 @@ function processCity(rules, state, city) {
if (city.size <= 0) { destroyCity(rules, state, city); return; }
}
// Shields.
city.shieldBox += y.shield;
// Shields. Capped at cost — a settler build stalled on city.size < 2
// (see completeBuild) would otherwise keep banking shields turn after
// turn with nowhere to go, showing e.g. "60/40 shields" and a negative
// turn count once shields overshot the cost.
city.shieldBox = Math.min(city.shieldBox + y.shield, buildCost(rules, city));
if (city.shieldBox >= buildCost(rules, city)) completeBuild(rules, state, city);
city.boughtThisTurn = false;

View File

@ -336,6 +336,33 @@ if (RULES) {
check('granary keeps half box', city.foodBox >= Math.floor(((sizeBefore + 1) * Logic.FOODBOX_PER_SIZE) / 2));
}
// Settler-hold: a size-1 city building a settler must not bank shields
// past cost turn after turn while it waits for the city to spare a
// citizen (used to show e.g. "60/40 shields (-5 turns)").
{
const st = makeFlatState({ terrain: 'plains' });
const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null));
city.build = { type: 'unit', id: 'settlers' };
const cost = Logic.buildCost(RULES, city);
city.size = 1;
city.shieldBox = cost + 20; // as if it had banked well past cost over many stalled turns
city.foodBox = 0;
Logic.beginCivTurn(RULES, st, 0);
check('shieldBox re-capped at cost on a stalled turn', city.shieldBox <= cost,
`${city.shieldBox}/${cost}`);
// Once the city can spare a citizen, the stalled build completes
// immediately. foodBox=2 keeps this turn's -1 deficit (size-2 plains
// under despotism) from also triggering a famine shrink, which would
// confound the size assertion below.
const unitsBefore = st.units.length;
city.size = 2;
city.foodBox = 2;
Logic.beginCivTurn(RULES, st, 0);
check('stalled settler completes once city can spare population',
st.units.length === unitsBefore + 1 && city.size === 1 && city.shieldBox === 0);
}
// Despotism penalty & government trade bonus on tile yields.
{
const st = makeFlatState({ terrain: 'plains' });