diff --git a/demo/civ-lite/city-model.js b/demo/civ-lite/city-model.js new file mode 100644 index 0000000..eb51217 --- /dev/null +++ b/demo/civ-lite/city-model.js @@ -0,0 +1,148 @@ +export const CITY_BUILDINGS = { + granary: { + name: 'Granary', + cost: 12, + yields: { food: 2, prod: 0, sci: 0 }, + amenities: 0, + defense: 0, + }, + library: { + name: 'Library', + cost: 16, + yields: { food: 0, prod: 0, sci: 2 }, + amenities: 0, + defense: 0, + }, + walls: { + name: 'Walls', + cost: 14, + yields: { food: 0, prod: 1, sci: 0 }, + amenities: 0, + defense: 15, + }, + market: { + name: 'Market', + cost: 14, + yields: { food: 0, prod: 0, sci: 0 }, + amenities: 1, + defense: 0, + }, +}; + +export function createCity(city) { + const { buildings = [], ...rest } = city; + return { + food: 0, + prod: 0, + pop: 1, + amenities: 0, + ...rest, + buildings: [...buildings], + }; +} + +export function calculateCityEconomy(city, map, terrainYield, empireCityCount = 1) { + const normalized = createCity(city); + const workedTiles = getWorkedTiles(normalized, map, terrainYield); + const baseYields = sumYields(workedTiles.map(tile => tile.yields)); + const buildingYields = getBuildingYields(normalized); + const rawYields = addYields(baseYields, buildingYields); + const happiness = calculateHappiness(normalized, empireCityCount); + const penalty = happiness < 0 ? Math.min(0.3, Math.abs(happiness) * 0.1) : 0; + return { + workedTiles, + baseYields, + buildingYields, + totalYields: applyPenalty(rawYields, penalty), + happiness, + foodRequired: getFoodRequired(normalized), + defenseBonus: normalized.buildings.reduce((sum, key) => sum + (CITY_BUILDINGS[key]?.defense || 0), 0), + }; +} + +export function progressCityTurn(city, economy) { + const next = createCity(city); + next.food += economy.totalYields.food; + next.prod += economy.totalYields.prod; + if (next.food >= economy.foodRequired) { + next.pop += 1; + next.food = 0; + } + return next; +} + +export function buildBuilding(city, key) { + const building = CITY_BUILDINGS[key]; + const next = createCity(city); + if (!building) return { ok: false, reason: 'unknown-building', city: next }; + if (next.buildings.includes(key)) return { ok: false, reason: 'already-built', city: next }; + if (next.prod < building.cost) return { ok: false, reason: 'not-enough-production', city: next }; + next.prod -= building.cost; + next.buildings.push(key); + return { ok: true, city: next }; +} + +export function getFoodRequired(city) { + return 8 * Math.max(1, city.pop); +} + +function getWorkedTiles(city, map, terrainYield) { + const center = getTile(city.x, city.y, map, terrainYield); + if (!center) return []; + const candidates = []; + for (let y = city.y - 1; y <= city.y + 1; y++) { + for (let x = city.x - 1; x <= city.x + 1; x++) { + if (x === city.x && y === city.y) continue; + const tile = getTile(x, y, map, terrainYield); + if (tile && tile.terrain !== 'water') candidates.push(tile); + } + } + candidates.sort((a, b) => tileScore(b) - tileScore(a)); + return [center, ...candidates.slice(0, Math.max(0, city.pop))]; +} + +function getTile(x, y, map, terrainYield) { + if (!map[y]?.[x]) return null; + const terrain = map[y][x]; + return { x, y, terrain, yields: { ...terrainYield[terrain] } }; +} + +function tileScore(tile) { + return tile.yields.prod * 2 + tile.yields.food + tile.yields.sci; +} + +function sumYields(rows) { + return rows.reduce((sum, yields) => addYields(sum, yields), { food: 0, prod: 0, sci: 0 }); +} + +function addYields(a, b) { + return { + food: a.food + b.food, + prod: a.prod + b.prod, + sci: a.sci + b.sci, + }; +} + +function getBuildingYields(city) { + return city.buildings.reduce((sum, key) => addYields(sum, CITY_BUILDINGS[key]?.yields || { food: 0, prod: 0, sci: 0 }), { + food: 0, + prod: 0, + sci: 0, + }); +} + +function calculateHappiness(city, empireCityCount) { + const buildingAmenities = city.buildings.reduce((sum, key) => sum + (CITY_BUILDINGS[key]?.amenities || 0), 0); + const populationPressure = Math.max(0, city.pop - 3); + const expansionPressure = Math.max(0, empireCityCount - 2); + return 3 + city.amenities + buildingAmenities - populationPressure - expansionPressure; +} + +function applyPenalty(yields, penalty) { + if (penalty <= 0) return yields; + return { + food: Math.max(0, Math.floor(yields.food * (1 - penalty))), + prod: Math.max(0, Math.floor(yields.prod * (1 - penalty))), + sci: Math.max(0, Math.floor(yields.sci * (1 - penalty))), + }; +} diff --git a/demo/civ-lite/city-model.test.mjs b/demo/civ-lite/city-model.test.mjs new file mode 100644 index 0000000..ab78c98 --- /dev/null +++ b/demo/civ-lite/city-model.test.mjs @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + buildBuilding, + calculateCityEconomy, + CITY_BUILDINGS, + createCity, + progressCityTurn, +} from './city-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 }, +}; + +const map = [ + ['plains', 'forest', 'hill'], + ['plains', 'plains', 'desert'], + ['water', 'forest', 'hill'], +]; + +test('calculateCityEconomy works the city center plus best population tiles', () => { + const city = createCity({ owner: 'player', x: 1, y: 1, name: 'Athens', pop: 2 }); + const economy = calculateCityEconomy(city, map, terrainYield, 1); + + assert.equal(economy.workedTiles.length, 3); + assert.deepEqual(economy.baseYields, { food: 3, prod: 5, sci: 1 }); + assert.deepEqual(economy.totalYields, { food: 3, prod: 5, sci: 1 }); +}); + +test('building effects improve city yields and defense', () => { + const city = createCity({ + owner: 'player', + x: 1, + y: 1, + name: 'Athens', + pop: 1, + buildings: ['granary', 'library', 'walls'], + }); + const economy = calculateCityEconomy(city, map, terrainYield, 1); + + assert.deepEqual(economy.buildingYields, { food: 2, prod: 1, sci: 2 }); + assert.equal(economy.defenseBonus, CITY_BUILDINGS.walls.defense); + assert.deepEqual(economy.totalYields, { food: 5, prod: 4, sci: 2 }); +}); + +test('happiness accounts for population pressure, expansion, and amenities', () => { + const strained = createCity({ + owner: 'player', + x: 1, + y: 1, + name: 'Athens', + pop: 6, + amenities: 0, + }); + const supported = createCity({ + owner: 'player', + x: 1, + y: 1, + name: 'Athens', + pop: 6, + amenities: 2, + buildings: ['market'], + }); + + assert.equal(calculateCityEconomy(strained, map, terrainYield, 4).happiness, -2); + assert.equal(calculateCityEconomy(supported, map, terrainYield, 4).happiness, 1); +}); + +test('progressCityTurn accumulates food and grows when food reaches threshold', () => { + const city = createCity({ owner: 'player', x: 1, y: 1, name: 'Athens', pop: 1, food: 5, prod: 0 }); + const economy = calculateCityEconomy(city, map, terrainYield, 1); + const next = progressCityTurn(city, economy); + + assert.equal(next.pop, 2); + assert.equal(next.food, 0); + assert.equal(next.prod, 3); +}); + +test('buildBuilding spends production and prevents duplicate buildings', () => { + const city = createCity({ owner: 'player', x: 1, y: 1, name: 'Athens', prod: 12 }); + const built = buildBuilding(city, 'granary'); + const duplicate = buildBuilding(built.city, 'granary'); + + assert.equal(built.ok, true); + assert.deepEqual(built.city.buildings, ['granary']); + assert.equal(built.city.prod, 0); + assert.equal(duplicate.ok, false); + assert.equal(duplicate.reason, 'already-built'); +}); diff --git a/demo/civ-lite/game.js b/demo/civ-lite/game.js index 132b191..cb3f953 100644 --- a/demo/civ-lite/game.js +++ b/demo/civ-lite/game.js @@ -26,6 +26,13 @@ import { getUnitProfile, } from './combat-model.js'; import { buildSpriteAtlas } from './sprites.js'; +import { + buildBuilding, + calculateCityEconomy, + CITY_BUILDINGS, + createCity, + progressCityTurn, +} from './city-model.js'; import { activeTradeYield, advanceTradeRoutes, @@ -93,6 +100,7 @@ const dom = { prod: document.getElementById('prod'), prodRate: document.getElementById('prodRate'), science: document.getElementById('science'), + cityDet: document.getElementById('cityDetails'), scienceRate: document.getElementById('scienceRate'), gold: document.getElementById('gold'), goldRate: document.getElementById('goldRate'), @@ -243,7 +251,7 @@ const initialCiv = createCivState({ width: MAP_W, height: MAP_H, seed: 17 }); const S = { map: initialCiv.map, units: initialCiv.units, - cities: initialCiv.cities, + cities: initialCiv.cities.map((city) => createCity(city)), resources: initialCiv.resources, resourceTiles: [], empireResources: { player: null, bot: null }, @@ -378,6 +386,7 @@ initializeNeutralCamps(); refreshVision(); refreshEmpireResourceState(); updateResourcePanel(); +updateCityPanel(); dom.status.textContent = 'Ready'; renderUx(); @@ -652,8 +661,10 @@ function gatherResources(owner) { if (owner === 'player') playAudioEvent(AUDIO_EVENTS.build); log(`${city.name} grows to pop ${city.pop}!`, 'build'); } - // Auto-recruit - if (city.prod >= 12) { + + // 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; @@ -692,6 +703,7 @@ function gatherResources(owner) { S.resources[owner].science += totalSci; S.resources[owner].gold += totalGold; updateResourcePanel(); + updateCityPanel(); return { food: totalFood, prod: totalProd, science: totalSci, gold: totalGold }; } @@ -1348,6 +1360,14 @@ function drawLayerCities() { ctx.fillStyle = '#ffd700'; ctx.font = `bold ${8 / S.zoom}px system-ui`; ctx.fillText(city.pop, px + 18, py - 15); + + if (city.buildings?.length) { + ctx.fillStyle = '#1b1f2a'; + ctx.fillRect(px - 20, py + 32, 40, 10); + ctx.fillStyle = city.owner === 'player' ? '#3fb950' : '#d29922'; + ctx.font = `bold ${7 / S.zoom}px system-ui`; + ctx.fillText(`${city.buildings.length} bld`, px, py + 40); + } } } @@ -1643,6 +1663,60 @@ function updateUnitPanel() { } } +// ─── City management panel ────────────────────────── +function updateCityPanel() { + const city = S.cities.find(c => c.owner === 'player'); + if (!city) { + dom.cityDet.innerHTML = '
No player city remains
'; + return; + } + const playerCityCount = S.cities.filter(c => c.owner === 'player').length; + const economy = calculateCityEconomy(city, S.map, TERRAIN_YIELD, playerCityCount); + let happinessClass = 'happy-bad'; + if (economy.happiness >= 1) { + happinessClass = 'happy-good'; + } else if (economy.happiness === 0) { + happinessClass = 'happy-neutral'; + } + const built = city.buildings.length ? city.buildings.map(key => CITY_BUILDINGS[key]?.name || key).join(', ') : 'None'; + const workedTiles = economy.workedTiles + .map(tile => `${tile.terrain} (${tile.yields.food}/${tile.yields.prod}/${tile.yields.sci})`) + .join(', '); + const buttons = Object.entries(CITY_BUILDINGS).map(([key, building]) => { + const isBuilt = city.buildings.includes(key); + const affordable = city.prod >= building.cost; + const disabled = isBuilt || !affordable ? ' disabled' : ''; + const label = isBuilt ? `${building.name} built` : `Build ${building.name} (${building.cost})`; + const hint = formatBuildingHint(building); + return ``; + }).join(''); + + dom.cityDet.innerHTML = ` +Find resources near cities to trade.
+City details loading
+