empire-cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,47 +1,17 @@
1
1
  # ⚔️ Empire CLI 👑
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/empire-cli)](https://www.npmjs.com/package/empire-cli)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
3
6
  > **Status: Early MVP** — Core gameplay works. AI game master (Gemini/Ollama/Claude) coming soon.
4
7
 
5
8
  A CLI turn-based strategy RPG where you build armies, expand your empire, and conquer the world. Open source, runs in any terminal.
6
9
 
7
- ## Screenshot
10
+ ## Screenshots
8
11
 
9
- ```
10
- ╔════════════════════════════╗
11
- ║ ⚔️ E M P I R E CLI 👑 ║
12
- ╚════════════════════════════╝
13
-
14
- 👑 Iron Legion — Turn 1
15
- 💰 20 🍖 10 🪵 5 🪨 15
16
- ⚔️ Armies: 5 | 🚩 Territories: 2/8
17
-
18
- World Map:
19
- 🏰 Northkeep Iron Legion ⚔️ 3 ★
20
- ↔ Iron Hills, Greenwood
21
- ⛰️ Iron Hills Iron Legion ⚔️ 2 ★
22
- ↔ Northkeep, Crossroads
23
- 🌲 Greenwood Green Pact ⚔️ 2
24
- ↔ Northkeep, Crossroads, Silver Bay
25
- 🌾 Crossroads Unclaimed
26
- ↔ Iron Hills, Greenwood, Desert Gate, Stonehaven
27
- 🏰 Silver Bay Green Pact ⚔️ 3
28
- ↔ Greenwood, Stonehaven
29
- ⛰️ Dragon Peak Void Covenant ⚔️ 4
30
- ↔ Stonehaven
31
-
32
- Turn 1 [3/3 actions] > recruit northkeep 2
33
- 🛡️ Recruited 2 units in Northkeep. (💰-6 🍖-4)
34
-
35
- Turn 1 [2/3 actions] > attack iron crossroads
36
- 🔥 Battle: Iron Hills (5) → Crossroads (0)
37
- Decisive victory! The defenders are routed!
38
- 🚩 You captured Crossroads!
39
-
40
- --- End of Turn 1 ---
41
- 🔥 Enemy Actions:
42
- Green Pact recruited 3 units in Greenwood
43
- Sand Empire attacks Stonehaven from Desert Gate!
44
- ```
12
+ ![Menu](assets/sc-menu.png)
13
+
14
+ ![Gameplay](assets/sc-gameplay.png)
45
15
 
46
16
  ## Quick Start
47
17
 
@@ -64,8 +34,9 @@ npm start
64
34
  3. "info northkeep" — inspect a territory
65
35
  4. "recruit northkeep 3" — train 3 units (costs 3💰 + 2🍖 each)
66
36
  5. "move northkeep greenwood 3" — march 3 units to Greenwood
67
- 6. "attack greenwood silver" — attack Silver Bay from Greenwood
68
- 7. "next" — end your turn (or use all 3 actions, auto-advances)
37
+ 6. "build northkeep walls" — build walls for defense (costs 🪵🪨)
38
+ 7. "attack greenwood silver" — attack Silver Bay from Greenwood
39
+ 8. "next" — end your turn (or use all 3 actions, auto-advances)
69
40
  8. Watch enemy factions react — then plan your next move!
70
41
  ```
71
42
 
@@ -83,6 +54,7 @@ You get **3 actions per turn**. `look`, `info`, `status`, `help`, `save` are fre
83
54
  | `move <from> <to> [n]` | Move n units between territories (all if omitted) |
84
55
  | `recruit <territory> <n>` | Recruit n units at a territory |
85
56
  | `attack <from> <to>` | Attack enemy territory from yours |
57
+ | `build <territory> <type>` | Build walls/barracks/market |
86
58
  | `next` | End turn early |
87
59
  | `save [slot]` | Save game |
88
60
  | `help` | Show commands |
@@ -101,8 +73,16 @@ You get **3 actions per turn**. `look`, `info`, `status`, `help`, `save` are fre
101
73
 
102
74
  - 💰 **Gold** — Recruit armies (3 per unit)
103
75
  - 🍖 **Food** — Recruit + army upkeep (2 per unit)
104
- - 🪵 **Wood** — Future: buildings
105
- - 🪨 **Stone** — Future: fortifications
76
+ - 🪵 **Wood** — Build structures
77
+ - 🪨 **Stone** — Build structures
78
+
79
+ ## Buildings
80
+
81
+ | Building | Cost | Effect |
82
+ |----------|------|--------|
83
+ | 🧱 Walls | 10🪵 15🪨 | +0.3 defense bonus |
84
+ | 🏛️ Barracks | 8🪵 5🪨 | Recruit costs 2💰 instead of 3💰 |
85
+ | 🏪 Market | 10💰 5🪵 3🪨 | +2💰 income per turn |
106
86
 
107
87
  ## Roadmap
108
88
 
@@ -114,9 +94,9 @@ You get **3 actions per turn**. `look`, `info`, `status`, `help`, `save` are fre
114
94
  - [x] Action limit per turn (3 actions)
115
95
  - [ ] AI Game Master (Gemini free tier / Ollama / Claude) — dynamic narration
116
96
  - [ ] Diplomacy system (alliances, trade, peace)
117
- - [ ] Buildings (walls, barracks, markets)
97
+ - [x] Buildings (walls, barracks, markets)
118
98
  - [ ] More maps & factions
119
- - [ ] npm package (`npx empire-cli`)
99
+ - [x] npm package (`npx empire-cli`) [![npm](https://img.shields.io/npm/v/empire-cli)](https://www.npmjs.com/package/empire-cli)
120
100
 
121
101
  ## Tech Stack
122
102
 
Binary file
Binary file
@@ -16,6 +16,7 @@ export const DEFAULT_TERRITORIES = [
16
16
  armies: 3,
17
17
  resources: { gold: 4, food: 2, wood: 1, stone: 2 },
18
18
  adjacentTo: ['iron_hills', 'greenwood'],
19
+ buildings: [],
19
20
  },
20
21
  {
21
22
  id: 'iron_hills',
@@ -25,6 +26,7 @@ export const DEFAULT_TERRITORIES = [
25
26
  armies: 2,
26
27
  resources: { gold: 2, food: 1, wood: 0, stone: 4 },
27
28
  adjacentTo: ['northkeep', 'crossroads'],
29
+ buildings: [],
28
30
  },
29
31
  {
30
32
  id: 'greenwood',
@@ -34,6 +36,7 @@ export const DEFAULT_TERRITORIES = [
34
36
  armies: 2,
35
37
  resources: { gold: 1, food: 3, wood: 4, stone: 0 },
36
38
  adjacentTo: ['northkeep', 'crossroads', 'silver_bay'],
39
+ buildings: [],
37
40
  },
38
41
  {
39
42
  id: 'crossroads',
@@ -43,6 +46,7 @@ export const DEFAULT_TERRITORIES = [
43
46
  armies: 0,
44
47
  resources: { gold: 2, food: 2, wood: 1, stone: 1 },
45
48
  adjacentTo: ['iron_hills', 'greenwood', 'desert_gate', 'stonehaven'],
49
+ buildings: [],
46
50
  },
47
51
  {
48
52
  id: 'desert_gate',
@@ -52,6 +56,7 @@ export const DEFAULT_TERRITORIES = [
52
56
  armies: 2,
53
57
  resources: { gold: 3, food: 1, wood: 0, stone: 2 },
54
58
  adjacentTo: ['crossroads'],
59
+ buildings: [],
55
60
  },
56
61
  {
57
62
  id: 'silver_bay',
@@ -61,6 +66,7 @@ export const DEFAULT_TERRITORIES = [
61
66
  armies: 3,
62
67
  resources: { gold: 5, food: 2, wood: 1, stone: 1 },
63
68
  adjacentTo: ['greenwood', 'stonehaven'],
69
+ buildings: [],
64
70
  },
65
71
  {
66
72
  id: 'stonehaven',
@@ -70,6 +76,7 @@ export const DEFAULT_TERRITORIES = [
70
76
  armies: 2,
71
77
  resources: { gold: 2, food: 1, wood: 0, stone: 5 },
72
78
  adjacentTo: ['crossroads', 'silver_bay', 'dragon_peak'],
79
+ buildings: [],
73
80
  },
74
81
  {
75
82
  id: 'dragon_peak',
@@ -79,6 +86,7 @@ export const DEFAULT_TERRITORIES = [
79
86
  armies: 4,
80
87
  resources: { gold: 1, food: 0, wood: 0, stone: 6 },
81
88
  adjacentTo: ['stonehaven'],
89
+ buildings: [],
82
90
  },
83
91
  ];
84
92
  export const DEFAULT_FACTIONS = [
@@ -45,7 +45,7 @@ export function runAiTurns(state) {
45
45
  continue;
46
46
  if (attackerUnits < defenderUnits && faction.personality !== 'aggressive')
47
47
  continue;
48
- const result = resolveCombat(attackerUnits, 80, defenderUnits, 80, adj.type);
48
+ const result = resolveCombat(attackerUnits, 80, defenderUnits, 80, adj.type, adj);
49
49
  log.push(`${faction.name} attacks ${adj.name} from ${territory.name}!`);
50
50
  const attackerArmy = findArmyInTerritory(faction.id, territoryId, state.armies);
51
51
  if (attackerArmy)
@@ -1,6 +1,7 @@
1
1
  // Army recruitment, movement, and validation logic
2
2
  import { canAfford, deductResources } from './resource-calculator.js';
3
- const RECRUIT_COST_PER_UNIT = { gold: 3, food: 2, wood: 0, stone: 0 };
3
+ import { getRecruitGoldCost } from './building-manager.js';
4
+ const RECRUIT_FOOD_PER_UNIT = 2;
4
5
  const DEFAULT_MORALE = 80;
5
6
  let armyIdCounter = 1;
6
7
  function generateArmyId() {
@@ -17,9 +18,10 @@ export function recruitArmy(faction, territory, units, armies) {
17
18
  if (units < 1) {
18
19
  return 'Must recruit at least 1 unit.';
19
20
  }
21
+ const goldPerUnit = getRecruitGoldCost(territory);
20
22
  const totalCost = {
21
- gold: RECRUIT_COST_PER_UNIT.gold * units,
22
- food: RECRUIT_COST_PER_UNIT.food * units,
23
+ gold: goldPerUnit * units,
24
+ food: RECRUIT_FOOD_PER_UNIT * units,
23
25
  };
24
26
  if (!canAfford(faction, totalCost)) {
25
27
  return `Not enough resources. Need ${totalCost.gold} gold and ${totalCost.food} food.`;
@@ -0,0 +1,25 @@
1
+ import type { BuildingType, Faction, Territory, Resources } from '../game-types.js';
2
+ export interface BuildingDef {
3
+ type: BuildingType;
4
+ label: string;
5
+ icon: string;
6
+ cost: Partial<Resources>;
7
+ description: string;
8
+ }
9
+ export declare const BUILDINGS: Record<BuildingType, BuildingDef>;
10
+ /**
11
+ * Build a structure in a territory. Returns error message or null on success.
12
+ */
13
+ export declare function buildStructure(faction: Faction, territory: Territory, buildingType: BuildingType): string | null;
14
+ /**
15
+ * Get defense bonus from buildings in a territory (added to terrain bonus).
16
+ */
17
+ export declare function getBuildingDefenseBonus(territory: Territory): number;
18
+ /**
19
+ * Get recruit cost discount from barracks.
20
+ */
21
+ export declare function getRecruitGoldCost(territory: Territory): number;
22
+ /**
23
+ * Get bonus gold income from market.
24
+ */
25
+ export declare function getMarketGoldBonus(territory: Territory): number;
@@ -0,0 +1,52 @@
1
+ // Building construction logic: walls (defense), barracks (cheaper recruits), market (gold income)
2
+ export const BUILDINGS = {
3
+ walls: { type: 'walls', label: 'Walls', icon: '🧱', cost: { wood: 10, stone: 15 }, description: '+0.3 defense bonus' },
4
+ barracks: { type: 'barracks', label: 'Barracks', icon: '🏛️', cost: { wood: 8, stone: 5 }, description: 'recruit costs 2g instead of 3g' },
5
+ market: { type: 'market', label: 'Market', icon: '🏪', cost: { gold: 10, wood: 5, stone: 3 }, description: '+2 gold income/turn' },
6
+ };
7
+ /**
8
+ * Build a structure in a territory. Returns error message or null on success.
9
+ */
10
+ export function buildStructure(faction, territory, buildingType) {
11
+ if (territory.owner !== faction.id) {
12
+ return `You don't own ${territory.name}.`;
13
+ }
14
+ const def = BUILDINGS[buildingType];
15
+ if (!def)
16
+ return `Unknown building: ${buildingType}. Available: walls, barracks, market`;
17
+ if (territory.buildings.includes(buildingType)) {
18
+ return `${territory.name} already has ${def.label}.`;
19
+ }
20
+ // Check cost
21
+ const cost = def.cost;
22
+ if ((cost.gold ?? 0) > faction.gold)
23
+ return `Not enough gold. Need ${cost.gold}, have ${faction.gold}.`;
24
+ if ((cost.wood ?? 0) > faction.wood)
25
+ return `Not enough wood. Need ${cost.wood}, have ${faction.wood}.`;
26
+ if ((cost.stone ?? 0) > faction.stone)
27
+ return `Not enough stone. Need ${cost.stone}, have ${faction.stone}.`;
28
+ // Deduct resources
29
+ faction.gold -= cost.gold ?? 0;
30
+ faction.wood -= cost.wood ?? 0;
31
+ faction.stone -= cost.stone ?? 0;
32
+ territory.buildings.push(buildingType);
33
+ return null;
34
+ }
35
+ /**
36
+ * Get defense bonus from buildings in a territory (added to terrain bonus).
37
+ */
38
+ export function getBuildingDefenseBonus(territory) {
39
+ return territory.buildings.includes('walls') ? 0.3 : 0;
40
+ }
41
+ /**
42
+ * Get recruit cost discount from barracks.
43
+ */
44
+ export function getRecruitGoldCost(territory) {
45
+ return territory.buildings.includes('barracks') ? 2 : 3;
46
+ }
47
+ /**
48
+ * Get bonus gold income from market.
49
+ */
50
+ export function getMarketGoldBonus(territory) {
51
+ return territory.buildings.includes('market') ? 2 : 0;
52
+ }
@@ -1,9 +1,9 @@
1
- import type { CombatResult, TerritoryType } from '../game-types.js';
1
+ import type { CombatResult, TerritoryType, Territory } from '../game-types.js';
2
2
  /**
3
3
  * Resolve combat between attacker and defender armies.
4
4
  * Uses a power ratio formula to determine outcome and casualties.
5
5
  */
6
- export declare function resolveCombat(attackerUnits: number, attackerMorale: number, defenderUnits: number, defenderMorale: number, terrain: TerritoryType): CombatResult;
6
+ export declare function resolveCombat(attackerUnits: number, attackerMorale: number, defenderUnits: number, defenderMorale: number, terrain: TerritoryType, defenderTerritory?: Territory): CombatResult;
7
7
  /**
8
8
  * Simulate AI faction auto-attack decision: returns true if AI should attack.
9
9
  */
@@ -1,4 +1,5 @@
1
1
  // Combat resolution engine: calculates battle outcomes based on units, morale, terrain
2
+ import { getBuildingDefenseBonus } from './building-manager.js';
2
3
  // Terrain defense bonuses (multiplier on defender power)
3
4
  const TERRAIN_BONUS = {
4
5
  plains: 1.0,
@@ -10,13 +11,17 @@ const TERRAIN_BONUS = {
10
11
  * Resolve combat between attacker and defender armies.
11
12
  * Uses a power ratio formula to determine outcome and casualties.
12
13
  */
13
- export function resolveCombat(attackerUnits, attackerMorale, defenderUnits, defenderMorale, terrain) {
14
+ export function resolveCombat(attackerUnits, attackerMorale, defenderUnits, defenderMorale, terrain, defenderTerritory) {
14
15
  const log = [];
15
16
  const terrainBonus = TERRAIN_BONUS[terrain];
17
+ const wallBonus = defenderTerritory ? getBuildingDefenseBonus(defenderTerritory) : 0;
18
+ const totalDefBonus = terrainBonus + wallBonus;
16
19
  const attackPower = attackerUnits * (attackerMorale / 100);
17
- const defensePower = defenderUnits * (defenderMorale / 100) * terrainBonus;
20
+ const defensePower = defenderUnits * (defenderMorale / 100) * totalDefBonus;
18
21
  log.push(`Attack power: ${attackPower.toFixed(1)} vs Defense power: ${defensePower.toFixed(1)}`);
19
22
  log.push(`Terrain (${terrain}) gives defender x${terrainBonus} bonus`);
23
+ if (wallBonus > 0)
24
+ log.push(`Walls give defender +${wallBonus} bonus`);
20
25
  const ratio = attackPower / (defensePower || 1);
21
26
  let outcome;
22
27
  let attackerCasualties;
@@ -1,4 +1,5 @@
1
1
  // Resource collection and upkeep calculations for each faction per turn
2
+ import { getMarketGoldBonus } from './building-manager.js';
2
3
  const ARMY_FOOD_UPKEEP = 1; // food per army unit per turn
3
4
  const ARMY_GOLD_UPKEEP = 0; // gold per army unit (free for now)
4
5
  /**
@@ -10,7 +11,7 @@ export function collectResources(faction, territories) {
10
11
  const territory = territories.get(territoryId);
11
12
  if (!territory)
12
13
  continue;
13
- income.gold += territory.resources.gold;
14
+ income.gold += territory.resources.gold + getMarketGoldBonus(territory);
14
15
  income.food += territory.resources.food;
15
16
  income.wood += territory.resources.wood;
16
17
  income.stone += territory.resources.stone;
@@ -1,4 +1,5 @@
1
1
  export type TerritoryType = 'plains' | 'forest' | 'mountain' | 'city';
2
+ export type BuildingType = 'walls' | 'barracks' | 'market';
2
3
  export type FactionPersonality = 'aggressive' | 'defensive' | 'diplomatic' | 'mercantile';
3
4
  export interface Resources {
4
5
  gold: number;
@@ -14,6 +15,7 @@ export interface Territory {
14
15
  armies: number;
15
16
  resources: Resources;
16
17
  adjacentTo: string[];
18
+ buildings: BuildingType[];
17
19
  }
18
20
  export interface Faction {
19
21
  id: string;
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { newGame, saveGame, loadGame, listSaves, checkWinCondition } from './sta
7
7
  import { processTurnResources } from './engine/resource-calculator.js';
8
8
  import { resolveCombat } from './engine/combat-resolver.js';
9
9
  import { recruitArmy, findArmyInTerritory, applyCasualties, ensureArmyRecord } from './engine/army-manager.js';
10
+ import { buildStructure, BUILDINGS, getRecruitGoldCost } from './engine/building-manager.js';
10
11
  import { printLine, printSeparator, printStatus, printHelp, printMap, printTerritoryInfo, ICONS } from './ui/display-helpers.js';
11
12
  import { runAiTurns } from './engine/ai-turn-processor.js';
12
13
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -70,10 +71,16 @@ async function processCommand(input, state) {
70
71
  printLine(chalk.red(`No army in ${from.name}.`));
71
72
  break;
72
73
  }
73
- const toMove = unitCount > 0 ? Math.min(unitCount, army.units) : army.units;
74
+ const MIN_GARRISON = 2;
75
+ const movable = army.units - MIN_GARRISON;
76
+ if (movable <= 0) {
77
+ printLine(chalk.red(`Cannot move — need at least ${MIN_GARRISON} garrison in ${from.name}. (${army.units} units, ${movable} movable)`));
78
+ break;
79
+ }
80
+ const toMove = unitCount > 0 ? Math.min(unitCount, movable) : movable;
74
81
  const remaining = army.units - toMove;
75
82
  if (toMove === 0) {
76
- printLine(chalk.red('Must move at least 1 unit.'));
83
+ printLine(chalk.red(`No movable units. ${army.units} in ${from.name}, ${MIN_GARRISON} must garrison.`));
77
84
  break;
78
85
  }
79
86
  // Update source
@@ -124,7 +131,8 @@ async function processCommand(input, state) {
124
131
  printLine(chalk.red(err));
125
132
  break;
126
133
  }
127
- printLine(chalk.green(`${ICONS.shield} Recruited ${n} units in ${terr.name}. (${ICONS.gold}-${n * 3} ${ICONS.food}-${n * 2})`));
134
+ const goldCost = getRecruitGoldCost(terr);
135
+ printLine(chalk.green(`${ICONS.shield} Recruited ${n} units in ${terr.name}. (${ICONS.gold}-${n * goldCost} ${ICONS.food}-${n * 2})`));
128
136
  break;
129
137
  }
130
138
  case 'attack': {
@@ -157,7 +165,7 @@ async function processCommand(input, state) {
157
165
  break;
158
166
  }
159
167
  printLine(chalk.yellow(`\n ${ICONS.fire} Battle: ${from.name} (${attackerArmy.units}) → ${to.name} (${to.armies})`));
160
- const result = resolveCombat(attackerArmy.units, attackerArmy.morale, to.armies, 80, to.type);
168
+ const result = resolveCombat(attackerArmy.units, attackerArmy.morale, to.armies, 80, to.type, to);
161
169
  result.log.forEach((l) => printLine(` ${l}`));
162
170
  applyCasualties(attackerArmy, result.attackerCasualties, player, state.armies, from);
163
171
  if (result.captured) {
@@ -179,6 +187,35 @@ async function processCommand(input, state) {
179
187
  }
180
188
  break;
181
189
  }
190
+ case 'build': {
191
+ // build <territory> <type>
192
+ if (args.length < 2) {
193
+ printLine('Usage: build <territory> <walls|barracks|market>');
194
+ printLine('');
195
+ for (const b of Object.values(BUILDINGS)) {
196
+ const costParts = Object.entries(b.cost).filter(([, v]) => v > 0).map(([k, v]) => `${v}${k[0]}`);
197
+ printLine(` ${b.icon} ${b.label.padEnd(10)} ${costParts.join(' ').padEnd(12)} ${b.description}`);
198
+ }
199
+ printLine('');
200
+ break;
201
+ }
202
+ const buildType = args[args.length - 1].toLowerCase();
203
+ const buildTerrName = args.slice(0, -1).join(' ');
204
+ const buildTerr = findTerritory(state, buildTerrName);
205
+ if (!buildTerr) {
206
+ printLine(chalk.red(`Territory "${buildTerrName}" not found.`));
207
+ break;
208
+ }
209
+ const buildErr = buildStructure(player, buildTerr, buildType);
210
+ if (buildErr) {
211
+ printLine(chalk.red(buildErr));
212
+ break;
213
+ }
214
+ const def = BUILDINGS[buildType];
215
+ const costStr = Object.entries(def.cost).filter(([, v]) => v > 0).map(([k, v]) => `${ICONS[k] ?? k}-${v}`).join(' ');
216
+ printLine(chalk.green(`${def.icon} Built ${def.label} in ${buildTerr.name}! (${costStr})`));
217
+ break;
218
+ }
182
219
  case 'save': {
183
220
  saveGame(state, args[0] ?? 'autosave');
184
221
  printLine(chalk.green(`Game saved to "${args[0] ?? 'autosave'}".`));
@@ -275,6 +312,16 @@ async function runGameLoop(state) {
275
312
  state.isOver = true;
276
313
  state.winner = winner;
277
314
  printLine(chalk.bold.yellow(`\n ${ICONS.crown} ${state.factions.get(winner).name} has conquered the world! Game over.\n`));
315
+ printLine(' 1. Play Again');
316
+ printLine(' 2. Exit\n');
317
+ const choice = await ask(' Choose: ');
318
+ if (choice.trim() === '1') {
319
+ return mainMenu();
320
+ }
321
+ else {
322
+ rl.close();
323
+ process.exit(0);
324
+ }
278
325
  }
279
326
  }
280
327
  }
@@ -285,8 +332,17 @@ async function mainMenu() {
285
332
  printLine(chalk.bold.cyan(' ╚════════════════════════════╝\n'));
286
333
  printLine(' 1. New Game');
287
334
  printLine(' 2. Load Game');
288
- printLine(' 3. Quit\n');
335
+ printLine(' 3. Tutorial');
336
+ printLine(' 4. Quit\n');
289
337
  const choice = await ask(' Choose: ');
338
+ if (choice.trim() === '3') {
339
+ await showTutorial();
340
+ return mainMenu();
341
+ }
342
+ if (choice.trim() === '4') {
343
+ rl.close();
344
+ process.exit(0);
345
+ }
290
346
  if (choice.trim() === '1') {
291
347
  printLine('\nChoose your faction:');
292
348
  printLine(' 1. 🔴 Iron Legion (aggressive)');
@@ -317,4 +373,101 @@ async function mainMenu() {
317
373
  process.exit(0);
318
374
  }
319
375
  }
376
+ // ─── Tutorial ────────────────────────────────────────────────────────────────
377
+ async function showTutorial() {
378
+ const pages = [
379
+ // Page 1: Overview
380
+ [
381
+ chalk.bold.cyan('\n ═══ HOW TO PLAY ═══\n'),
382
+ chalk.bold(' 🎯 Goal'),
383
+ ' Conquer all 8 territories on the map to win.',
384
+ ' You start with 2 territories. Enemy factions hold the rest.',
385
+ '',
386
+ chalk.bold(' ⏳ Turns'),
387
+ ' Each turn you get 3 actions. Actions are:',
388
+ ' • recruit — train new soldiers',
389
+ ' • move — march armies between territories',
390
+ ' • attack — invade enemy territory',
391
+ ' • build — construct buildings',
392
+ '',
393
+ ' Free commands (unlimited): look, info, status, help, save',
394
+ ' Type "next" to end your turn early.',
395
+ ],
396
+ // Page 2: Economy & Resources
397
+ [
398
+ chalk.bold.cyan('\n ═══ RESOURCES ═══\n'),
399
+ chalk.bold(' 💰 Gold — recruit armies (3g each, 2g with barracks)'),
400
+ chalk.bold(' 🍖 Food — recruit armies (2f each) + army upkeep (1f/unit/turn)'),
401
+ chalk.bold(' 🪵 Wood — build structures'),
402
+ chalk.bold(' 🪨 Stone — build structures'),
403
+ '',
404
+ ' Each territory produces resources every turn.',
405
+ ' More territories = more income = bigger army.',
406
+ '',
407
+ chalk.bold(' 💡 Tip: ') + 'Use "info <territory>" to see resource output.',
408
+ ],
409
+ // Page 3: Combat
410
+ [
411
+ chalk.bold.cyan('\n ═══ COMBAT ═══\n'),
412
+ ' Attack from YOUR territory into an ADJACENT enemy territory.',
413
+ ' Example: attack crossroads greenwood',
414
+ '',
415
+ chalk.bold(' Terrain defense bonuses:'),
416
+ ' 🌾 Plains — x1.0 (no bonus)',
417
+ ' 🌲 Forest — x1.2',
418
+ ' 🏰 City — x1.3',
419
+ ' ⛰️ Mountain — x1.5',
420
+ '',
421
+ chalk.bold(' Outcomes depend on power ratio:'),
422
+ ' 2:1+ → Decisive victory (low losses)',
423
+ ' 1.2:1 → Victory (moderate losses)',
424
+ ' ~1:1 → Pyrrhic (heavy losses both sides)',
425
+ ' Below → Defeat (you lose units, they don\'t)',
426
+ '',
427
+ chalk.bold(' 💡 Tip: ') + 'Outnumber defenders 2:1 for clean wins.',
428
+ ],
429
+ // Page 4: Buildings
430
+ [
431
+ chalk.bold.cyan('\n ═══ BUILDINGS ═══\n'),
432
+ ' Build structures to strengthen your territories.',
433
+ ' Command: build <territory> <type>',
434
+ '',
435
+ ' 🧱 Walls 10🪵 15🪨 +0.3 defense bonus',
436
+ ' 🏛️ Barracks 8🪵 5🪨 recruit costs 2💰 instead of 3💰',
437
+ ' 🏪 Market 10💰 5🪵 3🪨 +2💰 income per turn',
438
+ '',
439
+ ' Each territory can have all 3 buildings.',
440
+ ' Buildings show as icons on the map next to your territory.',
441
+ '',
442
+ chalk.bold(' 💡 Tip: ') + 'Build markets early for economy, walls on borders.',
443
+ ],
444
+ // Page 5: Strategy
445
+ [
446
+ chalk.bold.cyan('\n ═══ STRATEGY TIPS ═══\n'),
447
+ ' 1. Don\'t rush — build your economy first (markets!)',
448
+ ' 2. Recruit in bulk, then attack with overwhelming force',
449
+ ' 3. Mountains are hard to capture — bring 2x defenders',
450
+ ' 4. Each territory keeps a garrison of 2 when moving',
451
+ ' 5. Watch enemy actions at end of turn — defend borders',
452
+ ' 6. Build barracks in your main recruiting territory',
453
+ ' 7. Build walls on border territories facing enemies',
454
+ '',
455
+ chalk.bold(' 🏆 Factions:'),
456
+ ' 🔴 Iron Legion — starts strong, aggressive AI',
457
+ ' 🟢 Green Pact — high food/wood, defensive AI',
458
+ ' 🟡 Sand Empire — rich in gold, balanced AI',
459
+ ' 🟣 Void Covenant — mountain fortress, cautious AI',
460
+ ],
461
+ ];
462
+ for (let i = 0; i < pages.length; i++) {
463
+ pages[i].forEach((line) => printLine(line));
464
+ printLine('');
465
+ if (i < pages.length - 1) {
466
+ await ask(chalk.gray(` [Page ${i + 1}/${pages.length}] Press Enter for next page...`));
467
+ }
468
+ else {
469
+ await ask(chalk.gray(` [Page ${pages.length}/${pages.length}] Press Enter to return to menu...`));
470
+ }
471
+ }
472
+ }
320
473
  mainMenu().catch((err) => { console.error(chalk.red('Fatal:'), err); process.exit(1); });
@@ -61,6 +61,11 @@ export function toSaveData(state) {
61
61
  * Deserialize SaveData back into a live GameState.
62
62
  */
63
63
  export function fromSaveData(data) {
64
+ // Backfill buildings for old saves
65
+ for (const t of Object.values(data.territories)) {
66
+ if (!t.buildings)
67
+ t.buildings = [];
68
+ }
64
69
  return {
65
70
  turn: data.turn,
66
71
  territories: new Map(Object.entries(data.territories)),
@@ -1,5 +1,6 @@
1
1
  // UI display helpers: icons, status, map, help text
2
2
  import chalk from 'chalk';
3
+ import { BUILDINGS } from '../engine/building-manager.js';
3
4
  export const ICONS = {
4
5
  city: '🏰', forest: '🌲', mountain: '⛰️ ', plains: '🌾',
5
6
  gold: '💰', food: '🍖', wood: '🪵', stone: '🪨',
@@ -29,6 +30,7 @@ export function printHelp() {
29
30
  printLine(' move <from> <to> [n] — move n units between territories (all if omitted)');
30
31
  printLine(' recruit <territory> <n> — recruit n units (3💰 + 2🍖 each)');
31
32
  printLine(' attack <from> <to> — attack enemy territory from yours');
33
+ printLine(' build <territory> <type> — build walls/barracks/market (🪵🪨)');
32
34
  printLine(' next — end turn (enemies act after this)');
33
35
  printLine(' save [slot] — save game');
34
36
  printLine(' quit — exit game');
@@ -46,7 +48,8 @@ export function printMap(state) {
46
48
  const icon = ICONS[t.type] ?? '?';
47
49
  const armies = t.armies > 0 ? ` ${ICONS.army} ${t.armies}` : '';
48
50
  const yours = t.owner === playerId ? chalk.green(' ★') : '';
49
- printLine(` ${icon} ${colorFn(t.name.padEnd(14))} ${colorFn(ownerName)}${armies}${yours}`);
51
+ const bldgs = (t.buildings ?? []).map((b) => BUILDINGS[b]?.icon ?? '').join('');
52
+ printLine(` ${icon} ${colorFn(t.name.padEnd(14))} ${colorFn(ownerName)}${armies}${yours}${bldgs ? ' ' + bldgs : ''}`);
50
53
  const adjNames = t.adjacentTo.map((id) => state.territories.get(id)?.name ?? id).join(', ');
51
54
  printLine(chalk.gray(` ↔ ${adjNames}`));
52
55
  }
@@ -67,6 +70,8 @@ export function printTerritoryInfo(state, territoryName) {
67
70
  printLine(` Owner: ${ownerFaction?.name ?? 'Unclaimed'}`);
68
71
  printLine(` ${ICONS.army} Armies: ${t.armies}`);
69
72
  printLine(` Resources/turn: ${ICONS.gold}${t.resources.gold} ${ICONS.food}${t.resources.food} ${ICONS.wood}${t.resources.wood} ${ICONS.stone}${t.resources.stone}`);
73
+ const bldgs = (t.buildings ?? []).map((b) => `${BUILDINGS[b]?.icon ?? ''} ${BUILDINGS[b]?.label ?? b}`).join(', ');
74
+ printLine(` Buildings: ${bldgs || 'none'}`);
70
75
  printLine(` Neighbors:`);
71
76
  for (const adjId of t.adjacentTo) {
72
77
  const adj = state.territories.get(adjId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "empire-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CLI strategy RPG with AI game master",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -19,6 +19,7 @@ export const DEFAULT_TERRITORIES: Territory[] = [
19
19
  armies: 3,
20
20
  resources: { gold: 4, food: 2, wood: 1, stone: 2 },
21
21
  adjacentTo: ['iron_hills', 'greenwood'],
22
+ buildings: [],
22
23
  },
23
24
  {
24
25
  id: 'iron_hills',
@@ -28,6 +29,7 @@ export const DEFAULT_TERRITORIES: Territory[] = [
28
29
  armies: 2,
29
30
  resources: { gold: 2, food: 1, wood: 0, stone: 4 },
30
31
  adjacentTo: ['northkeep', 'crossroads'],
32
+ buildings: [],
31
33
  },
32
34
  {
33
35
  id: 'greenwood',
@@ -37,6 +39,7 @@ export const DEFAULT_TERRITORIES: Territory[] = [
37
39
  armies: 2,
38
40
  resources: { gold: 1, food: 3, wood: 4, stone: 0 },
39
41
  adjacentTo: ['northkeep', 'crossroads', 'silver_bay'],
42
+ buildings: [],
40
43
  },
41
44
  {
42
45
  id: 'crossroads',
@@ -46,6 +49,7 @@ export const DEFAULT_TERRITORIES: Territory[] = [
46
49
  armies: 0,
47
50
  resources: { gold: 2, food: 2, wood: 1, stone: 1 },
48
51
  adjacentTo: ['iron_hills', 'greenwood', 'desert_gate', 'stonehaven'],
52
+ buildings: [],
49
53
  },
50
54
  {
51
55
  id: 'desert_gate',
@@ -55,6 +59,7 @@ export const DEFAULT_TERRITORIES: Territory[] = [
55
59
  armies: 2,
56
60
  resources: { gold: 3, food: 1, wood: 0, stone: 2 },
57
61
  adjacentTo: ['crossroads'],
62
+ buildings: [],
58
63
  },
59
64
  {
60
65
  id: 'silver_bay',
@@ -64,6 +69,7 @@ export const DEFAULT_TERRITORIES: Territory[] = [
64
69
  armies: 3,
65
70
  resources: { gold: 5, food: 2, wood: 1, stone: 1 },
66
71
  adjacentTo: ['greenwood', 'stonehaven'],
72
+ buildings: [],
67
73
  },
68
74
  {
69
75
  id: 'stonehaven',
@@ -73,6 +79,7 @@ export const DEFAULT_TERRITORIES: Territory[] = [
73
79
  armies: 2,
74
80
  resources: { gold: 2, food: 1, wood: 0, stone: 5 },
75
81
  adjacentTo: ['crossroads', 'silver_bay', 'dragon_peak'],
82
+ buildings: [],
76
83
  },
77
84
  {
78
85
  id: 'dragon_peak',
@@ -82,6 +89,7 @@ export const DEFAULT_TERRITORIES: Territory[] = [
82
89
  armies: 4,
83
90
  resources: { gold: 1, food: 0, wood: 0, stone: 6 },
84
91
  adjacentTo: ['stonehaven'],
92
+ buildings: [],
85
93
  },
86
94
  ];
87
95
 
@@ -46,7 +46,7 @@ export function runAiTurns(state: GameState): string[] {
46
46
  if (attackerUnits < 2) continue;
47
47
  if (attackerUnits < defenderUnits && faction.personality !== 'aggressive') continue;
48
48
 
49
- const result = resolveCombat(attackerUnits, 80, defenderUnits, 80, adj.type);
49
+ const result = resolveCombat(attackerUnits, 80, defenderUnits, 80, adj.type, adj);
50
50
  log.push(`${faction.name} attacks ${adj.name} from ${territory.name}!`);
51
51
 
52
52
  const attackerArmy = findArmyInTerritory(faction.id, territoryId, state.armies);
@@ -2,8 +2,9 @@
2
2
 
3
3
  import type { Army, Faction, Territory, GameState } from '../game-types.js';
4
4
  import { canAfford, deductResources } from './resource-calculator.js';
5
+ import { getRecruitGoldCost } from './building-manager.js';
5
6
 
6
- const RECRUIT_COST_PER_UNIT = { gold: 3, food: 2, wood: 0, stone: 0 };
7
+ const RECRUIT_FOOD_PER_UNIT = 2;
7
8
  const DEFAULT_MORALE = 80;
8
9
 
9
10
  let armyIdCounter = 1;
@@ -29,9 +30,10 @@ export function recruitArmy(
29
30
  return 'Must recruit at least 1 unit.';
30
31
  }
31
32
 
33
+ const goldPerUnit = getRecruitGoldCost(territory);
32
34
  const totalCost = {
33
- gold: RECRUIT_COST_PER_UNIT.gold * units,
34
- food: RECRUIT_COST_PER_UNIT.food * units,
35
+ gold: goldPerUnit * units,
36
+ food: RECRUIT_FOOD_PER_UNIT * units,
35
37
  };
36
38
 
37
39
  if (!canAfford(faction, totalCost)) {
@@ -0,0 +1,72 @@
1
+ // Building construction logic: walls (defense), barracks (cheaper recruits), market (gold income)
2
+
3
+ import type { BuildingType, Faction, Territory, Resources } from '../game-types.js';
4
+
5
+ export interface BuildingDef {
6
+ type: BuildingType;
7
+ label: string;
8
+ icon: string;
9
+ cost: Partial<Resources>;
10
+ description: string;
11
+ }
12
+
13
+ export const BUILDINGS: Record<BuildingType, BuildingDef> = {
14
+ walls: { type: 'walls', label: 'Walls', icon: '🧱', cost: { wood: 10, stone: 15 }, description: '+0.3 defense bonus' },
15
+ barracks: { type: 'barracks', label: 'Barracks', icon: '🏛️', cost: { wood: 8, stone: 5 }, description: 'recruit costs 2g instead of 3g' },
16
+ market: { type: 'market', label: 'Market', icon: '🏪', cost: { gold: 10, wood: 5, stone: 3 }, description: '+2 gold income/turn' },
17
+ };
18
+
19
+ /**
20
+ * Build a structure in a territory. Returns error message or null on success.
21
+ */
22
+ export function buildStructure(
23
+ faction: Faction,
24
+ territory: Territory,
25
+ buildingType: BuildingType
26
+ ): string | null {
27
+ if (territory.owner !== faction.id) {
28
+ return `You don't own ${territory.name}.`;
29
+ }
30
+
31
+ const def = BUILDINGS[buildingType];
32
+ if (!def) return `Unknown building: ${buildingType}. Available: walls, barracks, market`;
33
+
34
+ if (territory.buildings.includes(buildingType)) {
35
+ return `${territory.name} already has ${def.label}.`;
36
+ }
37
+
38
+ // Check cost
39
+ const cost = def.cost;
40
+ if ((cost.gold ?? 0) > faction.gold) return `Not enough gold. Need ${cost.gold}, have ${faction.gold}.`;
41
+ if ((cost.wood ?? 0) > faction.wood) return `Not enough wood. Need ${cost.wood}, have ${faction.wood}.`;
42
+ if ((cost.stone ?? 0) > faction.stone) return `Not enough stone. Need ${cost.stone}, have ${faction.stone}.`;
43
+
44
+ // Deduct resources
45
+ faction.gold -= cost.gold ?? 0;
46
+ faction.wood -= cost.wood ?? 0;
47
+ faction.stone -= cost.stone ?? 0;
48
+
49
+ territory.buildings.push(buildingType);
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * Get defense bonus from buildings in a territory (added to terrain bonus).
55
+ */
56
+ export function getBuildingDefenseBonus(territory: Territory): number {
57
+ return territory.buildings.includes('walls') ? 0.3 : 0;
58
+ }
59
+
60
+ /**
61
+ * Get recruit cost discount from barracks.
62
+ */
63
+ export function getRecruitGoldCost(territory: Territory): number {
64
+ return territory.buildings.includes('barracks') ? 2 : 3;
65
+ }
66
+
67
+ /**
68
+ * Get bonus gold income from market.
69
+ */
70
+ export function getMarketGoldBonus(territory: Territory): number {
71
+ return territory.buildings.includes('market') ? 2 : 0;
72
+ }
@@ -1,6 +1,7 @@
1
1
  // Combat resolution engine: calculates battle outcomes based on units, morale, terrain
2
2
 
3
- import type { CombatResult, TerritoryType } from '../game-types.js';
3
+ import type { CombatResult, TerritoryType, Territory } from '../game-types.js';
4
+ import { getBuildingDefenseBonus } from './building-manager.js';
4
5
 
5
6
  // Terrain defense bonuses (multiplier on defender power)
6
7
  const TERRAIN_BONUS: Record<TerritoryType, number> = {
@@ -19,16 +20,20 @@ export function resolveCombat(
19
20
  attackerMorale: number,
20
21
  defenderUnits: number,
21
22
  defenderMorale: number,
22
- terrain: TerritoryType
23
+ terrain: TerritoryType,
24
+ defenderTerritory?: Territory
23
25
  ): CombatResult {
24
26
  const log: string[] = [];
25
27
 
26
28
  const terrainBonus = TERRAIN_BONUS[terrain];
29
+ const wallBonus = defenderTerritory ? getBuildingDefenseBonus(defenderTerritory) : 0;
30
+ const totalDefBonus = terrainBonus + wallBonus;
27
31
  const attackPower = attackerUnits * (attackerMorale / 100);
28
- const defensePower = defenderUnits * (defenderMorale / 100) * terrainBonus;
32
+ const defensePower = defenderUnits * (defenderMorale / 100) * totalDefBonus;
29
33
 
30
34
  log.push(`Attack power: ${attackPower.toFixed(1)} vs Defense power: ${defensePower.toFixed(1)}`);
31
35
  log.push(`Terrain (${terrain}) gives defender x${terrainBonus} bonus`);
36
+ if (wallBonus > 0) log.push(`Walls give defender +${wallBonus} bonus`);
32
37
 
33
38
  const ratio = attackPower / (defensePower || 1);
34
39
 
@@ -1,6 +1,7 @@
1
1
  // Resource collection and upkeep calculations for each faction per turn
2
2
 
3
3
  import type { Faction, Territory, Resources } from '../game-types.js';
4
+ import { getMarketGoldBonus } from './building-manager.js';
4
5
 
5
6
  const ARMY_FOOD_UPKEEP = 1; // food per army unit per turn
6
7
  const ARMY_GOLD_UPKEEP = 0; // gold per army unit (free for now)
@@ -18,7 +19,7 @@ export function collectResources(
18
19
  const territory = territories.get(territoryId);
19
20
  if (!territory) continue;
20
21
 
21
- income.gold += territory.resources.gold;
22
+ income.gold += territory.resources.gold + getMarketGoldBonus(territory);
22
23
  income.food += territory.resources.food;
23
24
  income.wood += territory.resources.wood;
24
25
  income.stone += territory.resources.stone;
package/src/game-types.ts CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  export type TerritoryType = 'plains' | 'forest' | 'mountain' | 'city';
4
4
 
5
+ export type BuildingType = 'walls' | 'barracks' | 'market';
6
+
5
7
  export type FactionPersonality = 'aggressive' | 'defensive' | 'diplomatic' | 'mercantile';
6
8
 
7
9
  export interface Resources {
@@ -19,6 +21,7 @@ export interface Territory {
19
21
  armies: number;
20
22
  resources: Resources; // base income per turn
21
23
  adjacentTo: string[]; // territory ids
24
+ buildings: BuildingType[]; // built structures
22
25
  }
23
26
 
24
27
  export interface Faction {
package/src/index.ts CHANGED
@@ -9,6 +9,8 @@ import { newGame, saveGame, loadGame, listSaves, checkWinCondition } from './sta
9
9
  import { processTurnResources } from './engine/resource-calculator.js';
10
10
  import { resolveCombat } from './engine/combat-resolver.js';
11
11
  import { recruitArmy, findArmyInTerritory, applyCasualties, getUnitsInTerritory, ensureArmyRecord } from './engine/army-manager.js';
12
+ import { buildStructure, BUILDINGS, getRecruitGoldCost } from './engine/building-manager.js';
13
+ import type { BuildingType } from './game-types.js';
12
14
  import { printLine, printSeparator, printStatus, printHelp, printMap, printTerritoryInfo, ICONS } from './ui/display-helpers.js';
13
15
  import { runAiTurns } from './engine/ai-turn-processor.js';
14
16
 
@@ -60,9 +62,12 @@ async function processCommand(input: string, state: GameState): Promise<boolean>
60
62
  const army = findArmyInTerritory(player.id, from.id, state.armies);
61
63
  if (!army || army.units === 0) { printLine(chalk.red(`No army in ${from.name}.`)); break; }
62
64
 
63
- const toMove = unitCount > 0 ? Math.min(unitCount, army.units) : army.units;
65
+ const MIN_GARRISON = 2;
66
+ const movable = army.units - MIN_GARRISON;
67
+ if (movable <= 0) { printLine(chalk.red(`Cannot move — need at least ${MIN_GARRISON} garrison in ${from.name}. (${army.units} units, ${movable} movable)`)); break; }
68
+ const toMove = unitCount > 0 ? Math.min(unitCount, movable) : movable;
64
69
  const remaining = army.units - toMove;
65
- if (toMove === 0) { printLine(chalk.red('Must move at least 1 unit.')); break; }
70
+ if (toMove === 0) { printLine(chalk.red(`No movable units. ${army.units} in ${from.name}, ${MIN_GARRISON} must garrison.`)); break; }
66
71
 
67
72
  // Update source
68
73
  if (remaining > 0) { army.units = remaining; from.armies = remaining; }
@@ -91,7 +96,8 @@ async function processCommand(input: string, state: GameState): Promise<boolean>
91
96
 
92
97
  const err = recruitArmy(player, terr, n, state.armies);
93
98
  if (err) { printLine(chalk.red(err)); break; }
94
- printLine(chalk.green(`${ICONS.shield} Recruited ${n} units in ${terr.name}. (${ICONS.gold}-${n * 3} ${ICONS.food}-${n * 2})`));
99
+ const goldCost = getRecruitGoldCost(terr);
100
+ printLine(chalk.green(`${ICONS.shield} Recruited ${n} units in ${terr.name}. (${ICONS.gold}-${n * goldCost} ${ICONS.food}-${n * 2})`));
95
101
  break;
96
102
  }
97
103
 
@@ -109,7 +115,7 @@ async function processCommand(input: string, state: GameState): Promise<boolean>
109
115
  if (!attackerArmy || attackerArmy.units === 0) { printLine(chalk.red(`No army in ${from.name}.`)); break; }
110
116
 
111
117
  printLine(chalk.yellow(`\n ${ICONS.fire} Battle: ${from.name} (${attackerArmy.units}) → ${to.name} (${to.armies})`));
112
- const result = resolveCombat(attackerArmy.units, attackerArmy.morale, to.armies, 80, to.type);
118
+ const result = resolveCombat(attackerArmy.units, attackerArmy.morale, to.armies, 80, to.type, to);
113
119
  result.log.forEach((l) => printLine(` ${l}`));
114
120
 
115
121
  applyCasualties(attackerArmy, result.attackerCasualties, player, state.armies, from);
@@ -131,6 +137,30 @@ async function processCommand(input: string, state: GameState): Promise<boolean>
131
137
  break;
132
138
  }
133
139
 
140
+ case 'build': {
141
+ // build <territory> <type>
142
+ if (args.length < 2) {
143
+ printLine('Usage: build <territory> <walls|barracks|market>');
144
+ printLine('');
145
+ for (const b of Object.values(BUILDINGS)) {
146
+ const costParts = Object.entries(b.cost).filter(([, v]) => v > 0).map(([k, v]) => `${v}${k[0]}`);
147
+ printLine(` ${b.icon} ${b.label.padEnd(10)} ${costParts.join(' ').padEnd(12)} ${b.description}`);
148
+ }
149
+ printLine('');
150
+ break;
151
+ }
152
+ const buildType = args[args.length - 1].toLowerCase() as BuildingType;
153
+ const buildTerrName = args.slice(0, -1).join(' ');
154
+ const buildTerr = findTerritory(state, buildTerrName);
155
+ if (!buildTerr) { printLine(chalk.red(`Territory "${buildTerrName}" not found.`)); break; }
156
+ const buildErr = buildStructure(player, buildTerr, buildType);
157
+ if (buildErr) { printLine(chalk.red(buildErr)); break; }
158
+ const def = BUILDINGS[buildType];
159
+ const costStr = Object.entries(def.cost).filter(([, v]) => v > 0).map(([k, v]) => `${ICONS[k] ?? k}-${v}`).join(' ');
160
+ printLine(chalk.green(`${def.icon} Built ${def.label} in ${buildTerr.name}! (${costStr})`));
161
+ break;
162
+ }
163
+
134
164
  case 'save': {
135
165
  saveGame(state, args[0] ?? 'autosave');
136
166
  printLine(chalk.green(`Game saved to "${args[0] ?? 'autosave'}".`));
@@ -226,6 +256,11 @@ async function runGameLoop(state: GameState): Promise<void> {
226
256
  if (winner) {
227
257
  state.isOver = true; state.winner = winner;
228
258
  printLine(chalk.bold.yellow(`\n ${ICONS.crown} ${state.factions.get(winner)!.name} has conquered the world! Game over.\n`));
259
+ printLine(' 1. Play Again');
260
+ printLine(' 2. Exit\n');
261
+ const choice = await ask(' Choose: ');
262
+ if (choice.trim() === '1') { return mainMenu(); }
263
+ else { rl.close(); process.exit(0); }
229
264
  }
230
265
  }
231
266
  }
@@ -238,9 +273,15 @@ async function mainMenu(): Promise<void> {
238
273
  printLine(chalk.bold.cyan(' ╚════════════════════════════╝\n'));
239
274
  printLine(' 1. New Game');
240
275
  printLine(' 2. Load Game');
241
- printLine(' 3. Quit\n');
276
+ printLine(' 3. Tutorial');
277
+ printLine(' 4. Quit\n');
242
278
 
243
279
  const choice = await ask(' Choose: ');
280
+ if (choice.trim() === '3') {
281
+ await showTutorial();
282
+ return mainMenu();
283
+ }
284
+ if (choice.trim() === '4') { rl.close(); process.exit(0); }
244
285
  if (choice.trim() === '1') {
245
286
  printLine('\nChoose your faction:');
246
287
  printLine(' 1. 🔴 Iron Legion (aggressive)');
@@ -261,4 +302,104 @@ async function mainMenu(): Promise<void> {
261
302
  } else { rl.close(); process.exit(0); }
262
303
  }
263
304
 
305
+ // ─── Tutorial ────────────────────────────────────────────────────────────────
306
+
307
+ async function showTutorial(): Promise<void> {
308
+ const pages = [
309
+ // Page 1: Overview
310
+ [
311
+ chalk.bold.cyan('\n ═══ HOW TO PLAY ═══\n'),
312
+ chalk.bold(' 🎯 Goal'),
313
+ ' Conquer all 8 territories on the map to win.',
314
+ ' You start with 2 territories. Enemy factions hold the rest.',
315
+ '',
316
+ chalk.bold(' ⏳ Turns'),
317
+ ' Each turn you get 3 actions. Actions are:',
318
+ ' • recruit — train new soldiers',
319
+ ' • move — march armies between territories',
320
+ ' • attack — invade enemy territory',
321
+ ' • build — construct buildings',
322
+ '',
323
+ ' Free commands (unlimited): look, info, status, help, save',
324
+ ' Type "next" to end your turn early.',
325
+ ],
326
+ // Page 2: Economy & Resources
327
+ [
328
+ chalk.bold.cyan('\n ═══ RESOURCES ═══\n'),
329
+ chalk.bold(' 💰 Gold — recruit armies (3g each, 2g with barracks)'),
330
+ chalk.bold(' 🍖 Food — recruit armies (2f each) + army upkeep (1f/unit/turn)'),
331
+ chalk.bold(' 🪵 Wood — build structures'),
332
+ chalk.bold(' 🪨 Stone — build structures'),
333
+ '',
334
+ ' Each territory produces resources every turn.',
335
+ ' More territories = more income = bigger army.',
336
+ '',
337
+ chalk.bold(' 💡 Tip: ') + 'Use "info <territory>" to see resource output.',
338
+ ],
339
+ // Page 3: Combat
340
+ [
341
+ chalk.bold.cyan('\n ═══ COMBAT ═══\n'),
342
+ ' Attack from YOUR territory into an ADJACENT enemy territory.',
343
+ ' Example: attack crossroads greenwood',
344
+ '',
345
+ chalk.bold(' Terrain defense bonuses:'),
346
+ ' 🌾 Plains — x1.0 (no bonus)',
347
+ ' 🌲 Forest — x1.2',
348
+ ' 🏰 City — x1.3',
349
+ ' ⛰️ Mountain — x1.5',
350
+ '',
351
+ chalk.bold(' Outcomes depend on power ratio:'),
352
+ ' 2:1+ → Decisive victory (low losses)',
353
+ ' 1.2:1 → Victory (moderate losses)',
354
+ ' ~1:1 → Pyrrhic (heavy losses both sides)',
355
+ ' Below → Defeat (you lose units, they don\'t)',
356
+ '',
357
+ chalk.bold(' 💡 Tip: ') + 'Outnumber defenders 2:1 for clean wins.',
358
+ ],
359
+ // Page 4: Buildings
360
+ [
361
+ chalk.bold.cyan('\n ═══ BUILDINGS ═══\n'),
362
+ ' Build structures to strengthen your territories.',
363
+ ' Command: build <territory> <type>',
364
+ '',
365
+ ' 🧱 Walls 10🪵 15🪨 +0.3 defense bonus',
366
+ ' 🏛️ Barracks 8🪵 5🪨 recruit costs 2💰 instead of 3💰',
367
+ ' 🏪 Market 10💰 5🪵 3🪨 +2💰 income per turn',
368
+ '',
369
+ ' Each territory can have all 3 buildings.',
370
+ ' Buildings show as icons on the map next to your territory.',
371
+ '',
372
+ chalk.bold(' 💡 Tip: ') + 'Build markets early for economy, walls on borders.',
373
+ ],
374
+ // Page 5: Strategy
375
+ [
376
+ chalk.bold.cyan('\n ═══ STRATEGY TIPS ═══\n'),
377
+ ' 1. Don\'t rush — build your economy first (markets!)',
378
+ ' 2. Recruit in bulk, then attack with overwhelming force',
379
+ ' 3. Mountains are hard to capture — bring 2x defenders',
380
+ ' 4. Each territory keeps a garrison of 2 when moving',
381
+ ' 5. Watch enemy actions at end of turn — defend borders',
382
+ ' 6. Build barracks in your main recruiting territory',
383
+ ' 7. Build walls on border territories facing enemies',
384
+ '',
385
+ chalk.bold(' 🏆 Factions:'),
386
+ ' 🔴 Iron Legion — starts strong, aggressive AI',
387
+ ' 🟢 Green Pact — high food/wood, defensive AI',
388
+ ' 🟡 Sand Empire — rich in gold, balanced AI',
389
+ ' 🟣 Void Covenant — mountain fortress, cautious AI',
390
+ ],
391
+ ];
392
+
393
+ for (let i = 0; i < pages.length; i++) {
394
+ pages[i].forEach((line) => printLine(line));
395
+ printLine('');
396
+ if (i < pages.length - 1) {
397
+ await ask(chalk.gray(` [Page ${i + 1}/${pages.length}] Press Enter for next page...`));
398
+ } else {
399
+ await ask(chalk.gray(` [Page ${pages.length}/${pages.length}] Press Enter to return to menu...`));
400
+ }
401
+ }
402
+ }
403
+
404
+
264
405
  mainMenu().catch((err) => { console.error(chalk.red('Fatal:'), err); process.exit(1); });
@@ -70,6 +70,10 @@ export function toSaveData(state: GameState): SaveData {
70
70
  * Deserialize SaveData back into a live GameState.
71
71
  */
72
72
  export function fromSaveData(data: SaveData): GameState {
73
+ // Backfill buildings for old saves
74
+ for (const t of Object.values(data.territories)) {
75
+ if (!t.buildings) (t as any).buildings = [];
76
+ }
73
77
  return {
74
78
  turn: data.turn,
75
79
  territories: new Map(Object.entries(data.territories)),
@@ -2,6 +2,7 @@
2
2
  import chalk from 'chalk';
3
3
  import type { GameState } from '../game-types.js';
4
4
  import { findArmyInTerritory } from '../engine/army-manager.js';
5
+ import { BUILDINGS } from '../engine/building-manager.js';
5
6
 
6
7
  export const ICONS: Record<string, string> = {
7
8
  city: '🏰', forest: '🌲', mountain: '⛰️ ', plains: '🌾',
@@ -36,6 +37,7 @@ export function printHelp(): void {
36
37
  printLine(' move <from> <to> [n] — move n units between territories (all if omitted)');
37
38
  printLine(' recruit <territory> <n> — recruit n units (3💰 + 2🍖 each)');
38
39
  printLine(' attack <from> <to> — attack enemy territory from yours');
40
+ printLine(' build <territory> <type> — build walls/barracks/market (🪵🪨)');
39
41
  printLine(' next — end turn (enemies act after this)');
40
42
  printLine(' save [slot] — save game');
41
43
  printLine(' quit — exit game');
@@ -54,7 +56,8 @@ export function printMap(state: GameState): void {
54
56
  const icon = ICONS[t.type] ?? '?';
55
57
  const armies = t.armies > 0 ? ` ${ICONS.army} ${t.armies}` : '';
56
58
  const yours = t.owner === playerId ? chalk.green(' ★') : '';
57
- printLine(` ${icon} ${colorFn(t.name.padEnd(14))} ${colorFn(ownerName)}${armies}${yours}`);
59
+ const bldgs = (t.buildings ?? []).map((b) => BUILDINGS[b]?.icon ?? '').join('');
60
+ printLine(` ${icon} ${colorFn(t.name.padEnd(14))} ${colorFn(ownerName)}${armies}${yours}${bldgs ? ' ' + bldgs : ''}`);
58
61
  const adjNames = t.adjacentTo.map((id) => state.territories.get(id)?.name ?? id).join(', ');
59
62
  printLine(chalk.gray(` ↔ ${adjNames}`));
60
63
  }
@@ -77,6 +80,8 @@ export function printTerritoryInfo(state: GameState, territoryName: string): voi
77
80
  printLine(` Owner: ${ownerFaction?.name ?? 'Unclaimed'}`);
78
81
  printLine(` ${ICONS.army} Armies: ${t.armies}`);
79
82
  printLine(` Resources/turn: ${ICONS.gold}${t.resources.gold} ${ICONS.food}${t.resources.food} ${ICONS.wood}${t.resources.wood} ${ICONS.stone}${t.resources.stone}`);
83
+ const bldgs = (t.buildings ?? []).map((b) => `${BUILDINGS[b]?.icon ?? ''} ${BUILDINGS[b]?.label ?? b}`).join(', ');
84
+ printLine(` Buildings: ${bldgs || 'none'}`);
80
85
  printLine(` Neighbors:`);
81
86
  for (const adjId of t.adjacentTo) {
82
87
  const adj = state.territories.get(adjId)!;
@@ -1,117 +0,0 @@
1
-
2
- ╔════════════════════════════╗
3
- ║ ⚔️ E M P I R E CLI 👑 ║
4
- ╚════════════════════════════╝
5
-
6
- 1. New Game
7
- 2. Load Game
8
- 3. Quit
9
-
10
- Choose:
11
- Choose your faction:
12
- 1. 🔴 Iron Legion (aggressive)
13
- 2. 🟢 Green Pact (defensive)
14
- 3. 🟡 Sand Empire (mercantile)
15
- 4. 🟣 Void Covenant (diplomatic)
16
-
17
- Choose:
18
- 👑 Welcome to Empire CLI ⚔️
19
-
20
-
21
- Commands:
22
- look — show world map
23
- info <territory> — show territory details
24
- status — show your resources
25
- move <from> <to> [n] — move n units between territories (all if omitted)
26
- recruit <territory> <n> — recruit n units (3💰 + 2🍖 each)
27
- attack <from> <to> — attack enemy territory from yours
28
- next — end turn (enemies act after this)
29
- save [slot] — save game
30
- quit — exit game
31
- help — show this help
32
-
33
- ────────────────────────────────────────────────────────────
34
- 👑 Iron Legion — Turn 1
35
- 💰 20 🍖 10 🪵 5 🪨 15
36
- ⚔️ Armies: 5 | 🚩 Territories: 2/8
37
- ────────────────────────────────────────────────────────────
38
- Turn 1 [3/3 actions] >
39
- World Map:
40
-
41
- 🏰 Northkeep Iron Legion ⚔️ 3 ★
42
- ↔ Iron Hills, Greenwood
43
- ⛰️ Iron Hills Iron Legion ⚔️ 2 ★
44
- ↔ Northkeep, Crossroads
45
- 🌲 Greenwood Green Pact ⚔️ 2
46
- ↔ Northkeep, Crossroads, Silver Bay
47
- 🌾 Crossroads Unclaimed
48
- ↔ Iron Hills, Greenwood, Desert Gate, Stonehaven
49
- 🌾 Desert Gate Sand Empire ⚔️ 2
50
- ↔ Crossroads
51
- 🏰 Silver Bay Green Pact ⚔️ 3
52
- ↔ Greenwood, Stonehaven
53
- ⛰️ Stonehaven Sand Empire ⚔️ 2
54
- ↔ Crossroads, Silver Bay, Dragon Peak
55
- ⛰️ Dragon Peak Void Covenant ⚔️ 4
56
- ↔ Stonehaven
57
-
58
- Turn 1 [3/3 actions] >
59
- 🏰 Northkeep (city)
60
- Owner: Iron Legion
61
- ⚔️ Armies: 3
62
- Resources/turn: 💰4 🍖2 🪵1 🪨2
63
- Neighbors:
64
- → ⛰️ Iron Hills — Iron Legion ★
65
- → 🌲 Greenwood — Green Pact ⚔️ 2
66
-
67
- Turn 1 [3/3 actions] > 🛡️ Recruited 2 units in Northkeep. (💰-6 🍖-4)
68
- Turn 1 [2/3 actions] > ⚔️ Moved 3 units: Northkeep → Iron Hills (2 garrison Northkeep)
69
- Turn 1 [1/3 actions] >
70
- 🔥 Battle: Iron Hills (5) → Crossroads (0)
71
- Attack power: 4.0 vs Defense power: 0.0
72
- Terrain (plains) gives defender x1 bonus
73
- Decisive victory! The defenders are routed!
74
- Attacker losses: 0 units
75
- Defender losses: 0 units
76
-
77
- 🚩 You captured Crossroads!
78
-
79
- ⏰ No actions remaining — turn ends automatically.
80
-
81
- ────────────────────────────────────────────────────────────
82
- --- End of Turn 1 ---
83
-
84
- Iron Legion collected: +8g +5f +2w +7s
85
-
86
- 🔥 Enemy Actions:
87
- Green Pact recruited 3 units in Greenwood
88
- Green Pact attacks Northkeep from Greenwood!
89
- → Green Pact captured Northkeep! 🔥
90
-
91
- ────────────────────────────────────────────────────────────
92
- ────────────────────────────────────────────────────────────
93
- 👑 Iron Legion — Turn 2
94
- 💰 22 🍖 4 🪵 7 🪨 22
95
- ⚔️ Armies: 6 | 🚩 Territories: 2/8
96
- ────────────────────────────────────────────────────────────
97
- Turn 2 [3/3 actions] >
98
- World Map:
99
-
100
- 🏰 Northkeep Green Pact ⚔️ 4
101
- ↔ Iron Hills, Greenwood
102
- ⛰️ Iron Hills Iron Legion ⚔️ 5 ★
103
- ↔ Northkeep, Crossroads
104
- 🌲 Greenwood Green Pact ⚔️ 4
105
- ↔ Northkeep, Crossroads, Silver Bay
106
- 🌾 Crossroads Iron Legion ⚔️ 5 ★
107
- ↔ Iron Hills, Greenwood, Desert Gate, Stonehaven
108
- 🌾 Desert Gate Sand Empire ⚔️ 2
109
- ↔ Crossroads
110
- 🏰 Silver Bay Green Pact ⚔️ 3
111
- ↔ Greenwood, Stonehaven
112
- ⛰️ Stonehaven Sand Empire ⚔️ 2
113
- ↔ Crossroads, Silver Bay, Dragon Peak
114
- ⛰️ Dragon Peak Void Covenant ⚔️ 4
115
- ↔ Stonehaven
116
-
117
- Turn 2 [3/3 actions] > Farewell, Emperor.