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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Game state creation, serialization, save/load to ~/.empire-cli/saves/
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { DEFAULT_TERRITORIES, DEFAULT_FACTIONS } from '../data/default-world-map.js';
|
|
6
|
+
const SAVES_DIR = join(homedir(), '.empire-cli', 'saves');
|
|
7
|
+
function ensureSavesDir() {
|
|
8
|
+
if (!existsSync(SAVES_DIR)) {
|
|
9
|
+
mkdirSync(SAVES_DIR, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Create a fresh game state with default map and factions.
|
|
14
|
+
*/
|
|
15
|
+
export function newGame(playerFactionId) {
|
|
16
|
+
const territories = new Map(DEFAULT_TERRITORIES.map((t) => [t.id, { ...t }]));
|
|
17
|
+
const factions = new Map(DEFAULT_FACTIONS.map((f) => [f.id, { ...f, territories: [...f.territories] }]));
|
|
18
|
+
// Build initial armies from territory data
|
|
19
|
+
const armies = new Map();
|
|
20
|
+
let armyCounter = 1;
|
|
21
|
+
for (const territory of territories.values()) {
|
|
22
|
+
if (territory.owner && territory.armies > 0) {
|
|
23
|
+
const armyId = `army_start_${armyCounter++}`;
|
|
24
|
+
armies.set(armyId, {
|
|
25
|
+
id: armyId,
|
|
26
|
+
factionId: territory.owner,
|
|
27
|
+
units: territory.armies,
|
|
28
|
+
morale: 80,
|
|
29
|
+
territoryId: territory.id,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
turn: 1,
|
|
35
|
+
territories,
|
|
36
|
+
factions,
|
|
37
|
+
armies,
|
|
38
|
+
playerFactionId,
|
|
39
|
+
gameLog: ['A new empire rises. Your destiny awaits.'],
|
|
40
|
+
isOver: false,
|
|
41
|
+
winner: null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Serialize GameState to JSON-compatible SaveData.
|
|
46
|
+
*/
|
|
47
|
+
export function toSaveData(state) {
|
|
48
|
+
return {
|
|
49
|
+
turn: state.turn,
|
|
50
|
+
territories: Object.fromEntries(state.territories),
|
|
51
|
+
factions: Object.fromEntries(state.factions),
|
|
52
|
+
armies: Object.fromEntries(state.armies),
|
|
53
|
+
playerFactionId: state.playerFactionId,
|
|
54
|
+
gameLog: state.gameLog.slice(-50), // keep last 50 entries
|
|
55
|
+
isOver: state.isOver,
|
|
56
|
+
winner: state.winner,
|
|
57
|
+
savedAt: new Date().toISOString(),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Deserialize SaveData back into a live GameState.
|
|
62
|
+
*/
|
|
63
|
+
export function fromSaveData(data) {
|
|
64
|
+
return {
|
|
65
|
+
turn: data.turn,
|
|
66
|
+
territories: new Map(Object.entries(data.territories)),
|
|
67
|
+
factions: new Map(Object.entries(data.factions)),
|
|
68
|
+
armies: new Map(Object.entries(data.armies)),
|
|
69
|
+
playerFactionId: data.playerFactionId,
|
|
70
|
+
gameLog: data.gameLog,
|
|
71
|
+
isOver: data.isOver,
|
|
72
|
+
winner: data.winner,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Save game to ~/.empire-cli/saves/<slot>.json
|
|
77
|
+
*/
|
|
78
|
+
export function saveGame(state, slot = 'autosave') {
|
|
79
|
+
ensureSavesDir();
|
|
80
|
+
const filePath = join(SAVES_DIR, `${slot}.json`);
|
|
81
|
+
const data = toSaveData(state);
|
|
82
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Load game from ~/.empire-cli/saves/<slot>.json
|
|
86
|
+
* Returns null if save not found.
|
|
87
|
+
*/
|
|
88
|
+
export function loadGame(slot = 'autosave') {
|
|
89
|
+
ensureSavesDir();
|
|
90
|
+
const filePath = join(SAVES_DIR, `${slot}.json`);
|
|
91
|
+
if (!existsSync(filePath))
|
|
92
|
+
return null;
|
|
93
|
+
try {
|
|
94
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
95
|
+
const data = JSON.parse(raw);
|
|
96
|
+
return fromSaveData(data);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* List available save slots.
|
|
104
|
+
*/
|
|
105
|
+
export function listSaves() {
|
|
106
|
+
ensureSavesDir();
|
|
107
|
+
return readdirSync(SAVES_DIR)
|
|
108
|
+
.filter((f) => f.endsWith('.json'))
|
|
109
|
+
.map((f) => f.replace('.json', ''));
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Check win condition: a faction owns all territories.
|
|
113
|
+
*/
|
|
114
|
+
export function checkWinCondition(state) {
|
|
115
|
+
const totalTerritories = state.territories.size;
|
|
116
|
+
for (const faction of state.factions.values()) {
|
|
117
|
+
if (faction.territories.length >= totalTerritories) {
|
|
118
|
+
return faction.id;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { GameState } from '../game-types.js';
|
|
2
|
+
export declare const ICONS: Record<string, string>;
|
|
3
|
+
export declare function printLine(msg?: string): void;
|
|
4
|
+
export declare function printSeparator(): void;
|
|
5
|
+
export declare function printStatus(state: GameState): void;
|
|
6
|
+
export declare function printHelp(): void;
|
|
7
|
+
export declare function printMap(state: GameState): void;
|
|
8
|
+
export declare function printTerritoryInfo(state: GameState, territoryName: string): void;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// UI display helpers: icons, status, map, help text
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
export const ICONS = {
|
|
4
|
+
city: '🏰', forest: '🌲', mountain: '⛰️ ', plains: '🌾',
|
|
5
|
+
gold: '💰', food: '🍖', wood: '🪵', stone: '🪨',
|
|
6
|
+
army: '⚔️ ', skull: '💀', crown: '👑', shield: '🛡️ ',
|
|
7
|
+
flag: '🚩', peace: '🕊️ ', fire: '🔥',
|
|
8
|
+
};
|
|
9
|
+
export function printLine(msg = '') {
|
|
10
|
+
console.log(msg);
|
|
11
|
+
}
|
|
12
|
+
export function printSeparator() {
|
|
13
|
+
console.log(chalk.gray('─'.repeat(60)));
|
|
14
|
+
}
|
|
15
|
+
export function printStatus(state) {
|
|
16
|
+
const player = state.factions.get(state.playerFactionId);
|
|
17
|
+
const colorFn = chalk[player.color] ?? chalk.white;
|
|
18
|
+
printSeparator();
|
|
19
|
+
printLine(colorFn(` ${ICONS.crown} ${player.name} — Turn ${state.turn}`));
|
|
20
|
+
printLine(` ${ICONS.gold} ${chalk.yellow(player.gold)} ${ICONS.food} ${chalk.green(player.food)} ${ICONS.wood} ${chalk.blue(player.wood)} ${ICONS.stone} ${chalk.gray(player.stone)}`);
|
|
21
|
+
printLine(` ${ICONS.army} Armies: ${player.totalArmies} | ${ICONS.flag} Territories: ${player.territories.length}/${state.territories.size}`);
|
|
22
|
+
printSeparator();
|
|
23
|
+
}
|
|
24
|
+
export function printHelp() {
|
|
25
|
+
printLine(chalk.cyan('\n Commands:'));
|
|
26
|
+
printLine(' look — show world map');
|
|
27
|
+
printLine(' info <territory> — show territory details');
|
|
28
|
+
printLine(' status — show your resources');
|
|
29
|
+
printLine(' move <from> <to> [n] — move n units between territories (all if omitted)');
|
|
30
|
+
printLine(' recruit <territory> <n> — recruit n units (3💰 + 2🍖 each)');
|
|
31
|
+
printLine(' attack <from> <to> — attack enemy territory from yours');
|
|
32
|
+
printLine(' next — end turn (enemies act after this)');
|
|
33
|
+
printLine(' save [slot] — save game');
|
|
34
|
+
printLine(' quit — exit game');
|
|
35
|
+
printLine(' help — show this help');
|
|
36
|
+
printLine('');
|
|
37
|
+
}
|
|
38
|
+
export function printMap(state) {
|
|
39
|
+
const playerId = state.playerFactionId;
|
|
40
|
+
printLine(chalk.cyan('\n World Map:'));
|
|
41
|
+
printLine('');
|
|
42
|
+
for (const t of state.territories.values()) {
|
|
43
|
+
const ownerFaction = t.owner ? state.factions.get(t.owner) : null;
|
|
44
|
+
const ownerName = ownerFaction?.name ?? chalk.gray('Unclaimed');
|
|
45
|
+
const colorFn = ownerFaction ? (chalk[ownerFaction.color] ?? chalk.white) : chalk.gray;
|
|
46
|
+
const icon = ICONS[t.type] ?? '?';
|
|
47
|
+
const armies = t.armies > 0 ? ` ${ICONS.army} ${t.armies}` : '';
|
|
48
|
+
const yours = t.owner === playerId ? chalk.green(' ★') : '';
|
|
49
|
+
printLine(` ${icon} ${colorFn(t.name.padEnd(14))} ${colorFn(ownerName)}${armies}${yours}`);
|
|
50
|
+
const adjNames = t.adjacentTo.map((id) => state.territories.get(id)?.name ?? id).join(', ');
|
|
51
|
+
printLine(chalk.gray(` ↔ ${adjNames}`));
|
|
52
|
+
}
|
|
53
|
+
printLine('');
|
|
54
|
+
}
|
|
55
|
+
// Show detailed info about a specific territory
|
|
56
|
+
export function printTerritoryInfo(state, territoryName) {
|
|
57
|
+
const t = [...state.territories.values()].find((t) => t.name.toLowerCase().includes(territoryName.toLowerCase()));
|
|
58
|
+
if (!t) {
|
|
59
|
+
printLine(chalk.red(`Territory "${territoryName}" not found.`));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const ownerFaction = t.owner ? state.factions.get(t.owner) : null;
|
|
63
|
+
const colorFn = ownerFaction ? (chalk[ownerFaction.color] ?? chalk.white) : chalk.gray;
|
|
64
|
+
const icon = ICONS[t.type] ?? '';
|
|
65
|
+
const playerId = state.playerFactionId;
|
|
66
|
+
printLine(`\n ${icon} ${colorFn(t.name)} (${t.type})`);
|
|
67
|
+
printLine(` Owner: ${ownerFaction?.name ?? 'Unclaimed'}`);
|
|
68
|
+
printLine(` ${ICONS.army} Armies: ${t.armies}`);
|
|
69
|
+
printLine(` Resources/turn: ${ICONS.gold}${t.resources.gold} ${ICONS.food}${t.resources.food} ${ICONS.wood}${t.resources.wood} ${ICONS.stone}${t.resources.stone}`);
|
|
70
|
+
printLine(` Neighbors:`);
|
|
71
|
+
for (const adjId of t.adjacentTo) {
|
|
72
|
+
const adj = state.territories.get(adjId);
|
|
73
|
+
const adjOwner = adj.owner ? state.factions.get(adj.owner)?.name ?? '?' : 'Unclaimed';
|
|
74
|
+
const adjIcon = ICONS[adj.type] ?? '';
|
|
75
|
+
const threat = adj.owner !== playerId && adj.armies > 0 ? chalk.red(` ${ICONS.army}${adj.armies}`) : '';
|
|
76
|
+
const friendly = adj.owner === playerId ? chalk.green(` ★`) : '';
|
|
77
|
+
printLine(` → ${adjIcon} ${adj.name} — ${adjOwner}${friendly}${threat}`);
|
|
78
|
+
}
|
|
79
|
+
printLine('');
|
|
80
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "empire-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI strategy RPG with AI game master",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"empire-cli": "dist/index.js",
|
|
9
|
+
"empire": "dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"start": "tsx src/index.ts",
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"dev": "tsx src/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"keywords": ["cli", "game", "rpg", "strategy", "ai"],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"chalk": "^5.3.0",
|
|
20
|
+
"@google/generative-ai": "^0.24.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"typescript": "^5.3.0",
|
|
24
|
+
"tsx": "^4.7.0",
|
|
25
|
+
"@types/node": "^20.0.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
|
|
11
|
+
import type { Territory, Faction } from '../game-types.js';
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_TERRITORIES: Territory[] = [
|
|
14
|
+
{
|
|
15
|
+
id: 'northkeep',
|
|
16
|
+
name: 'Northkeep',
|
|
17
|
+
type: 'city',
|
|
18
|
+
owner: 'iron_legion',
|
|
19
|
+
armies: 3,
|
|
20
|
+
resources: { gold: 4, food: 2, wood: 1, stone: 2 },
|
|
21
|
+
adjacentTo: ['iron_hills', 'greenwood'],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'iron_hills',
|
|
25
|
+
name: 'Iron Hills',
|
|
26
|
+
type: 'mountain',
|
|
27
|
+
owner: 'iron_legion',
|
|
28
|
+
armies: 2,
|
|
29
|
+
resources: { gold: 2, food: 1, wood: 0, stone: 4 },
|
|
30
|
+
adjacentTo: ['northkeep', 'crossroads'],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'greenwood',
|
|
34
|
+
name: 'Greenwood',
|
|
35
|
+
type: 'forest',
|
|
36
|
+
owner: 'green_pact',
|
|
37
|
+
armies: 2,
|
|
38
|
+
resources: { gold: 1, food: 3, wood: 4, stone: 0 },
|
|
39
|
+
adjacentTo: ['northkeep', 'crossroads', 'silver_bay'],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'crossroads',
|
|
43
|
+
name: 'Crossroads',
|
|
44
|
+
type: 'plains',
|
|
45
|
+
owner: null,
|
|
46
|
+
armies: 0,
|
|
47
|
+
resources: { gold: 2, food: 2, wood: 1, stone: 1 },
|
|
48
|
+
adjacentTo: ['iron_hills', 'greenwood', 'desert_gate', 'stonehaven'],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'desert_gate',
|
|
52
|
+
name: 'Desert Gate',
|
|
53
|
+
type: 'plains',
|
|
54
|
+
owner: 'sand_empire',
|
|
55
|
+
armies: 2,
|
|
56
|
+
resources: { gold: 3, food: 1, wood: 0, stone: 2 },
|
|
57
|
+
adjacentTo: ['crossroads'],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'silver_bay',
|
|
61
|
+
name: 'Silver Bay',
|
|
62
|
+
type: 'city',
|
|
63
|
+
owner: 'green_pact',
|
|
64
|
+
armies: 3,
|
|
65
|
+
resources: { gold: 5, food: 2, wood: 1, stone: 1 },
|
|
66
|
+
adjacentTo: ['greenwood', 'stonehaven'],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'stonehaven',
|
|
70
|
+
name: 'Stonehaven',
|
|
71
|
+
type: 'mountain',
|
|
72
|
+
owner: 'sand_empire',
|
|
73
|
+
armies: 2,
|
|
74
|
+
resources: { gold: 2, food: 1, wood: 0, stone: 5 },
|
|
75
|
+
adjacentTo: ['crossroads', 'silver_bay', 'dragon_peak'],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'dragon_peak',
|
|
79
|
+
name: 'Dragon Peak',
|
|
80
|
+
type: 'mountain',
|
|
81
|
+
owner: 'void_covenant',
|
|
82
|
+
armies: 4,
|
|
83
|
+
resources: { gold: 1, food: 0, wood: 0, stone: 6 },
|
|
84
|
+
adjacentTo: ['stonehaven'],
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
export const DEFAULT_FACTIONS: Faction[] = [
|
|
89
|
+
{
|
|
90
|
+
id: 'iron_legion',
|
|
91
|
+
name: 'Iron Legion',
|
|
92
|
+
personality: 'aggressive',
|
|
93
|
+
color: 'red',
|
|
94
|
+
territories: ['northkeep', 'iron_hills'],
|
|
95
|
+
gold: 20,
|
|
96
|
+
food: 10,
|
|
97
|
+
wood: 5,
|
|
98
|
+
stone: 15,
|
|
99
|
+
totalArmies: 5,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: 'green_pact',
|
|
103
|
+
name: 'Green Pact',
|
|
104
|
+
personality: 'defensive',
|
|
105
|
+
color: 'green',
|
|
106
|
+
territories: ['greenwood', 'silver_bay'],
|
|
107
|
+
gold: 15,
|
|
108
|
+
food: 20,
|
|
109
|
+
wood: 25,
|
|
110
|
+
stone: 5,
|
|
111
|
+
totalArmies: 5,
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: 'sand_empire',
|
|
115
|
+
name: 'Sand Empire',
|
|
116
|
+
personality: 'mercantile',
|
|
117
|
+
color: 'yellow',
|
|
118
|
+
territories: ['desert_gate', 'stonehaven'],
|
|
119
|
+
gold: 30,
|
|
120
|
+
food: 8,
|
|
121
|
+
wood: 2,
|
|
122
|
+
stone: 10,
|
|
123
|
+
totalArmies: 4,
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: 'void_covenant',
|
|
127
|
+
name: 'Void Covenant',
|
|
128
|
+
personality: 'diplomatic',
|
|
129
|
+
color: 'magenta',
|
|
130
|
+
territories: ['dragon_peak'],
|
|
131
|
+
gold: 10,
|
|
132
|
+
food: 5,
|
|
133
|
+
wood: 2,
|
|
134
|
+
stone: 20,
|
|
135
|
+
totalArmies: 4,
|
|
136
|
+
},
|
|
137
|
+
];
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// AI faction turn processing — simple decision-making for enemy factions
|
|
2
|
+
import type { GameState } from '../game-types.js';
|
|
3
|
+
import { resolveCombat } from './combat-resolver.js';
|
|
4
|
+
import { findArmyInTerritory, getUnitsInTerritory, applyCasualties } from './army-manager.js';
|
|
5
|
+
|
|
6
|
+
export function runAiTurns(state: GameState): string[] {
|
|
7
|
+
const log: string[] = [];
|
|
8
|
+
for (const faction of state.factions.values()) {
|
|
9
|
+
if (faction.id === state.playerFactionId) continue;
|
|
10
|
+
if (faction.territories.length === 0) continue;
|
|
11
|
+
|
|
12
|
+
// Simple AI: recruit if has resources, then try to expand
|
|
13
|
+
// Recruit in first territory if affordable
|
|
14
|
+
if (faction.gold >= 6 && faction.food >= 4) {
|
|
15
|
+
const recruitTerrId = faction.territories[0];
|
|
16
|
+
const recruitTerr = state.territories.get(recruitTerrId);
|
|
17
|
+
if (recruitTerr) {
|
|
18
|
+
const units = Math.min(Math.floor(faction.gold / 3), Math.floor(faction.food / 2), 3);
|
|
19
|
+
if (units > 0) {
|
|
20
|
+
faction.gold -= units * 3;
|
|
21
|
+
faction.food -= units * 2;
|
|
22
|
+
faction.totalArmies += units;
|
|
23
|
+
recruitTerr.armies += units;
|
|
24
|
+
// Create/merge army record
|
|
25
|
+
const existing = findArmyInTerritory(faction.id, recruitTerrId, state.armies);
|
|
26
|
+
if (existing) { existing.units += units; }
|
|
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
|
+
|
|
36
|
+
// Try to attack one adjacent weak territory
|
|
37
|
+
for (const territoryId of [...faction.territories]) {
|
|
38
|
+
const territory = state.territories.get(territoryId)!;
|
|
39
|
+
for (const adjId of territory.adjacentTo) {
|
|
40
|
+
const adj = state.territories.get(adjId)!;
|
|
41
|
+
if (adj.owner === faction.id) continue;
|
|
42
|
+
|
|
43
|
+
const attackerUnits = getUnitsInTerritory(faction.id, territoryId, state.armies);
|
|
44
|
+
const defenderUnits = adj.armies;
|
|
45
|
+
|
|
46
|
+
if (attackerUnits < 2) continue;
|
|
47
|
+
if (attackerUnits < defenderUnits && faction.personality !== 'aggressive') continue;
|
|
48
|
+
|
|
49
|
+
const result = resolveCombat(attackerUnits, 80, defenderUnits, 80, adj.type);
|
|
50
|
+
log.push(`${faction.name} attacks ${adj.name} from ${territory.name}!`);
|
|
51
|
+
|
|
52
|
+
const attackerArmy = findArmyInTerritory(faction.id, territoryId, state.armies);
|
|
53
|
+
if (attackerArmy) applyCasualties(attackerArmy, result.attackerCasualties, faction, state.armies, territory);
|
|
54
|
+
|
|
55
|
+
if (result.captured) {
|
|
56
|
+
const oldOwner = adj.owner ? state.factions.get(adj.owner) : null;
|
|
57
|
+
if (oldOwner) {
|
|
58
|
+
oldOwner.territories = oldOwner.territories.filter((id) => id !== adjId);
|
|
59
|
+
const defArmy = findArmyInTerritory(adj.owner!, adjId, state.armies);
|
|
60
|
+
if (defArmy) applyCasualties(defArmy, result.defenderCasualties, oldOwner, state.armies, adj);
|
|
61
|
+
}
|
|
62
|
+
adj.owner = faction.id;
|
|
63
|
+
adj.armies = attackerArmy?.units ?? 0;
|
|
64
|
+
faction.territories.push(adjId);
|
|
65
|
+
log.push(` → ${faction.name} captured ${adj.name}! ${ICONS_FIRE}`);
|
|
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
|
+
|
|
76
|
+
const ICONS_FIRE = '🔥';
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Army recruitment, movement, and validation logic
|
|
2
|
+
|
|
3
|
+
import type { Army, Faction, Territory, GameState } from '../game-types.js';
|
|
4
|
+
import { canAfford, deductResources } from './resource-calculator.js';
|
|
5
|
+
|
|
6
|
+
const RECRUIT_COST_PER_UNIT = { gold: 3, food: 2, wood: 0, stone: 0 };
|
|
7
|
+
const DEFAULT_MORALE = 80;
|
|
8
|
+
|
|
9
|
+
let armyIdCounter = 1;
|
|
10
|
+
|
|
11
|
+
function generateArmyId(): string {
|
|
12
|
+
return `army_${Date.now()}_${armyIdCounter++}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Recruit new army units in a territory owned by the faction.
|
|
17
|
+
* Returns error message or null on success.
|
|
18
|
+
*/
|
|
19
|
+
export function recruitArmy(
|
|
20
|
+
faction: Faction,
|
|
21
|
+
territory: Territory,
|
|
22
|
+
units: number,
|
|
23
|
+
armies: Map<string, Army>
|
|
24
|
+
): string | null {
|
|
25
|
+
if (territory.owner !== faction.id) {
|
|
26
|
+
return `${territory.name} is not owned by ${faction.name}.`;
|
|
27
|
+
}
|
|
28
|
+
if (units < 1) {
|
|
29
|
+
return 'Must recruit at least 1 unit.';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const totalCost = {
|
|
33
|
+
gold: RECRUIT_COST_PER_UNIT.gold * units,
|
|
34
|
+
food: RECRUIT_COST_PER_UNIT.food * units,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
if (!canAfford(faction, totalCost)) {
|
|
38
|
+
return `Not enough resources. Need ${totalCost.gold} gold and ${totalCost.food} food.`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
deductResources(faction, totalCost);
|
|
42
|
+
faction.totalArmies += units;
|
|
43
|
+
territory.armies += units;
|
|
44
|
+
|
|
45
|
+
// Create or merge army in territory
|
|
46
|
+
const existingArmy = findArmyInTerritory(faction.id, territory.id, armies);
|
|
47
|
+
if (existingArmy) {
|
|
48
|
+
existingArmy.units += units;
|
|
49
|
+
} else {
|
|
50
|
+
const newArmy: Army = {
|
|
51
|
+
id: generateArmyId(),
|
|
52
|
+
factionId: faction.id,
|
|
53
|
+
units,
|
|
54
|
+
morale: DEFAULT_MORALE,
|
|
55
|
+
territoryId: territory.id,
|
|
56
|
+
};
|
|
57
|
+
armies.set(newArmy.id, newArmy);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Move an army from one territory to an adjacent territory.
|
|
65
|
+
* Returns error message or null on success.
|
|
66
|
+
*/
|
|
67
|
+
export function moveArmy(
|
|
68
|
+
army: Army,
|
|
69
|
+
fromTerritory: Territory,
|
|
70
|
+
toTerritory: Territory
|
|
71
|
+
): string | null {
|
|
72
|
+
if (army.territoryId !== fromTerritory.id) {
|
|
73
|
+
return 'Army is not in the specified territory.';
|
|
74
|
+
}
|
|
75
|
+
if (!fromTerritory.adjacentTo.includes(toTerritory.id)) {
|
|
76
|
+
return `${toTerritory.name} is not adjacent to ${fromTerritory.name}.`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Move the army
|
|
80
|
+
fromTerritory.armies -= army.units;
|
|
81
|
+
toTerritory.armies += army.units;
|
|
82
|
+
army.territoryId = toTerritory.id;
|
|
83
|
+
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Find any army belonging to a faction in a specific territory.
|
|
89
|
+
*/
|
|
90
|
+
export function findArmyInTerritory(
|
|
91
|
+
factionId: string,
|
|
92
|
+
territoryId: string,
|
|
93
|
+
armies: Map<string, Army>
|
|
94
|
+
): Army | undefined {
|
|
95
|
+
for (const army of armies.values()) {
|
|
96
|
+
if (army.factionId === factionId && army.territoryId === territoryId) {
|
|
97
|
+
return army;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get total units a faction has in a given territory.
|
|
105
|
+
*/
|
|
106
|
+
export function getUnitsInTerritory(
|
|
107
|
+
factionId: string,
|
|
108
|
+
territoryId: string,
|
|
109
|
+
armies: Map<string, Army>
|
|
110
|
+
): number {
|
|
111
|
+
let total = 0;
|
|
112
|
+
for (const army of armies.values()) {
|
|
113
|
+
if (army.factionId === factionId && army.territoryId === territoryId) {
|
|
114
|
+
total += army.units;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return total;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Remove casualties from army after combat. Destroys army if no units remain.
|
|
122
|
+
*/
|
|
123
|
+
export function applyCasualties(
|
|
124
|
+
army: Army,
|
|
125
|
+
casualties: number,
|
|
126
|
+
faction: Faction,
|
|
127
|
+
armies: Map<string, Army>,
|
|
128
|
+
territory: Territory
|
|
129
|
+
): void {
|
|
130
|
+
const actual = Math.min(casualties, army.units);
|
|
131
|
+
army.units -= actual;
|
|
132
|
+
faction.totalArmies -= actual;
|
|
133
|
+
territory.armies -= actual;
|
|
134
|
+
|
|
135
|
+
if (army.units <= 0) {
|
|
136
|
+
armies.delete(army.id);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Ensure an Army record exists for a faction in a territory.
|
|
142
|
+
* Fixes desync where territory.armies > 0 but no Army object exists.
|
|
143
|
+
*/
|
|
144
|
+
export function ensureArmyRecord(
|
|
145
|
+
factionId: string,
|
|
146
|
+
territory: Territory,
|
|
147
|
+
armies: Map<string, Army>
|
|
148
|
+
): void {
|
|
149
|
+
if (territory.armies > 0 && territory.owner === factionId) {
|
|
150
|
+
const existing = findArmyInTerritory(factionId, territory.id, armies);
|
|
151
|
+
if (!existing) {
|
|
152
|
+
const id = `army_fix_${Date.now()}`;
|
|
153
|
+
armies.set(id, { id, factionId, units: territory.armies, morale: 80, territoryId: territory.id });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|