diff --git a/demo/civ-lite/game.js b/demo/civ-lite/game.js
index 3faff81..1fcc3f2 100644
--- a/demo/civ-lite/game.js
+++ b/demo/civ-lite/game.js
@@ -24,6 +24,17 @@ import {
getUnitProfile,
} from './combat-model.js';
import { buildSpriteAtlas } from './sprites.js';
+import {
+ TECH_TREE,
+ advanceResearch,
+ chooseBotResearch,
+ createResearchState,
+ getAvailableTechs,
+ getTech,
+ getTechStatus,
+ getUnlockedContent,
+ selectResearch,
+} from './tech-model.js';
import {
createUxState,
describeOutcome,
@@ -38,6 +49,12 @@ const ZOOM_MIN = 0.4, ZOOM_MAX = 3, ZOOM_SPEED = 0.08;
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 },
+};
// ─── DOM refs ───────────────────────────────────────
const canvas = document.getElementById('gameCanvas');
@@ -58,6 +75,8 @@ const dom = {
scienceRate: document.getElementById('scienceRate'),
gold: document.getElementById('gold'),
goldRate: document.getElementById('goldRate'),
+ currentTech: document.getElementById('currentTech'),
+ techList: document.getElementById('techList'),
unitDet: document.getElementById('contextPanel') || document.getElementById('unitDetails'),
logBox: document.getElementById('eventLog') || document.getElementById('logContent'),
menu: document.getElementById('mainMenu'),
@@ -141,6 +160,11 @@ const S = {
camX: 0, camY: 0,
zoom: 1, targetZoom: 1,
nextId: 3,
+ research: {
+ player: selectResearch(createResearchState(), 'mining'),
+ bot: createResearchState(),
+ },
+ botPersonality: 'military',
// Visual state
particles: [],
waterPhase: 0,
@@ -152,6 +176,11 @@ const S = {
tileVariants: [],
};
+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 };
+}
+
// Init fog
for (let y = 0; y < MAP_H; y++) {
S.fog[y] = [];
@@ -385,7 +414,7 @@ function gatherResources(owner) {
spawnX = nx; spawnY = ny; break;
}
}
- const type = owner === 'player' && id % 3 === 0 ? 'archer' : 'warrior';
+ const type = chooseUnitToTrain(owner);
const profile = getUnitProfile({ type });
S.units.push({
id,
@@ -441,6 +470,85 @@ function updateHud() {
}
}
+function chooseUnitToTrain(owner) {
+ const unlocks = getUnlockedContent(S.research[owner]);
+ if (unlocks.units.includes('horseman')) return 'horseman';
+ if (unlocks.units.includes('swordsman')) return 'swordsman';
+ if (unlocks.units.includes('archer')) return 'archer';
+ return 'warrior';
+}
+
+function completeResearch(owner, sciencePerTurn) {
+ if (owner === 'bot' && !S.research.bot.current) {
+ const next = chooseBotResearch(S.research.bot, S.botPersonality);
+ if (next) S.research.bot = selectResearch(S.research.bot, next);
+ }
+
+ const before = S.research[owner].current;
+ const result = advanceResearch(S.research[owner], sciencePerTurn);
+ S.research[owner] = result.state;
+
+ if (result.completed.length > 0) {
+ for (const techId of result.completed) {
+ const tech = getTech(techId);
+ log(`${owner === 'player' ? 'You' : 'Bot'} researched ${tech.name}!`, 'build');
+ }
+ if (owner === 'player') updateTechPanel();
+ } else if (before && owner === 'player') {
+ updateTechPanel();
+ }
+
+ if (owner === 'bot' && !S.research.bot.current) {
+ const next = chooseBotResearch(S.research.bot, S.botPersonality);
+ if (next) S.research.bot = selectResearch(S.research.bot, next);
+ }
+}
+
+function researchProgressLabel(state) {
+ if (!state.current) return 'Choose research';
+ const tech = getTech(state.current);
+ return `${tech.name}: ${state.progress}/${tech.cost} 🔬`;
+}
+
+function updateTechPanel() {
+ if (!dom.currentTech || !dom.techList) return;
+ dom.currentTech.textContent = researchProgressLabel(S.research.player);
+ dom.techList.innerHTML = '';
+
+ for (const tech of TECH_TREE) {
+ const status = getTechStatus(S.research.player, tech.id);
+ const row = document.createElement('button');
+ row.type = 'button';
+ row.className = `tech-card tech-${status}`;
+ row.disabled = status === 'blocked' || status === 'researched';
+ row.dataset.tech = tech.id;
+ row.innerHTML = `
+ ${tech.name}
+ ${status.replace('-', ' ')}
+ 🔬 ${tech.cost}
+ ${formatUnlocks(tech.unlocks)}`;
+ row.addEventListener('click', () => {
+ try {
+ S.research.player = selectResearch(S.research.player, tech.id);
+ log(`Research started: ${tech.name}`, 'build');
+ updateTechPanel();
+ } catch (error) {
+ log(error.message, 'combat');
+ }
+ });
+ dom.techList.append(row);
+ }
+}
+
+function formatUnlocks(unlocks) {
+ const parts = [
+ ...(unlocks.units || []).map((item) => `Unit: ${item}`),
+ ...(unlocks.buildings || []).map((item) => `Building: ${item}`),
+ ...(unlocks.improvements || []).map((item) => `Improvement: ${item}`),
+ ];
+ return parts.join(' · ') || 'Economy';
+}
+
// ─── Turn management ────────────────────────────────
function endPlayerTurn() {
if (S.phase !== 'player') return;
@@ -454,6 +562,7 @@ function endPlayerTurn() {
updateUnitPanel();
const res = gatherResources('player');
+ completeResearch('player', res.science);
updateHud();
log(`Income: +${res.food} food, +${res.prod} prod, +${res.science} science, +${res.gold} gold`, 'build');
@@ -470,12 +579,14 @@ function startPlayerTurn() {
refreshVision();
claimTerritory(S.map, S.cities);
updateHud();
+ updateTechPanel();
log(`─── Turn ${S.turn} ───`);
}
// ─── Bot AI ─────────────────────────────────────────
function botTurn() {
- gatherResources('bot');
+ const botRes = gatherResources('bot');
+ completeResearch('bot', botRes.science);
const bots = S.units.filter(u => u.owner === 'bot');
for (const bot of bots) {
@@ -1384,5 +1495,6 @@ function gameLoop(now) {
updateHud();
updateUnitPanel();
log('Sprites loaded — game starting!', 'good');
+ updateTechPanel();
requestAnimationFrame(gameLoop);
})();
diff --git a/demo/civ-lite/index.html b/demo/civ-lite/index.html
index 1e4edc2..6634c98 100644
--- a/demo/civ-lite/index.html
+++ b/demo/civ-lite/index.html
@@ -40,6 +40,12 @@
Context
+
+ Technology
+ Choose research
+
+
+
diff --git a/demo/civ-lite/styles.css b/demo/civ-lite/styles.css
index c6eb534..1672c7b 100644
--- a/demo/civ-lite/styles.css
+++ b/demo/civ-lite/styles.css
@@ -243,6 +243,80 @@ main {
.stat-val.hp-mid { color: var(--gold); }
.stat-val.hp-low { color: var(--danger); }
+/* ───── Technology ───────────────────────────────── */
+#technology {
+ max-height: 260px;
+ overflow-y: auto;
+}
+
+#currentTech {
+ padding: 7px 8px;
+ margin-bottom: 8px;
+ border: 1px solid rgba(88,166,255,0.24);
+ border-radius: 6px;
+ background: rgba(88,166,255,0.08);
+ color: var(--accent);
+ font-size: 0.76rem;
+ font-weight: 700;
+}
+
+#techList {
+ display: grid;
+ gap: 6px;
+}
+
+.tech-card {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 2px 8px;
+ width: 100%;
+ padding: 7px 8px;
+ text-align: left;
+ border-radius: 6px;
+}
+
+.tech-name {
+ color: var(--text);
+ font-size: 0.76rem;
+ font-weight: 700;
+}
+
+.tech-status,
+.tech-cost,
+.tech-unlocks {
+ color: var(--dim);
+ font-size: 0.64rem;
+ text-transform: capitalize;
+}
+
+.tech-cost {
+ text-align: right;
+ color: var(--gold);
+}
+
+.tech-unlocks {
+ grid-column: 1 / -1;
+ text-transform: none;
+}
+
+.tech-available {
+ border-color: rgba(63,185,80,0.35);
+}
+
+.tech-in-progress {
+ border-color: rgba(210,153,34,0.7);
+ box-shadow: inset 0 0 0 1px rgba(210,153,34,0.22);
+}
+
+.tech-researched {
+ opacity: 0.76;
+ background: linear-gradient(180deg, rgba(45,90,45,0.65), rgba(26,51,26,0.75));
+}
+
+.tech-blocked {
+ opacity: 0.48;
+}
+
/* ───── Actions ──────────────────────────────────── */
#actions {
display: flex;
diff --git a/demo/civ-lite/tech-model.js b/demo/civ-lite/tech-model.js
new file mode 100644
index 0000000..4081a21
--- /dev/null
+++ b/demo/civ-lite/tech-model.js
@@ -0,0 +1,157 @@
+export const TECH_TREE = [
+ {
+ id: 'mining',
+ name: 'Mining',
+ cost: 10,
+ prereqs: [],
+ unlocks: { units: [], buildings: [], improvements: ['mine'] },
+ flavor: 'Reveals productive hills and enables mine improvements.',
+ },
+ {
+ id: 'archery',
+ name: 'Archery',
+ cost: 12,
+ prereqs: [],
+ unlocks: { units: ['archer'], buildings: [], improvements: [] },
+ flavor: 'Unlocks ranged city defense and early skirmishers.',
+ },
+ {
+ id: 'writing',
+ name: 'Writing',
+ cost: 14,
+ prereqs: [],
+ unlocks: { units: [], buildings: ['library'], improvements: [] },
+ flavor: 'Turns science income into a focused research economy.',
+ },
+ {
+ id: 'bronze_working',
+ name: 'Bronze Working',
+ cost: 16,
+ prereqs: ['mining'],
+ unlocks: { units: ['swordsman'], buildings: [], improvements: [] },
+ flavor: 'Unlocks swordsmen for stronger melee assaults.',
+ },
+ {
+ id: 'horseback_riding',
+ name: 'Horseback Riding',
+ cost: 18,
+ prereqs: ['archery'],
+ unlocks: { units: ['horseman'], buildings: [], improvements: ['pasture'] },
+ flavor: 'Unlocks fast cavalry and pasture economies.',
+ },
+ {
+ id: 'engineering',
+ name: 'Engineering',
+ cost: 22,
+ prereqs: ['bronze_working', 'writing'],
+ unlocks: { units: [], buildings: ['walls'], improvements: ['road'] },
+ flavor: 'Adds defensive infrastructure and strategic movement.',
+ },
+];
+
+const TECH_BY_ID = new Map(TECH_TREE.map((tech) => [tech.id, tech]));
+const EMPTY_UNLOCKS = { units: [], buildings: [], improvements: [] };
+
+export function createResearchState(seed = {}) {
+ return {
+ researched: [...(seed.researched || [])],
+ current: seed.current || null,
+ progress: Number(seed.progress || 0),
+ };
+}
+
+export function getTech(id) {
+ const tech = TECH_BY_ID.get(id);
+ if (!tech) throw new Error(`Unknown technology: ${id}`);
+ return tech;
+}
+
+export function isResearched(state, techId) {
+ return state.researched.includes(techId);
+}
+
+export function getTechStatus(state, techId) {
+ const tech = getTech(techId);
+ if (isResearched(state, tech.id)) return 'researched';
+ if (state.current === tech.id) return 'in-progress';
+ const unlocked = tech.prereqs.every((id) => isResearched(state, id));
+ return unlocked ? 'available' : 'blocked';
+}
+
+export function getAvailableTechs(state) {
+ return TECH_TREE.filter((tech) => getTechStatus(state, tech.id) === 'available');
+}
+
+export function selectResearch(state, techId) {
+ const status = getTechStatus(state, techId);
+ if (status !== 'available' && status !== 'in-progress') {
+ throw new Error(`Cannot research ${techId}: ${status}`);
+ }
+ return {
+ ...state,
+ current: techId,
+ progress: state.current === techId ? state.progress : 0,
+ };
+}
+
+export function advanceResearch(state, sciencePerTurn) {
+ if (!state.current) {
+ return { state: createResearchState(state), completed: [] };
+ }
+
+ const tech = getTech(state.current);
+ const total = state.progress + Math.max(0, Number(sciencePerTurn || 0));
+ if (total < tech.cost) {
+ return {
+ state: { ...state, progress: total },
+ completed: [],
+ };
+ }
+
+ const researched = Array.from(new Set([...state.researched, tech.id]));
+ return {
+ state: {
+ researched,
+ current: null,
+ progress: total - tech.cost,
+ },
+ completed: [tech.id],
+ };
+}
+
+export function getUnlockedContent(state) {
+ const unlocks = {
+ units: ['warrior'],
+ buildings: [],
+ improvements: [],
+ };
+
+ for (const techId of state.researched) {
+ const tech = getTech(techId);
+ mergeUnlocks(unlocks, tech.unlocks || EMPTY_UNLOCKS);
+ }
+
+ return unlocks;
+}
+
+export function chooseBotResearch(state, personality = 'balanced') {
+ const available = getAvailableTechs(state);
+ if (available.length === 0) return null;
+
+ const preferences = {
+ military: ['archery', 'bronze_working', 'horseback_riding', 'engineering', 'mining', 'writing'],
+ science: ['writing', 'mining', 'engineering', 'archery', 'bronze_working', 'horseback_riding'],
+ expansion: ['mining', 'horseback_riding', 'writing', 'archery', 'bronze_working', 'engineering'],
+ balanced: ['mining', 'writing', 'archery', 'bronze_working', 'horseback_riding', 'engineering'],
+ };
+ const order = preferences[personality] || preferences.balanced;
+ return [...available].sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id))[0].id;
+}
+
+function mergeUnlocks(target, incoming) {
+ for (const key of Object.keys(target)) {
+ for (const value of incoming[key] || []) {
+ if (!target[key].includes(value)) target[key].push(value);
+ }
+ }
+}
diff --git a/demo/civ-lite/tech-model.test.mjs b/demo/civ-lite/tech-model.test.mjs
new file mode 100644
index 0000000..7096f63
--- /dev/null
+++ b/demo/civ-lite/tech-model.test.mjs
@@ -0,0 +1,80 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { test } from 'node:test';
+
+import {
+ TECH_TREE,
+ advanceResearch,
+ chooseBotResearch,
+ createResearchState,
+ getAvailableTechs,
+ getTechStatus,
+ getUnlockedContent,
+ selectResearch,
+} from './tech-model.js';
+
+test('tech tree defines prerequisites, costs, and concrete gameplay unlocks', () => {
+ assert.ok(TECH_TREE.length >= 6);
+
+ const mining = TECH_TREE.find((tech) => tech.id === 'mining');
+ const bronze = TECH_TREE.find((tech) => tech.id === 'bronze_working');
+ const writing = TECH_TREE.find((tech) => tech.id === 'writing');
+
+ assert.equal(mining.cost, 10);
+ assert.deepEqual(mining.prereqs, []);
+ assert.ok(mining.unlocks.improvements.includes('mine'));
+ assert.deepEqual(bronze.prereqs, ['mining']);
+ assert.ok(bronze.unlocks.units.includes('swordsman'));
+ assert.ok(writing.unlocks.buildings.includes('library'));
+});
+
+test('research state exposes available, blocked, in-progress, and researched statuses', () => {
+ let state = createResearchState();
+
+ assert.deepEqual(getAvailableTechs(state).map((tech) => tech.id).sort(), ['archery', 'mining', 'writing']);
+ assert.equal(getTechStatus(state, 'bronze_working'), 'blocked');
+
+ state = selectResearch(state, 'mining');
+ assert.equal(getTechStatus(state, 'mining'), 'in-progress');
+
+ const result = advanceResearch(state, 10);
+ assert.deepEqual(result.completed, ['mining']);
+ assert.equal(getTechStatus(result.state, 'mining'), 'researched');
+ assert.equal(getTechStatus(result.state, 'bronze_working'), 'available');
+});
+
+test('research progress accumulates science per turn without losing overflow', () => {
+ let state = selectResearch(createResearchState(), 'archery');
+
+ let result = advanceResearch(state, 4);
+ assert.equal(result.state.current, 'archery');
+ assert.equal(result.state.progress, 4);
+ assert.deepEqual(result.completed, []);
+
+ result = advanceResearch(result.state, 9);
+ assert.deepEqual(result.completed, ['archery']);
+ assert.equal(result.state.current, null);
+ assert.equal(result.state.progress, 1);
+});
+
+test('unlocks and bot choice support the opponent research path', () => {
+ let player = selectResearch(createResearchState(), 'mining');
+ player = advanceResearch(player, 10).state;
+ player = selectResearch(player, 'bronze_working');
+ player = advanceResearch(player, 16).state;
+
+ const unlocks = getUnlockedContent(player);
+ assert.ok(unlocks.units.includes('swordsman'));
+ assert.ok(unlocks.improvements.includes('mine'));
+
+ const botChoice = chooseBotResearch(createResearchState(), 'military');
+ assert.equal(botChoice, 'archery');
+});
+
+test('Civ Lite markup includes a technology panel for selectable research', async () => {
+ const html = await readFile(new URL('./index.html', import.meta.url), 'utf8');
+
+ assert.match(html, /id="technology"/);
+ assert.match(html, /id="currentTech"/);
+ assert.match(html, /id="techList"/);
+});