diff --git a/demo/civ-lite/game.js b/demo/civ-lite/game.js
index 7b0dea2..464abe6 100644
--- a/demo/civ-lite/game.js
+++ b/demo/civ-lite/game.js
@@ -38,6 +38,13 @@ import {
getUnitProfile,
} from './combat-model.js';
import { buildSpriteAtlas } from './sprites.js';
+import {
+ createProductionQueue,
+ enqueueProduction,
+ evaluateVictory,
+ movementPlanForPath,
+ progressProductionQueue,
+} from './production-model.js';
import {
buildBuilding,
calculateCityEconomy,
@@ -90,11 +97,16 @@ const EDGE_SCROLL_ZONE = 30, EDGE_SCROLL_SPEED = 600; // px from edge, px/sec
const PAN_SPEED = 500; // WASD px/sec (world units)
const SIGHT = 2;
const UNIT_STATS = {
- warrior: { atk: 30, def: 15, mov: 2 },
- archer: { atk: 24, def: 12, mov: 2 },
- swordsman: { atk: 38, def: 18, mov: 2 },
- horseman: { atk: 34, def: 14, mov: 3 },
+ warrior: { hp: 100, atk: 30, def: 15, mov: 2 },
+ archer: { hp: 90, atk: 24, def: 12, mov: 2 },
+ swordsman: { hp: 110, atk: 38, def: 18, mov: 2 },
+ horseman: { hp: 95, atk: 34, def: 14, mov: 3 },
+ scout: { hp: 80, atk: 18, def: 10, mov: 3 },
};
+const PRODUCTION_OPTIONS = [
+ { id: 'warrior', label: 'Warrior', cost: 12, unitType: 'warrior' },
+ { id: 'scout', label: 'Scout', cost: 8, unitType: 'scout' },
+];
const GAME_SEED = 'civ-lite-barbarian-events-21';
// ─── DOM refs ───────────────────────────────────────
@@ -141,6 +153,7 @@ const dom = {
masterVolume: document.getElementById('masterVolume'),
musicVolume: document.getElementById('musicVolume'),
sfxVolume: document.getElementById('sfxVolume'),
+ prodPanel: document.getElementById('productionPanel'),
menu: document.getElementById('mainMenu'),
gameOver: document.getElementById('gameOver'),
tutorial: document.getElementById('tutorialPanel'),
@@ -295,7 +308,10 @@ const S = {
diplomacy: buildDiplomacy(DEFAULT_CIVS),
map: initialCiv.map,
units: initialCiv.units,
- cities: initialCiv.cities.map((city) => createCity(city)),
+ cities: initialCiv.cities.map((city) => ({
+ ...createCity(city),
+ productionQueue: createProductionQueue(PRODUCTION_OPTIONS),
+ })),
resources: initialCiv.resources,
resourceTiles: [],
empireResources: { player: null, bot: null },
@@ -331,6 +347,10 @@ const S = {
tileVariants: [],
};
+for (const city of S.cities) {
+ if (city.owner === 'player') enqueueProduction(city.productionQueue, 'warrior');
+}
+
function resetVisibilityState() {
S.fog = [];
S.tileVariants = [];
@@ -739,6 +759,37 @@ function getShakeOffset(now) {
}
// ─── City production ────────────────────────────────
+function spawnUnitFromProduction(owner, city, item) {
+ const stats = UNIT_STATS[item.unitType] || UNIT_STATS.warrior;
+ const id = S.nextId++;
+ let spawnX = city.x, spawnY = city.y;
+ for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,-1]]) {
+ const nx = city.x + dx, ny = city.y + dy;
+ if (nx >= 0 && nx < MAP_W && ny >= 0 && ny < MAP_H &&
+ !isWaterTerrain(S.map[ny][nx].terrain) &&
+ !S.units.find(u => u.x === nx && u.y === ny)) {
+ spawnX = nx; spawnY = ny; break;
+ }
+ }
+ S.units.push({
+ id,
+ owner,
+ x: spawnX,
+ y: spawnY,
+ hp: stats.hp,
+ atk: stats.atk,
+ def: stats.def,
+ mov: stats.mov,
+ movLeft: stats.mov,
+ type: item.unitType,
+ xp: 0,
+ level: 1,
+ });
+ if (owner === 'player') playAudioEvent(AUDIO_EVENTS.build);
+ log(`${city.name} completed ${item.label}!`, 'build');
+ if (owner === 'player') revealAround(spawnX, spawnY);
+}
+
function gatherResources(owner) {
S.empireResources[owner] = collectEmpireResources({
owner,
@@ -779,41 +830,14 @@ function gatherResources(owner) {
log(`${city.name} grows to pop ${city.pop}!`, 'build');
}
- // Bot auto-recruitment keeps the prototype opponent active; player
- // production is banked for city buildings until #16 adds a queue.
- if (owner === 'bot' && city.prod >= 12) {
- city.prod -= 12;
- const id = S.nextId++;
- let spawnX = city.x, spawnY = city.y;
- // Find empty adjacent tile
- for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,-1]]) {
- const nx = city.x + dx, ny = city.y + dy;
- if (nx >= 0 && nx < MAP_W && ny >= 0 && ny < MAP_H &&
- !isWaterTerrain(S.map[ny][nx].terrain) &&
- !S.units.find(u => u.x === nx && u.y === ny)) {
- spawnX = nx; spawnY = ny; break;
- }
- }
- const type = chooseUnitToTrain(owner);
- const profile = getUnitProfile({ type });
- S.units.push({
- id,
- owner,
- x: spawnX,
- y: spawnY,
- hp: 100,
- atk: profile.strength,
- def: profile.defense,
- mov: 2,
- movLeft: 2,
- type,
- xp: 0,
- level: 1,
- });
- if (owner === 'player') playAudioEvent(AUDIO_EVENTS.build);
- log(`${city.name} trained a ${type}!`, 'build');
- if (owner === 'player') revealAround(spawnX, spawnY);
+ if (owner === 'bot' && city.productionQueue.items.length === 0) {
+ enqueueProduction(city.productionQueue, 'warrior');
}
+
+ const result = progressProductionQueue(city.productionQueue, city.prod);
+ city.productionQueue = result.queue;
+ city.prod = city.productionQueue.progress;
+ result.completed.forEach(item => spawnUnitFromProduction(owner, city, item));
}
S.resources[owner].food += totalFood;
S.resources[owner].prod += totalProd;
@@ -1119,6 +1143,8 @@ function endPlayerTurn() {
updateEmpireHud();
completeResearch('player', res.science);
updateHud();
+ updateCityPanel();
+ updateProductionPanel();
log(`Income: +${res.food} food, +${res.prod} prod, +${res.science} science, +${res.gold} gold`, 'build');
setTimeout(botTurn, 400);
@@ -1135,6 +1161,8 @@ function startPlayerTurn() {
claimTerritory(S.map, S.cities);
updateHud();
updateTechPanel();
+ updateCityPanel();
+ updateProductionPanel();
log(`─── Turn ${S.turn} ───`);
maybeQueueRandomEvent();
renderEventPanel();
@@ -1193,7 +1221,10 @@ function botTurn() {
addCaptureFeedback(cap);
claimTerritory(S.map, S.cities);
updateHud();
+ updateCityPanel();
+ updateProductionPanel();
log(`Bot captured ${cap.name}!`, 'combat');
+ checkWin();
}
continue;
}
@@ -1219,7 +1250,6 @@ function botTurn() {
movLeft -= cost;
steps++;
}
- bot.movLeft = movLeft;
// Capture city
const cap = S.cities.find(c => c.owner === 'player' && c.x === bot.x && c.y === bot.y);
if (cap) {
@@ -1227,7 +1257,10 @@ function botTurn() {
addCaptureFeedback(cap);
claimTerritory(S.map, S.cities);
updateHud();
+ updateCityPanel();
+ updateProductionPanel();
log(`Bot captured ${cap.name}!`, 'combat');
+ checkWin();
}
}
}
@@ -1927,6 +1960,46 @@ function updateUnitPanel() {
}
}
+// ─── Production queue panel ─────────────────────────
+function updateProductionPanel() {
+ if (!dom.prodPanel) return;
+ const city = S.cities.find(c => c.owner === 'player');
+ if (!city) {
+ dom.prodPanel.innerHTML = '
No city remains
';
+ return;
+ }
+
+ const current = city.productionQueue.items[0];
+ const pct = current ? Math.min(100, Math.round((city.productionQueue.progress / current.cost) * 100)) : 0;
+ const queueText = city.productionQueue.items.length > 1
+ ? city.productionQueue.items.slice(1).map(item => item.label).join(' → ')
+ : 'No queued follow-up';
+
+ dom.prodPanel.innerHTML = `
+
+ ${city.name}
+ Pop ${city.pop}
+
+
+ ${current ? current.label : 'Idle'}
+ ${current ? `${city.productionQueue.progress}/${current.cost}` : '0/0'}
+
+
+ Next: ${queueText}
+ Win: capture Babylon or remove all bot pieces.
`;
+}
+
+function queueCityProduction(itemId) {
+ if (S.phase === 'gameover') return;
+ const city = S.cities.find(c => c.owner === 'player');
+ if (!city) return;
+ const item = enqueueProduction(city.productionQueue, itemId).items.at(-1);
+ log(`${city.name} queued ${item.label}`, 'build');
+ updateProductionPanel();
+}
+
// ─── City management panel ──────────────────────────
function updateCityPanel() {
const city = S.cities.find(c => c.owner === 'player');
@@ -2208,6 +2281,7 @@ function handleClick(tx, ty) {
claimTerritory(S.map, S.cities);
updateHud();
updateCityPanel();
+ updateProductionPanel();
playAudioEvent(AUDIO_EVENTS.build);
log(`Captured ${cap.name}!`, 'build');
checkWin();
@@ -2280,6 +2354,8 @@ document.getElementById('btnCenter').addEventListener('click', () => {
centerOn(u.x, u.y);
}
});
+document.getElementById('btnQueueWarrior')?.addEventListener('click', () => queueCityProduction('warrior'));
+document.getElementById('btnQueueScout')?.addEventListener('click', () => queueCityProduction('scout'));
dom.btnStartSetup?.addEventListener('click', () => applyGameSetup());
renderSetupRoster();
dom.cityDet.addEventListener('click', e => {
@@ -2295,6 +2371,7 @@ dom.cityDet.addEventListener('click', e => {
log(`Cannot build ${CITY_BUILDINGS[btn.dataset.building]?.name || 'building'}: ${result.reason}.`, 'combat');
}
updateCityPanel();
+ updateProductionPanel();
});
dom.tradeBtn.addEventListener('click', openTradeRoute);
document.getElementById('btnHelp').addEventListener('click', () => setTutorial());
@@ -2392,5 +2469,7 @@ function gameLoop(now) {
updateEmpireHud();
renderEventPanel();
updateTechPanel();
+ updateCityPanel();
+ updateProductionPanel();
requestAnimationFrame(gameLoop);
})();
diff --git a/demo/civ-lite/index.html b/demo/civ-lite/index.html
index 5c67676..3cafe85 100644
--- a/demo/civ-lite/index.html
+++ b/demo/civ-lite/index.html
@@ -93,6 +93,15 @@ City Management
+
+ City Production
+
+
+
+
+
+
+
Context
diff --git a/demo/civ-lite/production-model.js b/demo/civ-lite/production-model.js
new file mode 100644
index 0000000..7a24449
--- /dev/null
+++ b/demo/civ-lite/production-model.js
@@ -0,0 +1,106 @@
+export const DEFAULT_PRODUCTION_OPTIONS = [
+ { id: 'warrior', label: 'Warrior', cost: 12, unitType: 'warrior' },
+ { id: 'scout', label: 'Scout', cost: 8, unitType: 'scout' },
+];
+
+export function createProductionQueue(options = DEFAULT_PRODUCTION_OPTIONS) {
+ return {
+ options: options.map(option => ({ ...option })),
+ items: [],
+ progress: 0,
+ };
+}
+
+export function enqueueProduction(queue, itemId) {
+ const option = queue.options.find(candidate => candidate.id === itemId);
+ if (!option) throw new Error(`Unknown production item: ${itemId}`);
+ queue.items.push({ ...option });
+ return queue;
+}
+
+export function progressProductionQueue(queue, production) {
+ let available = Math.max(0, production);
+ const completed = [];
+ let nextQueue = {
+ options: queue.options.map(option => ({ ...option })),
+ items: queue.items.map(item => ({ ...item })),
+ progress: queue.progress,
+ };
+
+ while (available > 0 && nextQueue.items.length > 0) {
+ const current = nextQueue.items[0];
+ const remaining = current.cost - nextQueue.progress;
+ if (available < remaining) {
+ nextQueue.progress += available;
+ available = 0;
+ } else {
+ available -= remaining;
+ completed.push(current);
+ nextQueue.items.shift();
+ nextQueue.progress = 0;
+ }
+ }
+
+ return { queue: nextQueue, completed };
+}
+
+export function movementPlanForPath(path, options) {
+ const steps = [];
+ let movementLeft = Math.max(0, options.movementLeft ?? 0);
+ let stopReason = null;
+
+ for (let i = 1; i < path.length; i++) {
+ const tile = path[i];
+ if (options.isBlocked?.(tile)) {
+ stopReason = 'blocked';
+ break;
+ }
+
+ const cost = options.costForTile(tile);
+ if (!Number.isFinite(cost) || cost <= 0) {
+ stopReason = 'impassable';
+ break;
+ }
+
+ if (cost > movementLeft) {
+ stopReason = 'movement';
+ break;
+ }
+
+ steps.push({ ...tile });
+ movementLeft -= cost;
+ }
+
+ return { steps, movementLeft, stopReason };
+}
+
+export function evaluateVictory(state) {
+ const playerPieces = countOwnedPieces(state, 'player');
+ const botPieces = countOwnedPieces(state, 'bot');
+ const playerCities = state.cities.filter(city => city.owner === 'player').length;
+ const botCities = state.cities.filter(city => city.owner === 'bot').length;
+
+ if (botPieces === 0 && playerPieces > 0) {
+ return { phase: 'gameover', winner: 'player', reason: 'conquest' };
+ }
+
+ if (playerPieces === 0 && botPieces > 0) {
+ return { phase: 'gameover', winner: 'bot', reason: 'conquest' };
+ }
+
+ if (botCities === 0 && playerCities > 0) {
+ return { phase: 'gameover', winner: 'player', reason: 'domination' };
+ }
+
+ if (playerCities === 0 && botCities > 0) {
+ return { phase: 'gameover', winner: 'bot', reason: 'domination' };
+ }
+
+ return { phase: 'active', winner: null, reason: null };
+}
+
+function countOwnedPieces(state, owner) {
+ const units = state.units.filter(unit => unit.owner === owner).length;
+ const cities = state.cities.filter(city => city.owner === owner).length;
+ return units + cities;
+}
diff --git a/demo/civ-lite/production-model.test.mjs b/demo/civ-lite/production-model.test.mjs
new file mode 100644
index 0000000..6c4a264
--- /dev/null
+++ b/demo/civ-lite/production-model.test.mjs
@@ -0,0 +1,94 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ createProductionQueue,
+ enqueueProduction,
+ progressProductionQueue,
+ movementPlanForPath,
+ evaluateVictory,
+} from './production-model.js';
+
+test('production queue completes the front item and carries overflow into the next item', () => {
+ const queue = createProductionQueue([
+ { id: 'warrior', label: 'Warrior', cost: 12, unitType: 'warrior' },
+ { id: 'scout', label: 'Scout', cost: 8, unitType: 'scout' },
+ ]);
+
+ const updatedQueue = enqueueProduction(queue, 'warrior');
+ enqueueProduction(updatedQueue, 'scout');
+
+ const result = progressProductionQueue(updatedQueue, 15);
+
+ assert.equal(result.completed.length, 1);
+ assert.equal(result.completed[0].id, 'warrior');
+ assert.equal(result.queue.items[0].id, 'scout');
+ assert.equal(result.queue.progress, 3);
+});
+
+test('movement plan spends terrain costs without entering blocked or unaffordable tiles', () => {
+ const path = [
+ { x: 1, y: 1 },
+ { x: 2, y: 1 },
+ { x: 3, y: 1 },
+ { x: 4, y: 1 },
+ ];
+ const costs = new Map([
+ ['2,1', 1],
+ ['3,1', 2],
+ ['4,1', 1],
+ ]);
+ const occupied = new Set(['4,1']);
+
+ const plan = movementPlanForPath(path, {
+ movementLeft: 3,
+ costForTile: tile => costs.get(`${tile.x},${tile.y}`),
+ isBlocked: tile => occupied.has(`${tile.x},${tile.y}`),
+ });
+
+ assert.deepEqual(plan.steps, [{ x: 2, y: 1 }, { x: 3, y: 1 }]);
+ assert.equal(plan.movementLeft, 0);
+ assert.equal(plan.stopReason, 'blocked');
+});
+
+test('victory evaluation reports conquest and domination states', () => {
+ assert.deepEqual(
+ evaluateVictory({
+ units: [{ owner: 'player' }],
+ cities: [{ owner: 'player' }],
+ }),
+ { phase: 'gameover', winner: 'player', reason: 'conquest' },
+ );
+
+ assert.deepEqual(
+ evaluateVictory({
+ units: [{ owner: 'bot' }],
+ cities: [{ owner: 'bot' }],
+ }),
+ { phase: 'gameover', winner: 'bot', reason: 'conquest' },
+ );
+
+ assert.deepEqual(
+ evaluateVictory({
+ units: [{ owner: 'player' }, { owner: 'bot' }],
+ cities: [{ owner: 'player' }],
+ }),
+ { phase: 'gameover', winner: 'player', reason: 'domination' },
+ );
+
+ assert.deepEqual(
+ evaluateVictory({
+ units: [{ owner: 'player' }, { owner: 'bot' }],
+ cities: [{ owner: 'bot' }],
+ }),
+ { phase: 'gameover', winner: 'bot', reason: 'domination' },
+ );
+
+ assert.deepEqual(
+ evaluateVictory({
+ units: [{ owner: 'player' }, { owner: 'bot' }],
+ cities: [{ owner: 'player' }, { owner: 'bot' }],
+ }),
+ { phase: 'active', winner: null, reason: null },
+ );
+});
diff --git a/demo/civ-lite/styles.css b/demo/civ-lite/styles.css
index 13cdd5c..181dccf 100644
--- a/demo/civ-lite/styles.css
+++ b/demo/civ-lite/styles.css
@@ -333,6 +333,64 @@ main {
.stat-val.hp-mid { color: var(--gold); }
.stat-val.hp-low { color: var(--danger); }
+/* ───── City Production ─────────────────────────── */
+#cityProduction {
+ display: grid;
+ gap: 6px;
+ font-size: 0.76rem;
+}
+
+.city-row,
+.queue-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.city-name,
+.queue-name {
+ font-weight: 700;
+ color: var(--text);
+}
+
+.queue-meta {
+ color: var(--dim);
+ white-space: nowrap;
+}
+
+.queue-track {
+ width: 100%;
+ height: 6px;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: #0b111b;
+}
+
+.queue-fill {
+ height: 100%;
+ width: var(--pct, 0%);
+ background: linear-gradient(90deg, var(--accent), var(--accent2));
+}
+
+.queue-list {
+ color: var(--dim);
+ line-height: 1.35;
+}
+
+.production-actions {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 6px;
+ margin-top: 8px;
+}
+
+.production-actions button {
+ padding: 6px 8px;
+ font-size: 0.76rem;
+}
+
/* ───── Technology ───────────────────────────────── */
#technology {
max-height: 260px;