From 83e66e2790807b8a903e867a8880e6f0d2d3d8a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 18:22:54 +0000 Subject: [PATCH] Blockilization: a worker's card says what it's doing, how far along, and when it's done The card showed only a hammer and "N turns" in its stats row, and every build button looked the same while one was under way. Now a worker at work gets its own lines on the card: what it's building ("Building a Farm", "Clearing the Forest"), a progress bar, the turns left and what the improvement brings when done. The build under way is marked on its button, and the other buttons say switching loses what's done; the hint says moving it stops the work. A worker on its way to recommended work says what it builds when it gets there; the units overview names the build, and the map's sign on a working unit is the build's glyph rather than a hammer. The turns left were also wrong: they ignored the work rate (Pyramids, Citizenship...) and that a worker with no moves left does no work this turn. `workLeft` counts both, and the view sends how far along the work is. Ordering a worker to build what it's already building no longer starts it over from nothing. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01SiC4oAGGVGYdsEpbfMuweF --- src/games/blockilization/client/overlay.ts | 5 +- src/games/blockilization/client/overview.ts | 6 +- src/games/blockilization/client/pixel.ts | 7 ++ src/games/blockilization/client/style.ts | 4 ++ src/games/blockilization/client/ui.ts | 70 ++++++++++++++++--- src/games/blockilization/client/workadvice.ts | 4 ++ src/games/blockilization/protocol.ts | 3 +- src/games/blockilization/rules/game.ts | 19 ++++- src/games/blockilization/tests/cases.ts | 23 ++++++ src/games/blockilization/views.ts | 4 +- 10 files changed, 127 insertions(+), 18 deletions(-) diff --git a/src/games/blockilization/client/overlay.ts b/src/games/blockilization/client/overlay.ts index dcf799d..a6954ed 100644 --- a/src/games/blockilization/client/overlay.ts +++ b/src/games/blockilization/client/overlay.ts @@ -14,6 +14,7 @@ import { marchingAt } from './models3d'; import { drawPins } from './pins'; import { drawPings } from './chat'; import { drawWorkTips } from './workadvice'; +import { BUILD_GLYPH } from './ui'; import { previewPath, routeOf, type Route } from './path'; import { mapFont } from './pixel'; import { cityAt, enemyAt, me, myUnit, reachOf, attackable, store, TEAM_COLORS, tone } from './store'; @@ -706,8 +707,8 @@ export function drawOverlay(now: number) { ctx.textBaseline = 'middle'; shadowText(label, tx + tw / 2, ty + th / 2 + 1, '#f2c14e'); } - // Fortified, asleep, working: a small sign at its corner. - const sign = u.stance === 'fortify' ? 'shield' : u.stance === 'sleep' ? 'turn' : u.work ? 'hammer' : ''; + // Fortified, asleep, working (the sign of what it's building): a small sign at its corner. + const sign = u.stance === 'fortify' ? 'shield' : u.stance === 'sleep' ? 'turn' : u.work ? (BUILD_GLYPH[u.work.what] ?? 'hammer') : ''; if (sign && own) { ctx.fillStyle = 'rgba(13,19,27,0.9)'; dotPath(x + fw / 2, y - fw / 2, size * 0.42); diff --git a/src/games/blockilization/client/overview.ts b/src/games/blockilization/client/overview.ts index 035f04a..41c1e18 100644 --- a/src/games/blockilization/client/overview.ts +++ b/src/games/blockilization/client/overview.ts @@ -1,5 +1,6 @@ import { centre } from '../geometry'; import type { CityWire, DemographicId, UnitWire } from '../protocol'; +import type { BuildWhat } from '../rules/game'; import { CIVS } from '../rules/civs'; import { distance } from '../rules/hex'; import { ERA_NAMES, ERAS } from '../rules/techs'; @@ -7,8 +8,9 @@ import { UNITS } from '../rules/units'; import { lookAt } from './camera'; import { emblemEl, GREAT_GLYPH, icon, svg, YIELD_COLOR } from './icons'; import { me, store } from './store'; -import { askCity, cityBuild, h, itemArt, itemName, openCity, pic, portrait, tell, tile, togglePanel, type El } from './ui'; +import { askCity, cityBuild, h, itemArt, itemName, openCity, pic, portrait, tell, tile, togglePanel, workWhen, workWords, type El } from './ui'; import { unitModel } from './models3d'; +import { buildName } from './workadvice'; import { renderPolitics } from './politics'; /** @@ -261,7 +263,7 @@ function renderUnits(): El { return h( `div.ov-unit${chosen ? '.chosen' : ''}`, { onclick: (e: MouseEvent) => select(u, e), title: 'Click to select it; shift-click to add it to the selected unit’s group' }, - h('span.st', { style: `color:${STATUS[st][1]}` }, h('i.dot', { style: `background:${STATUS[st][1]}` }), st === 'working' && u.work ? `${STATUS[st][0]} (${u.work.turns}t)` : st === 'moving' ? `${STATUS[st][0]} (${u.path!.length} tile${u.path!.length === 1 ? '' : 's'})` : STATUS[st][0]), + h('span.st', { style: `color:${STATUS[st][1]}`, title: st === 'working' && u.work ? `${workWords(u.work.what, u.tile)}: ${workWhen(u.work.turns)}` : undefined }, h('i.dot', { style: `background:${STATUS[st][1]}` }), st === 'working' && u.work ? `${buildName(u.work.what as BuildWhat, u.tile)} (${u.work.turns}t)` : st === 'moving' ? `${STATUS[st][0]} (${u.path!.length} tile${u.path!.length === 1 ? '' : 's'})` : STATUS[st][0]), u.embarked && h('span.cv-chip', {}, 'at sea'), h('span.cv-grow.cv-muted', {}, whereIs(u)), h('span.hp', {}, h(`div.cv-bar.${u.hp > 50 ? 'green' : u.hp > 25 ? 'gold' : 'red'}`, {}, h('i', { style: `width:${u.hp}%` }))), diff --git a/src/games/blockilization/client/pixel.ts b/src/games/blockilization/client/pixel.ts index c0193a3..d160c59 100644 --- a/src/games/blockilization/client/pixel.ts +++ b/src/games/blockilization/client/pixel.ts @@ -61,6 +61,7 @@ const BTN_GREEN = button('#a6ec7c', '#2c6420'); const BTN_RED = button('#ee7c64', '#5e1c12'); const BTN_GOLD = button('#ffe48a', '#7a5a12'); const BTN_BLUE = button('#a8dcff', '#1e4a80'); +const BTN_ORANGE = button('#ffcc88', '#7a3e10'); const CHIP = frame(1, ['O'], { O: '#07090c' }, 2, 0); const CHIP_ON = frame(1, ['O'], { O: '#f2c14e' }, 2, 0); const TIP = frame(2, ['O', 'bev'], { O: '#07090c', H: '#5d6884', L: '#0d1015' }); @@ -73,6 +74,7 @@ const FACE_GREEN = face('#6cc04c', '#5fb042'); const FACE_RED = face('#c04a34', '#b0412d'); const FACE_GOLD = face('#e8bc48', '#dcae3a'); const FACE_BLUE = face('#4a8ed8', '#3f80c8'); +const FACE_ORANGE = face('#d97e30', '#c97028'); // Bars: blocks of 8 with a 2-pixel gap, each lit on top. const blocks = (bc: string, bh: string, bl: string) => `background: repeating-linear-gradient(90deg, transparent 0 8px, #0f1218 8px 10px), linear-gradient(180deg, ${bh} 0 2px, ${bc} 2px calc(100% - 2px), ${bl} calc(100% - 2px)) !important;`; @@ -210,6 +212,11 @@ export const PIXEL_CSS = ` .cv-unit .acts .cv-btn.small { padding: 2px 5px 3px !important; gap: 4px !important; font-size: 17px !important; } .cv-unit .cv-ustats { font-size: 13px !important; } .cv-unit .cv-ustats b { font-size: 22px !important; line-height: 1; ${SH} } +.cv-unit .cv-work { ${border(SLOT_SM)} background: #2c2519 !important; background-clip: padding-box !important; box-shadow: none !important; padding: 5px 8px 6px !important; } +.cv-unit .cv-work .going { font: 400 21px/1 ${D} !important; letter-spacing: .03em; ${SH} } .cv-unit .cv-work .left { color: #fff !important; } +.cv-unit .cv-work small { font-size: 13px !important; } +.cv-btn.on { ${border(BTN_ORANGE)} ${FACE_ORANGE} color: #fff !important; text-shadow: 2px 2px 0 #5a2a08 !important; cursor: default !important; } +.cv-btn.on:hover:not(.off), .cv-btn.on:active:not(.off) { ${FACE_ORANGE} transform: none !important; } .cv-mod { font: 400 16px/1.1 ${D} !important; letter-spacing: .04em; padding: 1px 6px !important; } .cv-mod.up { background: #24402a !important; box-shadow: inset 0 0 0 2px #3c6a3c; } .cv-mod.down { background: #4a2420 !important; box-shadow: inset 0 0 0 2px #7a3a30; } .cv-defence { font-size: 14px !important; } .cv-defence b { font-size: 20px; ${SH} } diff --git a/src/games/blockilization/client/style.ts b/src/games/blockilization/client/style.ts index 19aea80..5e48d0f 100644 --- a/src/games/blockilization/client/style.ts +++ b/src/games/blockilization/client/style.ts @@ -144,6 +144,10 @@ select.cv-input { width: auto; flex: 1; } .cv-blocked > div { display: flex; align-items: center; gap: 6px; } .cv-blocked b { font-weight: 700; color: var(--text); } .cv-unit .going { display: flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 700; color: #ffe3a1; } .cv-unit .going.danger { color: #f0a090; } +.cv-unit .cv-work { display: flex; flex-direction: column; gap: 4px; padding: 7px 9px; border-radius: var(--r2); background: rgba(240,162,74,.1); box-shadow: inset 0 0 0 1px rgba(240,162,74,.3); } +.cv-unit .cv-work .going { color: #ffd9a8; } .cv-unit .cv-work .left { color: var(--text); white-space: nowrap; } +.cv-unit .cv-work small { font-size: 12px; } .cv-unit .cv-work .spent { color: #ffe3a1; } +.cv-btn.on { background: rgba(240,162,74,.18); box-shadow: inset 0 0 0 1px #f0a24a; color: #ffd9a8; cursor: default; } .cv-unit .arrive { font-size: 12px; font-weight: 700; color: #cfe6ff; } .cv-unit .arrive:empty { display: none; } .cv-unit .arrive.no { color: #ff9a8e; } .cv-promos { display: flex; flex-direction: column; gap: 5px; padding: 8px; border-radius: 10px; background: rgba(242,193,78,.1); border: 1px solid rgba(242,193,78,.3); } .cv-promo { display: block; text-align: left; padding: 6px 9px; border-radius: 8px; background: rgba(242,193,78,.12); border: 1px solid rgba(242,193,78,.35) !important; transition: background .12s; } diff --git a/src/games/blockilization/client/ui.ts b/src/games/blockilization/client/ui.ts index a1018c3..557fbc6 100644 --- a/src/games/blockilization/client/ui.ts +++ b/src/games/blockilization/client/ui.ts @@ -2,6 +2,7 @@ import type { Client, ClientRooms } from '@platform/client'; import { centre } from '../geometry'; import type { CityInfo, CityWire, ClientToServer, LobbyAction, UnitWire, View } from '../protocol'; import { takenAlive } from '../rules/combat'; +import { aName } from '../rules/core'; import { article, formationBonus, formationKind, unitName } from '../rules/armies'; import type { CombatPreview, PreviewSide } from '../rules/preview'; import { BUILDINGS, SPACESHIP_PARTS } from '../rules/buildings'; @@ -35,7 +36,7 @@ import { gameId, roomCode } from '../saves'; import { ADVICE_CSS, adviceTag, researchHint } from './advice'; import { POLITICS_CSS } from './politics'; import { heldTile, tileMenuFree } from './input'; -import { workQuiet, workTip, workTipLines } from './workadvice'; +import { workPlan, workQuiet, workTip, workTipLines } from './workadvice'; import { FAME_CSS, renderFame } from './fame'; import { defenceAdvice } from './defend'; import { closePinMenu, pinButtons, pinColor, pinMenuOpen, pinsButton, PINS_CSS, pinTip, renderPinList, renderPinMenu } from './pins'; @@ -1194,15 +1195,15 @@ function drawMinimap(cv: HTMLCanvasElement) { // The selected unit // ------------------------------------------------------------------------------------------------- -/** Improvements a worker might build on its tile, and what each does there (the server checks it can). */ -function buildOptions(t: number): { what: string; name: string; does: string }[] { +/** Improvements a worker might build on its tile, and what each does there (an improvement's yields and resource: `gives`). The server checks it can. */ +function buildOptions(t: number): { what: string; name: string; does: string; gives?: string }[] { const v = store.view!; const techs = new Set(v.self.techs); const s = store.terrain[t]; const k = store.known[t]; // (Nothing's built on a natural wonder, not even a road.) if (!s || !k || k.wonder) return []; - const out: { what: string; name: string; does: string }[] = []; + const out: { what: string; name: string; does: string; gives?: string }[] = []; // (No road on the water: an embarked worker builds none, as the rules' `canBuild` has it.) if (!k.road && techs.has('the_wheel') && !TERRAINS[s.terrain].water) out.push({ what: 'road', name: 'Road', does: 'Faster moves, and a link from a city to your capital' }); if (k.road === 'road' && techs.has('railroad')) out.push({ what: 'rail', name: 'Railroad', does: 'Much faster moves' }); @@ -1217,7 +1218,8 @@ function buildOptions(t: number): { what: string; name: string; does: string }[] if (imp.great || (imp.tech && !techs.has(imp.tech)) || k.improvement === id || cleared(id, t)) continue; const res = k.resource && tapsWith({ resource: k.resource, terrain: s.terrain }) === id ? RESOURCES[k.resource] : undefined; const gain = yieldWords(addYields(imp.yields, res?.improved ?? {})); - if (imp.on.includes('resource') ? res : fitsLand(id, t) || res) out.push({ what: id, name: imp.name, does: `${imp.name}: ${gain || 'no yields of its own'} here${res ? ` (with the ${res.name}${res.kind === 'bonus' ? '' : `: your empire gets the ${res.name}`})` : ''}` }); + const gives = `${gain || 'no yields of its own'} here${res ? ` (with the ${res.name}${res.kind === 'bonus' ? '' : `: your empire gets the ${res.name}`})` : ''}`; + if (imp.on.includes('resource') ? res : fitsLand(id, t) || res) out.push({ what: id, name: imp.name, does: `${imp.name}: ${gives}`, gives }); } if (s.feature && FEATURES[s.feature].removable && techs.has(FEATURES[s.feature].removable!.tech)) out.push({ what: 'remove_feature', name: `Clear ${FEATURES[s.feature].name}`, does: `Clear the ${FEATURES[s.feature].name}${FEATURES[s.feature].removable!.prod ? ': its wood is production for the nearest city' : ''}` }); return out; @@ -1280,8 +1282,43 @@ function seaBlocked(t: number): { what: ImprovementId; name: string; why: string export const BUILD_GLYPH: Record = { road: 'trade', rail: 'rail', repair: 'hammer', farm: 'wheat', mine: 'pick', pasture: 'cattle', camp: 'deer', plantation: 'leaf', quarry: 'stone', trading_post: 'gold', lumber_mill: 'hammer', fishing_boats: 'fish', oil_well: 'oil', offshore_platform: 'oil', fort: 'shield', remove_feature: 'axe', scrub_fallout: 'drop', + landmark: 'pick', artifact: 'frame', }; +/** What a unit at work is doing on its tile, in words: 'Building a Farm', 'Clearing the Forest'. */ +export function workWords(what: string, t: number): string { + const s = store.terrain[t]; + const k = store.known[t]; + if (what === 'road') return 'Building a Road'; + if (what === 'rail') return 'Laying a Railroad'; + if (what === 'repair') return `Repairing the pillaged ${k?.improvement ? (IMPROVEMENTS[k.improvement as ImprovementId]?.name ?? 'improvement') : 'Road'}`; + if (what === 'remove_feature') return `Clearing the ${s?.feature ? FEATURES[s.feature].name : 'land'}`; + if (what === 'scrub_fallout') return 'Scrubbing away the Fallout'; + if (what === 'landmark') return 'Making the site a Landmark'; + if (what === 'artifact') return 'Digging up an Artifact'; + return `Building ${aName(IMPROVEMENTS[what as ImprovementId]?.name ?? what.replace('_', ' '))}`; +} + +/** When a unit's work is done, in words: its turns left counted as `views.ts` counts them (this turn the first, if it has a move). */ +export const workWhen = (turns: number) => (turns <= 1 ? 'done at the end of this turn' : `${turns} turns left`); + +/** + * A unit at work's lines on its card: what it's doing, how far along, when it's done, and (an + * improvement) what that brings; or, with its moves spent, that the work only starts next turn. + */ +function workRow(u: UnitWire): El { + const w = u.work!; + const spent = (u.moves ?? 0) <= 0; + const gives = buildOptions(u.tile).find((b) => b.what === w.what)?.gives; + return h( + 'div.cv-work', + { title: `${Math.round(w.progress * 100)}% done` }, + h('div.going', {}, icon(BUILD_GLYPH[w.what] ?? 'hammer', 14, '#f0a24a'), h('span.cv-grow', {}, workWords(w.what, u.tile)), h('span.left', {}, workWhen(w.turns))), + h('div.cv-bar.orange', {}, h('i', { style: `width:${Math.round(w.progress * 100)}%` })), + spent ? h('small.spent', {}, 'No moves left this turn: the work starts next turn') : gives ? h('small.cv-muted', {}, `When done: ${gives}`) : null, + ); +} + /** The selected unit's card's line for where the mouse is (how many turns to get there), kept current every frame. */ let arrival: El | null = null; @@ -1308,12 +1345,21 @@ function renderUnit(): El | null { if (u.auto) act('Stop automating', { t: 'automate', unit: u.id, mode: null }, 'Take it back: it waits for your orders again', 'close', '.gold'); else if (type.abilities?.includes('build') || type.abilities?.includes('build_sea')) act('Automate', { t: 'automate', unit: u.id, mode: 'work' }, 'Let it improve your land by itself each turn (E): luxuries and resources first, then the tiles your cities work, then roads', 'gear'); else if (explores(u)) act('Explore', { t: 'automate', unit: u.id, mode: 'explore' }, `Let it explore by itself each turn, until there is nothing left to see${type.domain === 'sea' ? ' on the seas' : ''}; then it heads home (E)`, 'compass'); - // (The advisor's recommended work here, if any, in gold: `workadvice.ts`.) + // (The advisor's recommended work here, if any, in gold; the work it's on its way to: `workadvice.ts`.) const advised = workTip(u.tile); + const plan = workPlan(u.id); + const then = plan && workWords(plan.what, plan.tile); // (A work boat builds fishing boats, or an offshore platform on Oil, on a sea resource in your waters, and is spent doing it.) const sea = !type.abilities?.includes('build') && !!type.abilities?.includes('build_sea'); const builds = type.abilities?.includes('build') ? buildOptions(u.tile) : sea ? buildOptions(u.tile).filter((b) => SEA_WORK.includes(b.what)).map((b) => ({ ...b, does: `${b.does}. The ${type.name} is spent building it` })) : []; - for (const b of builds) act(b.name, { t: 'build', unit: u.id, what: b.what as never }, advised?.what === b.what ? `Advised: ${advised.why}. ${b.does}` : b.does, BUILD_GLYPH[b.what] ?? 'hammer', advised?.what === b.what || sea ? '.gold' : ''); + // (At work: its work's button marked as under way, and a word on each other that switching loses what's done.) + const busy = u.work && { ...u.work, words: workWords(u.work.what, u.tile) }; + for (const b of builds) + if (busy?.what === b.what) act(b.name, () => {}, `${busy.words}: ${workWhen(busy.turns)}`, BUILD_GLYPH[b.what] ?? 'hammer', '.on'); + else { + const does = advised?.what === b.what ? `Advised: ${advised.why}. ${b.does}` : b.does; + act(b.name, { t: 'build', unit: u.id, what: b.what as never }, busy ? `${does}. Stops what it's doing now (${busy.words.toLowerCase()}) and starts this instead: the work done so far is lost` : does, BUILD_GLYPH[b.what] ?? 'hammer', advised?.what === b.what || sea ? '.gold' : ''); + } const blocked = type.abilities?.includes('build') ? buildBlocked(u.tile) : sea ? seaBlocked(u.tile) : []; // (Why the advisor marks no work for it, when it marks none.) const quiet = workQuiet(); @@ -1372,7 +1418,9 @@ function renderUnit(): El | null { ? 'Exploring by itself each turn. Any order takes it back.' : type.abilities?.includes('found_city') ? 'Move to good land (rivers, coast, food), then found a city.' - : type.abilities?.includes('build') + : u.work + ? 'Moving it or giving it other work stops this, and what it has done is lost.' + : type.abilities?.includes('build') ? 'Improve tiles your cities work: farms, mines, roads.' : type.abilities?.includes('combat_bonus_aura') ? `${ABILITY_TEXT.combat_bonus_aura} Keep it with your army (it can share a tile with a soldier), out of the enemy's reach.` @@ -1408,7 +1456,6 @@ function renderUnit(): El | null { type.ranged ? h('span', { title: 'Ranged strength · range' }, icon('ranged', 14, '#6ab8ff'), h('b', {}, `${type.ranged}`), formed, ` · ${reachOf(u)}`) : null, h('span', { title: 'Moves' }, icon('moves', 14, '#7bd16a'), h('b', {}, `${Math.round((u.moves ?? 0) * 10) / 10}/${u.maxMoves ?? type.moves}`)), u.xp ? h('span', { title: 'Experience' }, icon('xp', 14, '#f2c14e'), h('b', {}, String(u.xp))) : null, - u.work ? h('span', {}, icon('hammer', 14), h('b', {}, `${u.work.turns} turn${u.work.turns === 1 ? '' : 's'}`)) : null, ), ), ), @@ -1425,8 +1472,11 @@ function renderUnit(): El | null { ), // (Standing where an enemy city's walls reach: they fire at it each turn, fortified or not.) isMilitary(type) && type.domain !== 'air' && wallsOver(u.tile) && h('div.going.danger', {}, icon('war', 14, '#e0604a'), h('span', {}, `In range of ${wallsOver(u.tile)!.name}'s walls: they fire at it each turn`)), + // At work: what, how far along, when it's done. + u.work && workRow(u), // Its orders: where it's going, and when it gets there; the units gathered with it. - u.path?.length ? h('div.going', {}, icon('moves', 14, '#ffd23a'), h('span', {}, `Moving to ${placeName(u.path[u.path.length - 1])} · ${arrives(routeOf(u))}${marching(u.id) ? ' (with its group, at the pace of the slowest)' : ''}`)) : null, + // (A worker sent to recommended work: what it builds when it gets there, `workadvice.ts`.) + u.path?.length ? h('div.going', {}, icon('moves', 14, '#ffd23a'), h('span', {}, `Moving to ${placeName(u.path[u.path.length - 1])} · ${arrives(routeOf(u))}${marching(u.id) ? ' (with its group, at the pace of the slowest)' : ''}${then ? `, then ${then.charAt(0).toLowerCase()}${then.slice(1)}` : ''}`)) : null, // (One of a march that's ahead waits a turn for the slowest, with no way of its own to show.) !u.path?.length && marchOf(u.id) && diff --git a/src/games/blockilization/client/workadvice.ts b/src/games/blockilization/client/workadvice.ts index b2c440e..7fb7d49 100644 --- a/src/games/blockilization/client/workadvice.ts +++ b/src/games/blockilization/client/workadvice.ts @@ -26,6 +26,9 @@ let asked = ''; /** Workers on their way to recommended work: where, what to build there, where they set out from and when. */ const plans = new Map(); +/** Where a worker of yours is on its way to recommended work, and what it builds there. */ +export const workPlan = (unit: number) => plans.get(unit); + /** A new game: the last one's plans (and advice) go (its unit ids mean other units now). */ export function resetWorkPlans() { plans.clear(); @@ -71,6 +74,7 @@ export function buildName(what: BuildWhat, t: number): string { if (what === 'rail') return 'Railroad'; if (what === 'repair') return 'Repair'; if (what === 'scrub_fallout') return 'Scrub Fallout'; + if (what === 'artifact') return 'Artifact'; if (what === 'remove_feature') { const f = store.terrain[t]?.feature; return f ? `Clear the ${FEATURES[f].name}` : 'Clear the land'; diff --git a/src/games/blockilization/protocol.ts b/src/games/blockilization/protocol.ts index dcc2f49..609d723 100644 --- a/src/games/blockilization/protocol.ts +++ b/src/games/blockilization/protocol.ts @@ -222,7 +222,8 @@ export interface UnitWire { maxMoves?: number; stance?: string; path?: number[]; - work?: { what: string; turns: number }; + /** Own units at work: what (an improvement's id, or `road`, `repair`, `remove_feature`...), the turns it has left (this one counted if it has a move to spend), how far along it is (0 to 1). */ + work?: { what: string; turns: number; progress: number }; xp?: number; attacked?: boolean; setUp?: boolean; diff --git a/src/games/blockilization/rules/game.ts b/src/games/blockilization/rules/game.ts index a996aec..ed97ccb 100644 --- a/src/games/blockilization/rules/game.ts +++ b/src/games/blockilization/rules/game.ts @@ -837,6 +837,21 @@ export function workTurns(game: Game, what: string): number { return Math.max(1, Math.round(base * speed(game))); } +/** How much work a civ's units do a turn: a turn's worth, more with Citizenship, the Pyramids... */ +export const workRate = (game: Game, p: Player) => 1 + civEffects(game, p).reduce((s, e) => s + (e.workRate ?? 0), 0) / 100; + +/** + * How many turns a unit's work has left at its civ's pace, and how far along it is. This turn counts + * only if it still has a move at the turn's end (work isn't done with none: one that walked there + * and used them up starts next turn). + */ +export function workLeft(game: Game, u: Unit): { turns: number; progress: number } | undefined { + if (!u.work) return undefined; + const total = workTurns(game, u.work.what); + const turns = Math.max(1, Math.ceil((total - u.work.done) / workRate(game, game.players[u.owner]) - 1e-9)); + return { turns: turns + (u.moves > 0 ? 0 : 1), progress: Math.min(1, u.work.done / total) }; +} + /** What an order's field must be: a whole number, a tile of the map, a whole number or nothing, a string, one of some strings (or nothing, null or left out, where null is one), a list of strings, a list (checked where it's used), a yes or no, a production item; `?` when it may be left out. */ type Field = 'int' | 'tile' | 'int|null' | 'string' | 'strings' | 'list' | 'bool' | 'bool?' | 'item' | readonly (string | null)[]; @@ -1151,6 +1166,8 @@ function applyOrder(game: Game, by: number, o: Order): Outcome { } case 'build': { if (!canBuild(game, u, o.what)) return fail(buildWhy(game, u, o.what)); + // (Told again what it's already at: it carries on, keeping what it's done.) + if (u.work?.what === o.what) return OK; u.work = { what: o.what, done: 0 }; u.path = undefined; if (SEA_WORK.includes(o.what)) { @@ -2085,7 +2102,7 @@ function unitTurn(game: Game, u: Unit) { const p = game.players[u.owner]; // Workers' work (faster with Citizenship, the Pyramids...); an archaeologist's dig. if (u.work && u.moves > 0) { - u.work.done += 1 + civEffects(game, p).reduce((s, e) => s + (e.workRate ?? 0), 0) / 100; + u.work.done += workRate(game, p); const w = u.work.what; if (u.work.done >= workTurns(game, w) && (w === 'artifact' || (w === 'landmark' && type.abilities?.includes('archaeology')))) { dig(game, p, u, w); diff --git a/src/games/blockilization/tests/cases.ts b/src/games/blockilization/tests/cases.ts index 93ca872..10a4508 100644 --- a/src/games/blockilization/tests/cases.ts +++ b/src/games/blockilization/tests/cases.ts @@ -484,6 +484,29 @@ test('workers: the Pyramids and Citizenship each a quarter faster', () => { void p; }); +test('workers: their card is told the turns left at their pace, and how far along; told again, they carry on', () => { + const { game, at } = blank(); + const p = game.players[0]; + city(game, 0, at(5, 5)); + techsTo(game, 0, 1); + const w = unit(game, 'worker', 0, at(6, 5)); + const left = () => viewFor(game, p, null).units.find((x) => x.id === w.id)!.work!; + ok(apply(game, 0, { t: 'build', unit: w.id, what: 'farm' }).ok, 'build a farm'); + const total = workTurns(game, 'farm'); + eq(left().turns, total, 'all of it, this turn the first'); + eq(left().progress, 0, 'nothing done yet'); + endTurn(game); + eq(left().turns, total - 1, 'a turn less'); + near(left().progress, 1 / total, 'a turn of it done'); + ok(apply(game, 0, { t: 'build', unit: w.id, what: 'farm' }).ok, 'told again'); + eq(w.work?.done, 1, 'what it had done is kept'); + w.moves = 0; + eq(left().turns, total, 'with no moves left, this turn does no work'); + w.moves = 2; + adopt(game, 0, 'citizenship'); + eq(left().turns, Math.ceil((total - 1) / 1.25), 'a quarter faster'); +}); + // ------------------------------------------------------------------------------------------------- // Combat // ------------------------------------------------------------------------------------------------- diff --git a/src/games/blockilization/views.ts b/src/games/blockilization/views.ts index e7460af..298a582 100644 --- a/src/games/blockilization/views.ts +++ b/src/games/blockilization/views.ts @@ -4,7 +4,7 @@ import { BUILDINGS } from './rules/buildings'; import { canPillage, cityMaxHp, cityStrength, strength, type Modifier } from './rules/combat'; import { atWar, citiesOf, civEffects, has, knows, seesResource, seesUnit, techCost } from './rules/core'; import { assignCitizens, blockaded, cityEffects, cityYields, goldUpkeep, goldenCost, growthThreshold, happiness, luxuries, nextTile, policyCost, purchaseCost, SPECIALIST_YIELD, specialistSlots, strategic, tileCultureCost, tileGoldCost, tileYields, workedYields } from './rules/economy'; -import { ANARCHY_TURNS, blockers, buyable, canChooseIdeology, ideologySwitch, constructible, eraOf, faithPurchases, heldBuildings, itemCost, productionToward, reformationDue, score, trainable, trainOptions, upgradeCost, upgradeTo, workTurns } from './rules/game'; +import { ANARCHY_TURNS, blockers, buyable, canChooseIdeology, ideologySwitch, constructible, eraOf, faithPurchases, heldBuildings, itemCost, productionToward, reformationDue, score, trainable, trainOptions, upgradeCost, upgradeTo, workLeft } from './rules/game'; import { distance, neighbours, within } from './rules/hex'; import { pantheonCost } from './rules/religion'; import type { City, Game, Player, Production, Unit } from './rules/state'; @@ -365,7 +365,7 @@ export function viewFor(game: Game, p: Player | null, timer: number | null, pres w.setUp = u.setUp; w.skip = u.skip; w.auto = u.auto; - if (u.work) w.work = { what: u.work.what, turns: Math.max(0, Math.ceil(workTurns(game, u.work.what) - u.work.done)) }; + if (u.work) w.work = { what: u.work.what, ...workLeft(game, u)! }; w.promotions = u.promotions; w.promotionsDue = promotionsDue(u); if (w.promotionsDue > 0) w.promotionOptions = promotionsFor(u).map((x) => x.id);