diff --git a/demo/civ-lite/game.js b/demo/civ-lite/game.js
index 725f3a8..132b191 100644
--- a/demo/civ-lite/game.js
+++ b/demo/civ-lite/game.js
@@ -9,6 +9,7 @@ import {
CIV_COLORS,
RESOURCE_TYPES,
TERRAIN_DEFENSE,
+ TERRAIN_YIELDS as TERRAIN_YIELD,
claimTerritory,
computeVisibility,
createCivState,
@@ -25,6 +26,16 @@ import {
getUnitProfile,
} from './combat-model.js';
import { buildSpriteAtlas } from './sprites.js';
+import {
+ activeTradeYield,
+ advanceTradeRoutes,
+ collectEmpireResources,
+ createTradeRoute,
+ formatYield,
+ generateResourceMap,
+ resourceAt,
+ summarizeResourceList,
+} from './resource-model.js';
import {
applyEventChoice,
buildRandomEvent,
@@ -87,6 +98,10 @@ const dom = {
goldRate: document.getElementById('goldRate'),
currentTech: document.getElementById('currentTech'),
techList: document.getElementById('techList'),
+ strategic: document.getElementById('strategicList'),
+ luxury: document.getElementById('luxuryList'),
+ tradeStatus: document.getElementById('tradeStatus'),
+ tradeBtn: document.getElementById('btnTrade'),
unitDet: document.getElementById('contextPanel') || document.getElementById('unitDetails'),
eventPanel: document.getElementById('eventPanel'),
logBox: document.getElementById('eventLog') || document.getElementById('logContent'),
@@ -230,6 +245,9 @@ const S = {
units: initialCiv.units,
cities: initialCiv.cities,
resources: initialCiv.resources,
+ resourceTiles: [],
+ empireResources: { player: null, bot: null },
+ tradeRoutes: [],
camps: [],
pendingEvent: null,
eventHistory: new Set(),
@@ -259,6 +277,13 @@ const S = {
tileVariants: [],
};
+S.resourceTiles = generateResourceMap({
+ map: S.map,
+ cities: S.cities,
+ width: MAP_W,
+ height: MAP_H,
+});
+
function createUnit(id, owner, x, y, type = 'warrior') {
const stats = UNIT_STATS[type] || UNIT_STATS.warrior;
return { id, owner, x, y, hp: 100, atk: stats.atk, def: stats.def, mov: stats.mov, movLeft: stats.mov, type };
@@ -351,9 +376,78 @@ function refreshVision() {
initializeNeutralCamps();
refreshVision();
+refreshEmpireResourceState();
+updateResourcePanel();
dom.status.textContent = 'Ready';
renderUx();
+// ─── Resource & trade helpers ──────────────────────
+function refreshEmpireResourceState() {
+ S.empireResources.player = collectEmpireResources({
+ owner: 'player',
+ cities: S.cities,
+ map: S.map,
+ resources: S.resourceTiles,
+ terrainYield: TERRAIN_YIELD,
+ });
+ S.empireResources.bot = collectEmpireResources({
+ owner: 'bot',
+ cities: S.cities,
+ map: S.map,
+ resources: S.resourceTiles,
+ terrainYield: TERRAIN_YIELD,
+ });
+}
+
+function updateResourcePanel() {
+ const playerState = S.empireResources.player;
+ if (!playerState) return;
+
+ dom.strategic.textContent = summarizeResourceList(playerState.categories.strategic);
+ dom.luxury.textContent = summarizeResourceList(playerState.categories.luxury);
+
+ const activeRoutes = S.tradeRoutes.filter(route => route.turnsLeft > 0);
+ if (activeRoutes.length) {
+ const route = activeRoutes[0];
+ dom.tradeStatus.textContent = `${route.exportResource} for ${route.importResource}: +${formatYield(route.playerYield)} for ${route.turnsLeft} turns.`;
+ dom.tradeBtn.disabled = true;
+ return;
+ }
+
+ const route = createTradeRoute({
+ playerState: S.empireResources.player,
+ botState: S.empireResources.bot,
+ turn: S.turn,
+ });
+ dom.tradeBtn.disabled = route === null;
+ dom.tradeStatus.textContent = route
+ ? `Available: export ${route.exportResource} for ${route.importResource}.`
+ : 'Need distinct controlled resources for trade.';
+}
+
+function openTradeRoute() {
+ refreshEmpireResourceState();
+ if (S.tradeRoutes.some(route => route.turnsLeft > 0)) {
+ updateResourcePanel();
+ return;
+ }
+
+ const route = createTradeRoute({
+ playerState: S.empireResources.player,
+ botState: S.empireResources.bot,
+ turn: S.turn,
+ });
+ if (!route) {
+ log('No resource trade is available yet.', 'trade');
+ updateResourcePanel();
+ return;
+ }
+
+ S.tradeRoutes.push(route);
+ log(`Trade route opened: ${route.exportResource} for ${route.importResource}.`, 'trade');
+ updateResourcePanel();
+}
+
// ─── Pathfinding (A*) ───────────────────────────────
function heuristic(a, b) { return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); }
@@ -520,8 +614,21 @@ function updateParticles() {
// ─── City production ────────────────────────────────
function gatherResources(owner) {
+ S.empireResources[owner] = collectEmpireResources({
+ owner,
+ cities: S.cities,
+ map: S.map,
+ resources: S.resourceTiles,
+ terrainYield: TERRAIN_YIELD,
+ });
+
const cities = S.cities.filter(c => c.owner === owner);
let totalFood = 0, totalProd = 0, totalSci = 0, totalGold = 0;
+ const tradeYield = activeTradeYield(S.tradeRoutes, owner);
+ totalFood += tradeYield.food;
+ totalProd += tradeYield.prod;
+ totalSci += tradeYield.sci;
+ let firstCity = true;
for (const city of cities) {
const cityYield = summarizeCityYield(S, city);
totalFood += cityYield.food;
@@ -532,6 +639,12 @@ function gatherResources(owner) {
city.food += cityYield.food;
city.prod += cityYield.prod;
+ if (firstCity) {
+ city.food += tradeYield.food;
+ city.prod += tradeYield.prod;
+ firstCity = false;
+ }
+
// City growth
if (city.food >= 8 * city.pop) {
city.pop++;
@@ -578,6 +691,7 @@ function gatherResources(owner) {
S.resources[owner].prod += totalProd;
S.resources[owner].science += totalSci;
S.resources[owner].gold += totalGold;
+ updateResourcePanel();
return { food: totalFood, prod: totalProd, science: totalSci, gold: totalGold };
}
@@ -791,6 +905,12 @@ function startPlayerTurn() {
function botTurn() {
const botRes = gatherResources('bot');
completeResearch('bot', botRes.science);
+ const routesBefore = S.tradeRoutes.length;
+ S.tradeRoutes = advanceTradeRoutes(S.tradeRoutes);
+ if (routesBefore > 0 && S.tradeRoutes.length === 0) {
+ log('Trade route completed.', 'trade');
+ }
+ updateResourcePanel();
const bots = S.units.filter(u => u.owner === 'bot');
for (const bot of bots) {
@@ -1109,6 +1229,39 @@ function drawLayerTerrain() {
}
}
+// Layer 0.5: Strategic and luxury resources
+function drawLayerResources() {
+ for (const resource of S.resourceTiles) {
+ if (S.fog[resource.y][resource.x] === 0) continue;
+ const px = resource.x * TILE + TILE / 2;
+ const py = resource.y * TILE + TILE / 2;
+ const radius = resource.category === 'luxury' ? 7 : 6;
+
+ ctx.fillStyle = 'rgba(0,0,0,0.45)';
+ ctx.beginPath();
+ ctx.arc(px + 1, py + 1, radius + 2, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.fillStyle = resource.marker;
+ ctx.beginPath();
+ if (resource.category === 'luxury') {
+ ctx.moveTo(px, py - radius);
+ ctx.lineTo(px + radius, py);
+ ctx.lineTo(px, py + radius);
+ ctx.lineTo(px - radius, py);
+ ctx.closePath();
+ } else {
+ ctx.arc(px, py, radius, 0, Math.PI * 2);
+ }
+ ctx.fill();
+
+ ctx.fillStyle = '#07111f';
+ ctx.font = `bold ${9 / S.zoom}px system-ui`;
+ ctx.textAlign = 'center';
+ ctx.fillText(resource.name.charAt(0), px, py + 3 / S.zoom);
+ }
+}
+
// Layer 1: Grid overlay
function drawLayerGrid() {
const vw = viewW(), vh = viewH();
@@ -1396,6 +1549,14 @@ function drawMinimap() {
}
}
+ for (const resource of S.resourceTiles) {
+ if (S.fog[resource.y][resource.x] === 0) continue;
+ miniCtx.fillStyle = resource.marker;
+ miniCtx.beginPath();
+ miniCtx.arc(resource.x * tw + tw / 2, resource.y * th + th / 2, Math.max(tw, th) * 0.35, 0, Math.PI * 2);
+ miniCtx.fill();
+ }
+
// Units & cities on minimap
for (const camp of S.camps) {
if (camp.cleared || S.fog[camp.y][camp.x] === 0) continue;
@@ -1490,6 +1651,10 @@ function showTooltip(x, y, tileX, tileY) {
const terrain = tile.terrain;
const yields = getTileYield(tile);
const def = Math.round(TERRAIN_DEFENSE[terrain] * 100);
+ const resource = resourceAt(S.resourceTiles, tileX, tileY);
+ const resourceLine = resource
+ ? `
Context
diff --git a/demo/civ-lite/resource-model.js b/demo/civ-lite/resource-model.js
new file mode 100644
index 0000000..a57e01c
--- /dev/null
+++ b/demo/civ-lite/resource-model.js
@@ -0,0 +1,283 @@
+export const RESOURCE_DEFS = {
+ iron: {
+ key: 'iron',
+ name: 'Iron',
+ category: 'strategic',
+ terrains: ['hill', 'desert'],
+ yield: { food: 0, prod: 2, sci: 0 },
+ marker: '#b7c0c7',
+ },
+ horses: {
+ key: 'horses',
+ name: 'Horses',
+ category: 'strategic',
+ terrains: ['plains', 'forest'],
+ yield: { food: 1, prod: 1, sci: 0 },
+ marker: '#c58b4a',
+ },
+ gems: {
+ key: 'gems',
+ name: 'Gems',
+ category: 'luxury',
+ terrains: ['hill', 'desert'],
+ yield: { food: 0, prod: 0, sci: 2 },
+ marker: '#c084fc',
+ },
+ spices: {
+ key: 'spices',
+ name: 'Spices',
+ category: 'luxury',
+ terrains: ['plains', 'forest', 'desert'],
+ yield: { food: 1, prod: 0, sci: 1 },
+ marker: '#f59e0b',
+ },
+};
+
+const DEFAULT_TARGET_COUNT = 16;
+
+export function generateResourceMap({ map, cities, width, height, targetCount = DEFAULT_TARGET_COUNT }) {
+ const resources = [];
+ const occupied = new Set(cities.map(city => keyFor(city.x, city.y)));
+ const addResource = createResourceAdder({ map, width, height, occupied, resources });
+ placeCapitalRingResources({ map, cities, width, height, occupied, addResource });
+ scatterResources({ map, width, height, occupied, resources, addResource, targetCount });
+ return resources;
+}
+
+export function resourceAt(resources, x, y) {
+ return resources.find(resource => resource.x === x && resource.y === y) ?? null;
+}
+
+export function collectEmpireResources({ owner, cities, map, resources, terrainYield, radius = 1 }) {
+ const controlled = [];
+ const seen = new Set();
+ const cityYields = cities
+ .filter(city => city.owner === owner)
+ .map(city => collectCityResources({ city, map, resources, terrainYield, radius, controlled, seen }));
+ const totals = cityYields.reduce((acc, entry) => addYield(acc, entry.yields), emptyYield());
+
+ return {
+ owner,
+ yields: totals,
+ cityYields,
+ controlled,
+ stock: stockByResource(controlled),
+ categories: {
+ strategic: controlled.filter(resource => resource.category === 'strategic'),
+ luxury: controlled.filter(resource => resource.category === 'luxury'),
+ },
+ };
+}
+
+function createResourceAdder({ map, width, height, occupied, resources }) {
+ return (defKey, x, y, source = 'scatter') => {
+ const def = RESOURCE_DEFS[defKey];
+ if (!def || !canPlaceResource({ map, width, height, occupied, def, x, y })) return false;
+ resources.push(makeResource(def, x, y, resources.length + 1, source));
+ occupied.add(keyFor(x, y));
+ return true;
+ };
+}
+
+function makeResource(def, x, y, index, source) {
+ return {
+ id: `${def.key}-${index}`,
+ key: def.key,
+ name: def.name,
+ category: def.category,
+ x,
+ y,
+ yield: { ...def.yield },
+ marker: def.marker,
+ source,
+ };
+}
+
+function placeCapitalRingResources({ map, cities, width, height, occupied, addResource }) {
+ for (const city of cities) {
+ placeNearCity({
+ map,
+ width,
+ height,
+ city,
+ defKey: city.owner === 'player' ? 'iron' : 'horses',
+ occupied,
+ addResource,
+ });
+ placeNearCity({
+ map,
+ width,
+ height,
+ city,
+ defKey: city.owner === 'player' ? 'gems' : 'spices',
+ occupied,
+ addResource,
+ });
+ }
+}
+
+function scatterResources({ map, width, height, occupied, resources, addResource, targetCount }) {
+ const resourceKeys = Object.keys(RESOURCE_DEFS);
+ for (const candidate of resourceCandidates({ map, width, height, occupied })) {
+ if (resources.length >= targetCount) break;
+ addFirstCompatibleResource({ candidate, resourceKeys, addResource });
+ }
+}
+
+function resourceCandidates({ map, width, height, occupied }) {
+ const candidates = [];
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ if (!isResourceCandidate(map, occupied, x, y)) continue;
+ candidates.push({ x, y, score: hashResource(x, y, width, height) });
+ }
+ }
+ return candidates.sort((a, b) => a.score - b.score);
+}
+
+function addFirstCompatibleResource({ candidate, resourceKeys, addResource }) {
+ const start = candidate.score % resourceKeys.length;
+ for (let i = 0; i < resourceKeys.length; i++) {
+ if (addResource(resourceKeys[(start + i) % resourceKeys.length], candidate.x, candidate.y)) return;
+ }
+}
+
+function isResourceCandidate(map, occupied, x, y) {
+ return !occupied.has(keyFor(x, y)) && map[y][x] !== 'water';
+}
+
+function collectCityResources({ city, map, resources, terrainYield, radius, controlled, seen }) {
+ const yields = emptyYield();
+ forEachCityTile(city, radius, ({ x, y }) => {
+ if (!map[y]?.[x]) return;
+ addYield(yields, terrainYield[map[y][x]] ?? emptyYield());
+ const resource = resourceAt(resources, x, y);
+ if (resource) trackControlledResource({ resource, yields, controlled, seen });
+ });
+ return { city, yields };
+}
+
+function forEachCityTile(city, radius, visit) {
+ for (let dy = -radius; dy <= radius; dy++) {
+ for (let dx = -radius; dx <= radius; dx++) {
+ visit({ x: city.x + dx, y: city.y + dy });
+ }
+ }
+}
+
+function trackControlledResource({ resource, yields, controlled, seen }) {
+ addYield(yields, resource.yield);
+ if (seen.has(resource.id)) return;
+ controlled.push(resource);
+ seen.add(resource.id);
+}
+
+function stockByResource(resources) {
+ const stock = {};
+ for (const resource of resources) {
+ stock[resource.key] = (stock[resource.key] ?? 0) + 1;
+ }
+ return stock;
+}
+
+export function createTradeRoute({ playerState, botState, turn }) {
+ const exportResource = firstTradable(playerState, botState);
+ const importResource = firstTradable(botState, playerState);
+ if (!exportResource || !importResource) return null;
+
+ const exportBonus = exportResource.category === 'luxury' ? { food: 1, prod: 0, sci: 2 } : { food: 0, prod: 2, sci: 0 };
+ const importBonus = importResource.category === 'luxury' ? { food: 1, prod: 0, sci: 2 } : { food: 0, prod: 2, sci: 0 };
+ const playerYield = addYield(addYield(emptyYield(), exportBonus), importBonus);
+
+ return {
+ id: `trade-${turn}-${exportResource.key}-${importResource.key}`,
+ from: 'player',
+ to: 'bot',
+ exportKey: exportResource.key,
+ importKey: importResource.key,
+ exportResource: exportResource.name,
+ importResource: importResource.name,
+ turnsLeft: 3,
+ playerYield,
+ botYield: { food: 1, prod: 1, sci: 1 },
+ };
+}
+
+export function activeTradeYield(routes, owner) {
+ const totals = emptyYield();
+ const field = owner === 'player' ? 'playerYield' : 'botYield';
+ for (const route of routes) {
+ if (route.turnsLeft > 0) addYield(totals, route[field] ?? emptyYield());
+ }
+ return totals;
+}
+
+export function advanceTradeRoutes(routes) {
+ return routes
+ .map(route => ({ ...route, turnsLeft: route.turnsLeft - 1 }))
+ .filter(route => route.turnsLeft > 0);
+}
+
+export function summarizeResourceList(resources) {
+ if (resources.length === 0) return 'None controlled';
+ return resources.map(resource => `${resource.name} +${formatYield(resource.yield)}`).join(', ');
+}
+
+export function formatYield(yieldValue) {
+ const parts = [];
+ if (yieldValue.food) parts.push(`${yieldValue.food}F`);
+ if (yieldValue.prod) parts.push(`${yieldValue.prod}P`);
+ if (yieldValue.sci) parts.push(`${yieldValue.sci}S`);
+ return parts.length ? parts.join(' ') : '0';
+}
+
+function placeNearCity({ map, width, height, city, defKey, occupied, addResource }) {
+ const offsets = [
+ [1, 0], [-1, 0], [0, 1], [0, -1],
+ [1, 1], [-1, -1], [2, 0], [0, 2],
+ [-2, 0], [0, -2], [2, 1], [-1, 2],
+ ];
+ const ranked = offsets
+ .map(([dx, dy], idx) => ({ x: city.x + dx, y: city.y + dy, idx }))
+ .filter(({ x, y }) => x >= 0 && x < width && y >= 0 && y < height && !occupied.has(keyFor(x, y)))
+ .sort((a, b) => {
+ const terrainA = map[a.y][a.x];
+ const terrainB = map[b.y][b.x];
+ const def = RESOURCE_DEFS[defKey];
+ const scoreA = def.terrains.includes(terrainA) ? a.idx : a.idx + 100;
+ const scoreB = def.terrains.includes(terrainB) ? b.idx : b.idx + 100;
+ return scoreA - scoreB;
+ });
+ for (const tile of ranked) {
+ if (addResource(defKey, tile.x, tile.y, 'capital-ring')) return;
+ }
+}
+
+function canPlaceResource({ map, width, height, occupied, def, x, y }) {
+ if (x < 0 || x >= width || y < 0 || y >= height) return false;
+ if (occupied.has(keyFor(x, y))) return false;
+ return def.terrains.includes(map[y][x]);
+}
+
+function firstTradable(primaryState, otherState) {
+ return primaryState.controlled.find(resource => (otherState.stock?.[resource.key] ?? 0) === 0) ?? null;
+}
+
+function addYield(target, source) {
+ target.food += source.food ?? 0;
+ target.prod += source.prod ?? 0;
+ target.sci += source.sci ?? 0;
+ return target;
+}
+
+function emptyYield() {
+ return { food: 0, prod: 0, sci: 0 };
+}
+
+function keyFor(x, y) {
+ return `${x},${y}`;
+}
+
+function hashResource(x, y, width, height) {
+ return ((x + 11) * 73856093 ^ (y + 17) * 19349663 ^ width * 83492791 ^ height * 2654435761) >>> 0;
+}
diff --git a/demo/civ-lite/resource-model.test.mjs b/demo/civ-lite/resource-model.test.mjs
new file mode 100644
index 0000000..b809128
--- /dev/null
+++ b/demo/civ-lite/resource-model.test.mjs
@@ -0,0 +1,110 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ activeTradeYield,
+ advanceTradeRoutes,
+ collectEmpireResources,
+ createTradeRoute,
+ generateResourceMap,
+ resourceAt,
+} from './resource-model.js';
+
+const terrainYield = {
+ plains: { food: 2, prod: 1, sci: 0 },
+ forest: { food: 1, prod: 2, sci: 0 },
+ hill: { food: 0, prod: 2, sci: 1 },
+ water: { food: 3, prod: 0, sci: 0 },
+ desert: { food: 0, prod: 1, sci: 1 },
+};
+
+function makeMap(width = 8, height = 6) {
+ return Array.from({ length: height }, (_, y) =>
+ Array.from({ length: width }, (_, x) => {
+ if (x === 0 || y === 0) return 'water';
+ if ((x + y) % 5 === 0) return 'hill';
+ if ((x + y) % 4 === 0) return 'forest';
+ if ((x + y) % 3 === 0) return 'desert';
+ return 'plains';
+ }),
+ );
+}
+
+test('generateResourceMap places deterministic strategic and luxury resources on valid non-city tiles', () => {
+ const map = makeMap();
+ const cities = [
+ { owner: 'player', x: 2, y: 2, name: 'Athens' },
+ { owner: 'bot', x: 6, y: 4, name: 'Babylon' },
+ ];
+
+ const resourcesA = generateResourceMap({ map, cities, width: 8, height: 6 });
+ const resourcesB = generateResourceMap({ map, cities, width: 8, height: 6 });
+
+ assert.deepEqual(resourcesA, resourcesB);
+ assert.ok(resourcesA.some(resource => resource.category === 'strategic'));
+ assert.ok(resourcesA.some(resource => resource.category === 'luxury'));
+
+ const cityKeys = new Set(cities.map(city => `${city.x},${city.y}`));
+ for (const resource of resourcesA) {
+ assert.notEqual(map[resource.y][resource.x], 'water');
+ assert.equal(cityKeys.has(`${resource.x},${resource.y}`), false);
+ assert.ok(resource.yield.food >= 0);
+ assert.ok(resource.yield.prod >= 0);
+ assert.ok(resource.yield.sci >= 0);
+ }
+});
+
+test('collectEmpireResources adds controlled resource yields and stock counts', () => {
+ const map = makeMap();
+ const cities = [{ owner: 'player', x: 2, y: 2, name: 'Athens' }];
+ const resources = [
+ { id: 'iron-1', key: 'iron', name: 'Iron', category: 'strategic', x: 3, y: 2, yield: { food: 0, prod: 2, sci: 0 } },
+ { id: 'gems-1', key: 'gems', name: 'Gems', category: 'luxury', x: 1, y: 2, yield: { food: 0, prod: 0, sci: 2 } },
+ { id: 'spices-1', key: 'spices', name: 'Spices', category: 'luxury', x: 6, y: 5, yield: { food: 1, prod: 0, sci: 1 } },
+ ];
+
+ const collected = collectEmpireResources({ owner: 'player', cities, map, resources, terrainYield });
+
+ assert.equal(resourceAt(resources, 3, 2).name, 'Iron');
+ assert.equal(collected.controlled.length, 2);
+ assert.equal(collected.stock.iron, 1);
+ assert.equal(collected.stock.gems, 1);
+ assert.equal(collected.stock.spices ?? 0, 0);
+ assert.ok(collected.yields.prod >= 2);
+ assert.ok(collected.yields.sci >= 2);
+ assert.equal(collected.categories.strategic.length, 1);
+ assert.equal(collected.categories.luxury.length, 1);
+});
+
+test('createTradeRoute exchanges distinct controlled resources for recurring yield', () => {
+ const playerState = {
+ controlled: [{ key: 'gems', name: 'Gems', category: 'luxury' }],
+ stock: { gems: 1 },
+ };
+ const botState = {
+ controlled: [{ key: 'iron', name: 'Iron', category: 'strategic' }],
+ stock: { iron: 1 },
+ };
+
+ const route = createTradeRoute({ playerState, botState, turn: 4 });
+
+ assert.equal(route.from, 'player');
+ assert.equal(route.to, 'bot');
+ assert.equal(route.exportResource, 'Gems');
+ assert.equal(route.importResource, 'Iron');
+ assert.equal(route.turnsLeft, 3);
+ assert.deepEqual(activeTradeYield([route], 'player'), { food: 1, prod: 2, sci: 2 });
+});
+
+test('advanceTradeRoutes expires completed trade routes', () => {
+ const routes = [
+ { id: 'a', turnsLeft: 2, playerYield: { food: 1, prod: 1, sci: 0 }, botYield: { food: 0, prod: 1, sci: 1 } },
+ { id: 'b', turnsLeft: 1, playerYield: { food: 0, prod: 0, sci: 2 }, botYield: { food: 1, prod: 0, sci: 0 } },
+ ];
+
+ const next = advanceTradeRoutes(routes);
+
+ assert.deepEqual(next, [
+ { id: 'a', turnsLeft: 1, playerYield: { food: 1, prod: 1, sci: 0 }, botYield: { food: 0, prod: 1, sci: 1 } },
+ ]);
+});
diff --git a/demo/civ-lite/styles.css b/demo/civ-lite/styles.css
index f20b6f8..a64ec13 100644
--- a/demo/civ-lite/styles.css
+++ b/demo/civ-lite/styles.css
@@ -501,6 +501,46 @@ button:active {
#eventLog .log-good { color: var(--accent2); }
#logContent .log-event,
#eventLog .log-event { color: var(--gold); }
+#logContent .log-trade,
+#eventLog .log-trade { color: var(--gold); }
+
+/* ───── Resources & Trade ────────────────────────── */
+.trade-group {
+ display: grid;
+ gap: 3px;
+ padding: 3px 0 6px;
+}
+
+.trade-label {
+ color: var(--dim);
+ font-size: 0.68rem;
+ text-transform: uppercase;
+ letter-spacing: 0.8px;
+}
+
+.trade-list {
+ color: var(--text);
+ font-size: 0.74rem;
+ line-height: 1.35;
+}
+
+#btnTrade {
+ margin-top: 4px;
+ width: 100%;
+}
+
+#btnTrade:disabled {
+ cursor: default;
+ opacity: 0.55;
+ box-shadow: none;
+}
+
+.trade-status {
+ margin-top: 6px;
+ color: var(--dim);
+ font-size: 0.72rem;
+ line-height: 1.35;
+}
/* ───── UX overlays ──────────────────────────────── */
.overlay {
@@ -635,6 +675,12 @@ button:active {
gap: 2px;
}
+#tooltip .tt-resource {
+ margin-top: 4px;
+ color: var(--gold);
+ font-size: 0.7rem;
+}
+
#tooltip .tt-camp {
color: var(--gold);
font-size: 0.7rem;