empire-cli 0.1.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 ADDED
@@ -0,0 +1,137 @@
1
+ # ⚔️ Empire CLI 👑
2
+
3
+ > **Status: Early MVP** — Core gameplay works. AI game master (Gemini/Ollama/Claude) coming soon.
4
+
5
+ A CLI turn-based strategy RPG where you build armies, expand your empire, and conquer the world. Open source, runs in any terminal.
6
+
7
+ ## Screenshot
8
+
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
+ ```
45
+
46
+ ## Quick Start
47
+
48
+ ```bash
49
+ # Play instantly (no install needed)
50
+ npx empire-cli
51
+
52
+ # Or clone for development
53
+ git clone https://github.com/lppduy/empire-cli.git
54
+ cd empire-cli
55
+ npm install
56
+ npm start
57
+ ```
58
+
59
+ ## Quick Tutorial
60
+
61
+ ```
62
+ 1. Start a new game → pick a faction (e.g. Iron Legion)
63
+ 2. "look" — see the world map
64
+ 3. "info northkeep" — inspect a territory
65
+ 4. "recruit northkeep 3" — train 3 units (costs 3💰 + 2🍖 each)
66
+ 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)
69
+ 8. Watch enemy factions react — then plan your next move!
70
+ ```
71
+
72
+ **Goal:** Conquer all 8 territories to win.
73
+
74
+ ## Commands
75
+
76
+ You get **3 actions per turn**. `look`, `info`, `status`, `help`, `save` are free (don't cost actions).
77
+
78
+ | Command | Description |
79
+ |---------|-------------|
80
+ | `look` | Show world map |
81
+ | `info <territory>` | Show territory details & neighbors |
82
+ | `status` | Show your resources and army count |
83
+ | `move <from> <to> [n]` | Move n units between territories (all if omitted) |
84
+ | `recruit <territory> <n>` | Recruit n units at a territory |
85
+ | `attack <from> <to>` | Attack enemy territory from yours |
86
+ | `next` | End turn early |
87
+ | `save [slot]` | Save game |
88
+ | `help` | Show commands |
89
+ | `quit` | Exit |
90
+
91
+ ## Factions
92
+
93
+ | Faction | Personality | Strengths |
94
+ |---------|-------------|-----------|
95
+ | 🔴 Iron Legion | Aggressive | High stone, strong start |
96
+ | 🟢 Green Pact | Defensive | High food & wood |
97
+ | 🟡 Sand Empire | Mercantile | High gold reserves |
98
+ | 🟣 Void Covenant | Diplomatic | Mountain fortress |
99
+
100
+ ## Resources
101
+
102
+ - 💰 **Gold** — Recruit armies (3 per unit)
103
+ - 🍖 **Food** — Recruit + army upkeep (2 per unit)
104
+ - 🪵 **Wood** — Future: buildings
105
+ - 🪨 **Stone** — Future: fortifications
106
+
107
+ ## Roadmap
108
+
109
+ - [x] Core game loop with turn-based strategy
110
+ - [x] 4 factions with AI personalities
111
+ - [x] 8-territory map with adjacency
112
+ - [x] Combat system with terrain bonuses
113
+ - [x] Save/load game
114
+ - [x] Action limit per turn (3 actions)
115
+ - [ ] AI Game Master (Gemini free tier / Ollama / Claude) — dynamic narration
116
+ - [ ] Diplomacy system (alliances, trade, peace)
117
+ - [ ] Buildings (walls, barracks, markets)
118
+ - [ ] More maps & factions
119
+ - [ ] npm package (`npx empire-cli`)
120
+
121
+ ## Tech Stack
122
+
123
+ - TypeScript + Node.js 18+
124
+ - chalk (terminal colors)
125
+ - readline (input)
126
+ - JSON saves (`~/.empire-cli/saves/`)
127
+
128
+ ## Development
129
+
130
+ ```bash
131
+ npm start # Play the game
132
+ npm run build # Compile TypeScript
133
+ ```
134
+
135
+ ## License
136
+
137
+ MIT
@@ -0,0 +1,117 @@
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.
@@ -0,0 +1,3 @@
1
+ import type { Territory, Faction } from '../game-types.js';
2
+ export declare const DEFAULT_TERRITORIES: Territory[];
3
+ export declare const DEFAULT_FACTIONS: Faction[];
@@ -0,0 +1,133 @@
1
+ // Default world map data: 8 territories, 4 starting factions
2
+ // Map layout:
3
+ // [Northkeep] --- [Iron Hills]
4
+ // | |
5
+ // [Greenwood] --- [Crossroads] --- [Desert Gate]
6
+ // | |
7
+ // [Silver Bay] --- [Stonehaven]
8
+ // |
9
+ // [Dragon Peak]
10
+ export const DEFAULT_TERRITORIES = [
11
+ {
12
+ id: 'northkeep',
13
+ name: 'Northkeep',
14
+ type: 'city',
15
+ owner: 'iron_legion',
16
+ armies: 3,
17
+ resources: { gold: 4, food: 2, wood: 1, stone: 2 },
18
+ adjacentTo: ['iron_hills', 'greenwood'],
19
+ },
20
+ {
21
+ id: 'iron_hills',
22
+ name: 'Iron Hills',
23
+ type: 'mountain',
24
+ owner: 'iron_legion',
25
+ armies: 2,
26
+ resources: { gold: 2, food: 1, wood: 0, stone: 4 },
27
+ adjacentTo: ['northkeep', 'crossroads'],
28
+ },
29
+ {
30
+ id: 'greenwood',
31
+ name: 'Greenwood',
32
+ type: 'forest',
33
+ owner: 'green_pact',
34
+ armies: 2,
35
+ resources: { gold: 1, food: 3, wood: 4, stone: 0 },
36
+ adjacentTo: ['northkeep', 'crossroads', 'silver_bay'],
37
+ },
38
+ {
39
+ id: 'crossroads',
40
+ name: 'Crossroads',
41
+ type: 'plains',
42
+ owner: null,
43
+ armies: 0,
44
+ resources: { gold: 2, food: 2, wood: 1, stone: 1 },
45
+ adjacentTo: ['iron_hills', 'greenwood', 'desert_gate', 'stonehaven'],
46
+ },
47
+ {
48
+ id: 'desert_gate',
49
+ name: 'Desert Gate',
50
+ type: 'plains',
51
+ owner: 'sand_empire',
52
+ armies: 2,
53
+ resources: { gold: 3, food: 1, wood: 0, stone: 2 },
54
+ adjacentTo: ['crossroads'],
55
+ },
56
+ {
57
+ id: 'silver_bay',
58
+ name: 'Silver Bay',
59
+ type: 'city',
60
+ owner: 'green_pact',
61
+ armies: 3,
62
+ resources: { gold: 5, food: 2, wood: 1, stone: 1 },
63
+ adjacentTo: ['greenwood', 'stonehaven'],
64
+ },
65
+ {
66
+ id: 'stonehaven',
67
+ name: 'Stonehaven',
68
+ type: 'mountain',
69
+ owner: 'sand_empire',
70
+ armies: 2,
71
+ resources: { gold: 2, food: 1, wood: 0, stone: 5 },
72
+ adjacentTo: ['crossroads', 'silver_bay', 'dragon_peak'],
73
+ },
74
+ {
75
+ id: 'dragon_peak',
76
+ name: 'Dragon Peak',
77
+ type: 'mountain',
78
+ owner: 'void_covenant',
79
+ armies: 4,
80
+ resources: { gold: 1, food: 0, wood: 0, stone: 6 },
81
+ adjacentTo: ['stonehaven'],
82
+ },
83
+ ];
84
+ export const DEFAULT_FACTIONS = [
85
+ {
86
+ id: 'iron_legion',
87
+ name: 'Iron Legion',
88
+ personality: 'aggressive',
89
+ color: 'red',
90
+ territories: ['northkeep', 'iron_hills'],
91
+ gold: 20,
92
+ food: 10,
93
+ wood: 5,
94
+ stone: 15,
95
+ totalArmies: 5,
96
+ },
97
+ {
98
+ id: 'green_pact',
99
+ name: 'Green Pact',
100
+ personality: 'defensive',
101
+ color: 'green',
102
+ territories: ['greenwood', 'silver_bay'],
103
+ gold: 15,
104
+ food: 20,
105
+ wood: 25,
106
+ stone: 5,
107
+ totalArmies: 5,
108
+ },
109
+ {
110
+ id: 'sand_empire',
111
+ name: 'Sand Empire',
112
+ personality: 'mercantile',
113
+ color: 'yellow',
114
+ territories: ['desert_gate', 'stonehaven'],
115
+ gold: 30,
116
+ food: 8,
117
+ wood: 2,
118
+ stone: 10,
119
+ totalArmies: 4,
120
+ },
121
+ {
122
+ id: 'void_covenant',
123
+ name: 'Void Covenant',
124
+ personality: 'diplomatic',
125
+ color: 'magenta',
126
+ territories: ['dragon_peak'],
127
+ gold: 10,
128
+ food: 5,
129
+ wood: 2,
130
+ stone: 20,
131
+ totalArmies: 4,
132
+ },
133
+ ];
@@ -0,0 +1,2 @@
1
+ import type { GameState } from '../game-types.js';
2
+ export declare function runAiTurns(state: GameState): string[];
@@ -0,0 +1,75 @@
1
+ import { resolveCombat } from './combat-resolver.js';
2
+ import { findArmyInTerritory, getUnitsInTerritory, applyCasualties } from './army-manager.js';
3
+ export function runAiTurns(state) {
4
+ const log = [];
5
+ for (const faction of state.factions.values()) {
6
+ if (faction.id === state.playerFactionId)
7
+ continue;
8
+ if (faction.territories.length === 0)
9
+ continue;
10
+ // Simple AI: recruit if has resources, then try to expand
11
+ // Recruit in first territory if affordable
12
+ if (faction.gold >= 6 && faction.food >= 4) {
13
+ const recruitTerrId = faction.territories[0];
14
+ const recruitTerr = state.territories.get(recruitTerrId);
15
+ if (recruitTerr) {
16
+ const units = Math.min(Math.floor(faction.gold / 3), Math.floor(faction.food / 2), 3);
17
+ if (units > 0) {
18
+ faction.gold -= units * 3;
19
+ faction.food -= units * 2;
20
+ faction.totalArmies += units;
21
+ recruitTerr.armies += units;
22
+ // Create/merge army record
23
+ const existing = findArmyInTerritory(faction.id, recruitTerrId, state.armies);
24
+ if (existing) {
25
+ existing.units += units;
26
+ }
27
+ else {
28
+ const id = `ai_army_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
29
+ state.armies.set(id, { id, factionId: faction.id, units, morale: 80, territoryId: recruitTerrId });
30
+ }
31
+ log.push(`${faction.name} recruited ${units} units in ${recruitTerr.name}`);
32
+ }
33
+ }
34
+ }
35
+ // Try to attack one adjacent weak territory
36
+ for (const territoryId of [...faction.territories]) {
37
+ const territory = state.territories.get(territoryId);
38
+ for (const adjId of territory.adjacentTo) {
39
+ const adj = state.territories.get(adjId);
40
+ if (adj.owner === faction.id)
41
+ continue;
42
+ const attackerUnits = getUnitsInTerritory(faction.id, territoryId, state.armies);
43
+ const defenderUnits = adj.armies;
44
+ if (attackerUnits < 2)
45
+ continue;
46
+ if (attackerUnits < defenderUnits && faction.personality !== 'aggressive')
47
+ continue;
48
+ const result = resolveCombat(attackerUnits, 80, defenderUnits, 80, adj.type);
49
+ log.push(`${faction.name} attacks ${adj.name} from ${territory.name}!`);
50
+ const attackerArmy = findArmyInTerritory(faction.id, territoryId, state.armies);
51
+ if (attackerArmy)
52
+ applyCasualties(attackerArmy, result.attackerCasualties, faction, state.armies, territory);
53
+ if (result.captured) {
54
+ const oldOwner = adj.owner ? state.factions.get(adj.owner) : null;
55
+ if (oldOwner) {
56
+ oldOwner.territories = oldOwner.territories.filter((id) => id !== adjId);
57
+ const defArmy = findArmyInTerritory(adj.owner, adjId, state.armies);
58
+ if (defArmy)
59
+ applyCasualties(defArmy, result.defenderCasualties, oldOwner, state.armies, adj);
60
+ }
61
+ adj.owner = faction.id;
62
+ adj.armies = attackerArmy?.units ?? 0;
63
+ faction.territories.push(adjId);
64
+ log.push(` → ${faction.name} captured ${adj.name}! ${ICONS_FIRE}`);
65
+ }
66
+ else {
67
+ log.push(` → Attack on ${adj.name} failed`);
68
+ }
69
+ return log; // one attack per faction per turn
70
+ }
71
+ }
72
+ }
73
+ return log;
74
+ }
75
+ const ICONS_FIRE = '🔥';
@@ -0,0 +1,28 @@
1
+ import type { Army, Faction, Territory } from '../game-types.js';
2
+ /**
3
+ * Recruit new army units in a territory owned by the faction.
4
+ * Returns error message or null on success.
5
+ */
6
+ export declare function recruitArmy(faction: Faction, territory: Territory, units: number, armies: Map<string, Army>): string | null;
7
+ /**
8
+ * Move an army from one territory to an adjacent territory.
9
+ * Returns error message or null on success.
10
+ */
11
+ export declare function moveArmy(army: Army, fromTerritory: Territory, toTerritory: Territory): string | null;
12
+ /**
13
+ * Find any army belonging to a faction in a specific territory.
14
+ */
15
+ export declare function findArmyInTerritory(factionId: string, territoryId: string, armies: Map<string, Army>): Army | undefined;
16
+ /**
17
+ * Get total units a faction has in a given territory.
18
+ */
19
+ export declare function getUnitsInTerritory(factionId: string, territoryId: string, armies: Map<string, Army>): number;
20
+ /**
21
+ * Remove casualties from army after combat. Destroys army if no units remain.
22
+ */
23
+ export declare function applyCasualties(army: Army, casualties: number, faction: Faction, armies: Map<string, Army>, territory: Territory): void;
24
+ /**
25
+ * Ensure an Army record exists for a faction in a territory.
26
+ * Fixes desync where territory.armies > 0 but no Army object exists.
27
+ */
28
+ export declare function ensureArmyRecord(factionId: string, territory: Territory, armies: Map<string, Army>): void;
@@ -0,0 +1,111 @@
1
+ // Army recruitment, movement, and validation logic
2
+ import { canAfford, deductResources } from './resource-calculator.js';
3
+ const RECRUIT_COST_PER_UNIT = { gold: 3, food: 2, wood: 0, stone: 0 };
4
+ const DEFAULT_MORALE = 80;
5
+ let armyIdCounter = 1;
6
+ function generateArmyId() {
7
+ return `army_${Date.now()}_${armyIdCounter++}`;
8
+ }
9
+ /**
10
+ * Recruit new army units in a territory owned by the faction.
11
+ * Returns error message or null on success.
12
+ */
13
+ export function recruitArmy(faction, territory, units, armies) {
14
+ if (territory.owner !== faction.id) {
15
+ return `${territory.name} is not owned by ${faction.name}.`;
16
+ }
17
+ if (units < 1) {
18
+ return 'Must recruit at least 1 unit.';
19
+ }
20
+ const totalCost = {
21
+ gold: RECRUIT_COST_PER_UNIT.gold * units,
22
+ food: RECRUIT_COST_PER_UNIT.food * units,
23
+ };
24
+ if (!canAfford(faction, totalCost)) {
25
+ return `Not enough resources. Need ${totalCost.gold} gold and ${totalCost.food} food.`;
26
+ }
27
+ deductResources(faction, totalCost);
28
+ faction.totalArmies += units;
29
+ territory.armies += units;
30
+ // Create or merge army in territory
31
+ const existingArmy = findArmyInTerritory(faction.id, territory.id, armies);
32
+ if (existingArmy) {
33
+ existingArmy.units += units;
34
+ }
35
+ else {
36
+ const newArmy = {
37
+ id: generateArmyId(),
38
+ factionId: faction.id,
39
+ units,
40
+ morale: DEFAULT_MORALE,
41
+ territoryId: territory.id,
42
+ };
43
+ armies.set(newArmy.id, newArmy);
44
+ }
45
+ return null;
46
+ }
47
+ /**
48
+ * Move an army from one territory to an adjacent territory.
49
+ * Returns error message or null on success.
50
+ */
51
+ export function moveArmy(army, fromTerritory, toTerritory) {
52
+ if (army.territoryId !== fromTerritory.id) {
53
+ return 'Army is not in the specified territory.';
54
+ }
55
+ if (!fromTerritory.adjacentTo.includes(toTerritory.id)) {
56
+ return `${toTerritory.name} is not adjacent to ${fromTerritory.name}.`;
57
+ }
58
+ // Move the army
59
+ fromTerritory.armies -= army.units;
60
+ toTerritory.armies += army.units;
61
+ army.territoryId = toTerritory.id;
62
+ return null;
63
+ }
64
+ /**
65
+ * Find any army belonging to a faction in a specific territory.
66
+ */
67
+ export function findArmyInTerritory(factionId, territoryId, armies) {
68
+ for (const army of armies.values()) {
69
+ if (army.factionId === factionId && army.territoryId === territoryId) {
70
+ return army;
71
+ }
72
+ }
73
+ return undefined;
74
+ }
75
+ /**
76
+ * Get total units a faction has in a given territory.
77
+ */
78
+ export function getUnitsInTerritory(factionId, territoryId, armies) {
79
+ let total = 0;
80
+ for (const army of armies.values()) {
81
+ if (army.factionId === factionId && army.territoryId === territoryId) {
82
+ total += army.units;
83
+ }
84
+ }
85
+ return total;
86
+ }
87
+ /**
88
+ * Remove casualties from army after combat. Destroys army if no units remain.
89
+ */
90
+ export function applyCasualties(army, casualties, faction, armies, territory) {
91
+ const actual = Math.min(casualties, army.units);
92
+ army.units -= actual;
93
+ faction.totalArmies -= actual;
94
+ territory.armies -= actual;
95
+ if (army.units <= 0) {
96
+ armies.delete(army.id);
97
+ }
98
+ }
99
+ /**
100
+ * Ensure an Army record exists for a faction in a territory.
101
+ * Fixes desync where territory.armies > 0 but no Army object exists.
102
+ */
103
+ export function ensureArmyRecord(factionId, territory, armies) {
104
+ if (territory.armies > 0 && territory.owner === factionId) {
105
+ const existing = findArmyInTerritory(factionId, territory.id, armies);
106
+ if (!existing) {
107
+ const id = `army_fix_${Date.now()}`;
108
+ armies.set(id, { id, factionId, units: territory.armies, morale: 80, territoryId: territory.id });
109
+ }
110
+ }
111
+ }
@@ -0,0 +1,10 @@
1
+ import type { CombatResult, TerritoryType } from '../game-types.js';
2
+ /**
3
+ * Resolve combat between attacker and defender armies.
4
+ * Uses a power ratio formula to determine outcome and casualties.
5
+ */
6
+ export declare function resolveCombat(attackerUnits: number, attackerMorale: number, defenderUnits: number, defenderMorale: number, terrain: TerritoryType): CombatResult;
7
+ /**
8
+ * Simulate AI faction auto-attack decision: returns true if AI should attack.
9
+ */
10
+ export declare function shouldAiAttack(personality: string, attackerUnits: number, defenderUnits: number): boolean;