Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions demo/civ-lite/city-model.js
Original file line number Diff line number Diff line change
@@ -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))),
};
}
94 changes: 94 additions & 0 deletions demo/civ-lite/city-model.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
});
Loading
Loading