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 +137 -0
- package/assets/gameplay-output.txt +117 -0
- package/dist/data/default-world-map.d.ts +3 -0
- package/dist/data/default-world-map.js +133 -0
- package/dist/engine/ai-turn-processor.d.ts +2 -0
- package/dist/engine/ai-turn-processor.js +75 -0
- package/dist/engine/army-manager.d.ts +28 -0
- package/dist/engine/army-manager.js +111 -0
- package/dist/engine/combat-resolver.d.ts +10 -0
- package/dist/engine/combat-resolver.js +73 -0
- package/dist/engine/resource-calculator.d.ts +22 -0
- package/dist/engine/resource-calculator.js +81 -0
- package/dist/game-types.d.ts +68 -0
- package/dist/game-types.js +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +320 -0
- package/dist/state/game-state-manager.d.ts +30 -0
- package/dist/state/game-state-manager.js +122 -0
- package/dist/ui/display-helpers.d.ts +8 -0
- package/dist/ui/display-helpers.js +80 -0
- package/package.json +27 -0
- package/src/data/default-world-map.ts +137 -0
- package/src/engine/ai-turn-processor.ts +76 -0
- package/src/engine/army-manager.ts +156 -0
- package/src/engine/combat-resolver.ts +92 -0
- package/src/engine/resource-calculator.ts +95 -0
- package/src/game-types.ts +80 -0
- package/src/index.ts +264 -0
- package/src/state/game-state-manager.ts +134 -0
- package/src/ui/display-helpers.ts +90 -0
- package/tsconfig.json +15 -0
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,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,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;
|