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 +23 -43
- package/assets/sc-gameplay.png +0 -0
- package/assets/sc-menu.png +0 -0
- package/dist/data/default-world-map.js +8 -0
- package/dist/engine/ai-turn-processor.js +1 -1
- package/dist/engine/army-manager.js +5 -3
- package/dist/engine/building-manager.d.ts +25 -0
- package/dist/engine/building-manager.js +52 -0
- package/dist/engine/combat-resolver.d.ts +2 -2
- package/dist/engine/combat-resolver.js +7 -2
- package/dist/engine/resource-calculator.js +2 -1
- package/dist/game-types.d.ts +2 -0
- package/dist/index.js +158 -5
- package/dist/state/game-state-manager.js +5 -0
- package/dist/ui/display-helpers.js +6 -1
- package/package.json +1 -1
- package/src/data/default-world-map.ts +8 -0
- package/src/engine/ai-turn-processor.ts +1 -1
- package/src/engine/army-manager.ts +5 -3
- package/src/engine/building-manager.ts +72 -0
- package/src/engine/combat-resolver.ts +8 -3
- package/src/engine/resource-calculator.ts +2 -1
- package/src/game-types.ts +3 -0
- package/src/index.ts +146 -5
- package/src/state/game-state-manager.ts +4 -0
- package/src/ui/display-helpers.ts +6 -1
- package/assets/gameplay-output.txt +0 -117
package/README.md
CHANGED
|
@@ -1,47 +1,17 @@
|
|
|
1
1
|
# ⚔️ Empire CLI 👑
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/empire-cli)
|
|
4
|
+
[](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
|
-
##
|
|
10
|
+
## Screenshots
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
+

|
|
13
|
+
|
|
14
|
+

|
|
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. "
|
|
68
|
-
7. "
|
|
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** —
|
|
105
|
-
- 🪨 **Stone** —
|
|
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
|
-
- [
|
|
97
|
+
- [x] Buildings (walls, barracks, markets)
|
|
118
98
|
- [ ] More maps & factions
|
|
119
|
-
- [
|
|
99
|
+
- [x] npm package (`npx 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
|
-
|
|
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:
|
|
22
|
-
food:
|
|
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) *
|
|
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;
|
package/dist/game-types.d.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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:
|
|
34
|
-
food:
|
|
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) *
|
|
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
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|