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
277 changes: 277 additions & 0 deletions demo/civ-lite/civ-model.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
export const TERRAIN_TYPES = [
'ocean',
'coast',
'plains',
'grassland',
'forest',
'jungle',
'hill',
'mountain',
'desert',
'tundra',
'snow',
];

export const TERRAIN_YIELDS = {
ocean: { food: 1, prod: 0, science: 0, gold: 1 },
coast: { food: 2, prod: 0, science: 0, gold: 1 },
plains: { food: 1, prod: 2, science: 0, gold: 0 },
grassland: { food: 2, prod: 1, science: 0, gold: 0 },
forest: { food: 1, prod: 2, science: 0, gold: 0 },
jungle: { food: 2, prod: 1, science: 1, gold: 0 },
hill: { food: 0, prod: 2, science: 1, gold: 0 },
mountain: { food: 0, prod: 1, science: 2, gold: 0 },
desert: { food: 0, prod: 1, science: 0, gold: 1 },
tundra: { food: 1, prod: 1, science: 1, gold: 0 },
snow: { food: 0, prod: 1, science: 0, gold: 0 },
};

export const TERRAIN_DEFENSE = {
ocean: 0,
coast: 0,
plains: 0,
grassland: 0,
forest: 0.25,
jungle: 0.2,
hill: 0.35,
mountain: 0.5,
desert: 0,
tundra: 0.1,
snow: 0.05,
};

export const RESOURCE_TYPES = {
wheat: { label: 'Wheat', icon: 'W', yield: { food: 1, prod: 0, science: 0, gold: 0 } },
cattle: { label: 'Cattle', icon: 'C', yield: { food: 1, prod: 1, science: 0, gold: 0 } },
fish: { label: 'Fish', icon: 'F', yield: { food: 2, prod: 0, science: 0, gold: 0 } },
iron: { label: 'Iron', icon: 'Fe', yield: { food: 0, prod: 2, science: 0, gold: 0 } },
gold: { label: 'Gold', icon: 'Au', yield: { food: 0, prod: 0, science: 0, gold: 2 } },
deer: { label: 'Deer', icon: 'D', yield: { food: 1, prod: 1, science: 0, gold: 0 } },
};

const RESOURCE_BY_TERRAIN = {
ocean: ['fish'],
coast: ['fish'],
plains: ['wheat', 'cattle'],
grassland: ['wheat', 'cattle'],
forest: ['deer'],
jungle: ['deer'],
hill: ['iron', 'gold'],
mountain: ['iron', 'gold'],
desert: ['gold'],
tundra: ['deer', 'iron'],
snow: ['iron'],
};

export const CIV_COLORS = {
player: '#44aaff',
bot: '#ff5555',
};

const DEFAULT_RESOURCES = { food: 0, prod: 0, science: 0, gold: 0 };

function mixSeed(seed, x, y, salt = 0) {
let n = (seed ^ (x * 374761393) ^ (y * 668265263) ^ (salt * 1274126177)) >>> 0;
n = Math.imul(n ^ (n >>> 15), 2246822519) >>> 0;
n = Math.imul(n ^ (n >>> 13), 3266489917) >>> 0;
return (n ^ (n >>> 16)) >>> 0;
}

function rand(seed, x, y, salt = 0) {
return mixSeed(seed, x, y, salt) / 4294967295;
}

function pickTerrain(width, height, x, y, seed) {

Check failure on line 84 in demo/civ-lite/civ-model.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Human-vs-bots&issues=AZ8UnDxn3i2zxRL0fb_F&open=AZ8UnDxn3i2zxRL0fb_F&pullRequest=54
const nx = width <= 1 ? 0 : x / (width - 1);
const ny = height <= 1 ? 0 : y / (height - 1);
const edge = Math.min(nx, ny, 1 - nx, 1 - ny);
const latitude = Math.abs(ny - 0.5) * 2;
const elevation = rand(seed, x, y, 1);
const moisture = rand(seed, x, y, 2);

if (edge < 0.07 || elevation < 0.09) return 'ocean';
if (edge < 0.12 || elevation < 0.16) return 'coast';
if (latitude > 0.88) return elevation > 0.72 ? 'mountain' : 'snow';
if (latitude > 0.72) return moisture > 0.45 ? 'tundra' : 'snow';
if (elevation > 0.88) return 'mountain';
if (elevation > 0.74) return 'hill';
if (moisture < 0.18 && latitude < 0.65) return 'desert';
if (moisture > 0.78 && latitude < 0.55) return 'jungle';
if (moisture > 0.58) return 'forest';
return moisture > 0.36 ? 'grassland' : 'plains';
}

function maybeResource(terrain, seed, x, y) {
const options = RESOURCE_BY_TERRAIN[terrain] || [];
if (options.length === 0) return null;
const chance = rand(seed, x, y, 8);
if (chance > 0.22) return null;
return options[Math.floor(rand(seed, x, y, 9) * options.length)];
}

function makeTile(terrain, seed, x, y) {
const resource = maybeResource(terrain, seed, x, y);
return {
x,
y,
terrain,
resource,
yields: getTileYield({ terrain, resource }),
owner: null,
variant: mixSeed(seed, x, y, 14) % 4,
};
}

function forceTerrainCoverage(map, seed) {
for (let i = 0; i < TERRAIN_TYPES.length; i++) {
const y = Math.min(map.length - 1, Math.floor(i / Math.max(1, map[0].length)));
const x = i % map[0].length;
map[y][x] = makeTile(TERRAIN_TYPES[i], seed, x, y);
}
}

function setTile(map, x, y, terrain, seed) {
if (!map[y] || !map[y][x]) return;

Check warning on line 134 in demo/civ-lite/civ-model.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Human-vs-bots&issues=AZ8UnDxn3i2zxRL0fb_G&open=AZ8UnDxn3i2zxRL0fb_G&pullRequest=54
map[y][x] = makeTile(terrain, seed, x, y);
}

export function isWaterTerrain(terrain) {
return terrain === 'ocean' || terrain === 'coast';
}

export function getTileYield(tile) {
const base = TERRAIN_YIELDS[tile.terrain] || DEFAULT_RESOURCES;
const resource = tile.resource ? RESOURCE_TYPES[tile.resource]?.yield : null;
return {
food: base.food + (resource?.food || 0),
prod: base.prod + (resource?.prod || 0),
science: base.science + (resource?.science || 0),
gold: base.gold + (resource?.gold || 0),
};
}

export function generateCivMap(width = 24, height = 16, seed = 42) {
const map = [];
for (let y = 0; y < height; y++) {
const row = [];
for (let x = 0; x < width; x++) {
row.push(makeTile(pickTerrain(width, height, x, y, seed), seed, x, y));
}
map.push(row);
}

forceTerrainCoverage(map, seed);
for (const [cx, cy] of [[2, 2], [width - 3, height - 3]]) {
setTile(map, cx, cy, 'grassland', seed);
setTile(map, cx + 1, cy, 'plains', seed);
setTile(map, cx, cy + 1, 'forest', seed);
}

return map;
}

export function claimTerritory(map, cities, radius = 2) {
for (const row of map) for (const tile of row) tile.owner = null;
for (const city of cities) {
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const tile = map[city.y + dy]?.[city.x + dx];
if (tile && Math.abs(dx) + Math.abs(dy) <= radius + 1) tile.owner = city.owner;
}
}
}
}

export function createCivState({ width = 24, height = 16, seed = 42 } = {}) {
const map = generateCivMap(width, height, seed);
const cities = [
{ owner: 'player', x: 2, y: 2, name: 'Athens', food: 0, prod: 0, pop: 1, production: 'Warrior' },
{ owner: 'bot', x: width - 3, y: height - 3, name: 'Babylon', food: 0, prod: 0, pop: 1, production: 'Warrior' },
];
claimTerritory(map, cities);

return {
map,
units: [
{ id: 1, owner: 'player', x: 2, y: 2, hp: 100, atk: 30, def: 15, mov: 2, movLeft: 2, type: 'warrior', sight: 2 },
{ id: 2, owner: 'bot', x: width - 3, y: height - 3, hp: 100, atk: 28, def: 18, mov: 2, movLeft: 2, type: 'warrior', sight: 2 },
],
cities,
resources: {
player: { food: 0, prod: 0, science: 0, gold: 0 },
bot: { food: 0, prod: 0, science: 0, gold: 0 },
},
};
}

export function computeVisibility({ width, height, previousFog = [], units = [], cities = [], owner }) {
const fog = Array.from({ length: height }, (_, y) =>
Array.from({ length: width }, (_, x) => (previousFog[y]?.[x] ? 1 : 0))
);

const reveal = (cx, cy, radius) => {
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const nx = cx + dx;
const ny = cy + dy;
if (nx >= 0 && nx < width && ny >= 0 && ny < height && Math.abs(dx) + Math.abs(dy) <= radius + 1) {
fog[ny][nx] = 2;
}
}
}
};

for (const unit of units.filter((u) => u.owner === owner)) reveal(unit.x, unit.y, unit.sight ?? 2);
for (const city of cities.filter((c) => c.owner === owner)) reveal(city.x, city.y, city.sight ?? 2);
return fog;
}

function addYield(total, tile) {
const yields = getTileYield(tile);
total.food += yields.food;
total.prod += yields.prod;
total.science += yields.science;
total.gold += yields.gold;
}

export function summarizeCityYield(state, city) {
const total = { ...DEFAULT_RESOURCES };
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const tile = state.map[city.y + dy]?.[city.x + dx];
if (tile) addYield(total, tile);
}
}
total.food += city.pop;
total.prod += Math.max(0, city.pop - 1);
total.gold += city.owner === 'player' ? 1 : 0;
return total;
}

export function summarizeEmpire(state, owner) {
const totals = { ...(state.resources?.[owner] || DEFAULT_RESOURCES) };
const rates = { ...DEFAULT_RESOURCES };
const ownedCities = state.cities.filter((city) => city.owner === owner);

for (const city of ownedCities) {
const cityYield = summarizeCityYield(state, city);
rates.food += cityYield.food;
rates.prod += cityYield.prod;
rates.science += cityYield.science;
rates.gold += cityYield.gold;
}

return {
totals,
rates,
cityCount: ownedCities.length,
unitCount: state.units.filter((unit) => unit.owner === owner).length,
notifications: [`Turn economy: ${ownedCities.length} cities, ${rates.food} food, ${rates.prod} prod`],
};
}

export function describeTile(tile) {
const terrain = tile.terrain.replace(/^\w/, (c) => c.toUpperCase());
const resource = tile.resource ? ` + ${RESOURCE_TYPES[tile.resource].label}` : '';
return `${terrain}${resource}`;
}
106 changes: 106 additions & 0 deletions demo/civ-lite/civ-model.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';

import {
RESOURCE_TYPES,
TERRAIN_TYPES,
TERRAIN_YIELDS,
computeVisibility,
createCivState,
generateCivMap,
summarizeEmpire,
} from './civ-model.js';
import { TERRAIN_FILES } from './sprites.js';

test('terrain model covers Civ biomes, resource overlays, and yields', () => {
const requiredTerrain = [
'ocean',
'coast',
'plains',
'grassland',
'forest',
'jungle',
'hill',
'mountain',
'desert',
'tundra',
'snow',
];

assert.deepEqual(TERRAIN_TYPES, requiredTerrain);
assert.equal(RESOURCE_TYPES.wheat.yield.food, 1);
assert.equal(RESOURCE_TYPES.gold.yield.gold, 2);

for (const terrain of requiredTerrain) {
assert.ok(TERRAIN_YIELDS[terrain], `${terrain} has yields`);
assert.equal(typeof TERRAIN_YIELDS[terrain].food, 'number');
assert.equal(typeof TERRAIN_YIELDS[terrain].prod, 'number');
assert.equal(typeof TERRAIN_YIELDS[terrain].science, 'number');
assert.equal(typeof TERRAIN_YIELDS[terrain].gold, 'number');
assert.ok(TERRAIN_FILES[terrain]?.length >= 1, `${terrain} has sprite sources`);
}
});

test('generated map has tile objects with plausible biome and resource variety', () => {
const map = generateCivMap(32, 24, 17);
const terrains = new Set(map.flat().map((tile) => tile.terrain));
const resources = map.flat().filter((tile) => tile.resource);

assert.equal(map.length, 24);
assert.equal(map[0].length, 32);
assert.ok(terrains.has('ocean'));
assert.ok(terrains.has('coast'));
assert.ok(terrains.has('grassland'));
assert.ok(terrains.has('jungle'));
assert.ok(terrains.has('tundra'));
assert.ok(resources.length >= 10);
});

test('visibility model keeps hidden, explored, and visible fog states separate', () => {
const previousFog = Array.from({ length: 5 }, () => Array(5).fill(0));
previousFog[0][0] = 2;
const fog = computeVisibility({
width: 5,
height: 5,
previousFog,
units: [{ owner: 'player', x: 2, y: 2, sight: 1 }],
cities: [{ owner: 'player', x: 4, y: 4, sight: 1 }],
owner: 'player',
});

assert.equal(fog[0][0], 1, 'previously visible tiles become explored');
assert.equal(fog[2][2], 2, 'unit tile is visible');
assert.equal(fog[4][4], 2, 'city tile is visible');
assert.equal(fog[0][4], 0, 'unseen far tile remains hidden');
});

test('empire summary feeds the 4X HUD with totals and per-turn rates', () => {
const state = createCivState({ width: 12, height: 8, seed: 3 });
const summary = summarizeEmpire(state, 'player');

assert.ok(summary.totals.food >= 0);
assert.ok(summary.rates.food > 0);
assert.ok(summary.rates.prod > 0);
assert.ok(summary.rates.science >= 0);
assert.ok(summary.rates.gold >= 0);
assert.ok(summary.notifications.some((line) => line.includes('Turn')));
});

test('Civ HUD markup exposes resource rates, minimap, contextual panel, and turn log', () => {
const html = fs.readFileSync(new URL('./index.html', import.meta.url), 'utf8');

for (const id of [
'foodRate',
'prodRate',
'scienceRate',
'gold',
'goldRate',
'contextPanel',
'turnState',
'eventLog',
'minimap',
]) {
assert.match(html, new RegExp(`id="${id}"`));
}
});
Loading
Loading