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,92 @@
|
|
|
1
|
+
// Combat resolution engine: calculates battle outcomes based on units, morale, terrain
|
|
2
|
+
|
|
3
|
+
import type { CombatResult, TerritoryType } from '../game-types.js';
|
|
4
|
+
|
|
5
|
+
// Terrain defense bonuses (multiplier on defender power)
|
|
6
|
+
const TERRAIN_BONUS: Record<TerritoryType, number> = {
|
|
7
|
+
plains: 1.0,
|
|
8
|
+
forest: 1.2,
|
|
9
|
+
mountain: 1.5,
|
|
10
|
+
city: 1.3,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve combat between attacker and defender armies.
|
|
15
|
+
* Uses a power ratio formula to determine outcome and casualties.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveCombat(
|
|
18
|
+
attackerUnits: number,
|
|
19
|
+
attackerMorale: number,
|
|
20
|
+
defenderUnits: number,
|
|
21
|
+
defenderMorale: number,
|
|
22
|
+
terrain: TerritoryType
|
|
23
|
+
): CombatResult {
|
|
24
|
+
const log: string[] = [];
|
|
25
|
+
|
|
26
|
+
const terrainBonus = TERRAIN_BONUS[terrain];
|
|
27
|
+
const attackPower = attackerUnits * (attackerMorale / 100);
|
|
28
|
+
const defensePower = defenderUnits * (defenderMorale / 100) * terrainBonus;
|
|
29
|
+
|
|
30
|
+
log.push(`Attack power: ${attackPower.toFixed(1)} vs Defense power: ${defensePower.toFixed(1)}`);
|
|
31
|
+
log.push(`Terrain (${terrain}) gives defender x${terrainBonus} bonus`);
|
|
32
|
+
|
|
33
|
+
const ratio = attackPower / (defensePower || 1);
|
|
34
|
+
|
|
35
|
+
let outcome: CombatResult['outcome'];
|
|
36
|
+
let attackerCasualties: number;
|
|
37
|
+
let defenderCasualties: number;
|
|
38
|
+
let captured = false;
|
|
39
|
+
|
|
40
|
+
if (ratio >= 2.0) {
|
|
41
|
+
// Decisive victory — attacker dominates
|
|
42
|
+
outcome = 'decisive_victory';
|
|
43
|
+
attackerCasualties = Math.max(0, Math.floor(attackerUnits * 0.1));
|
|
44
|
+
defenderCasualties = defenderUnits; // all defenders lost
|
|
45
|
+
captured = true;
|
|
46
|
+
log.push('Decisive victory! The defenders are routed!');
|
|
47
|
+
} else if (ratio >= 1.2) {
|
|
48
|
+
// Regular victory
|
|
49
|
+
outcome = 'victory';
|
|
50
|
+
attackerCasualties = Math.max(0, Math.floor(attackerUnits * 0.25));
|
|
51
|
+
defenderCasualties = Math.max(0, Math.floor(defenderUnits * 0.6));
|
|
52
|
+
captured = true;
|
|
53
|
+
log.push('Victory! The territory has been captured.');
|
|
54
|
+
} else if (ratio >= 0.8) {
|
|
55
|
+
// Pyrrhic — costly win or stalemate resolved as attacker loss
|
|
56
|
+
outcome = 'pyrrhic_victory';
|
|
57
|
+
attackerCasualties = Math.max(0, Math.floor(attackerUnits * 0.5));
|
|
58
|
+
defenderCasualties = Math.max(0, Math.floor(defenderUnits * 0.4));
|
|
59
|
+
captured = ratio >= 1.0; // only capture if slightly above even
|
|
60
|
+
log.push(captured ? 'Pyrrhic victory — heavy losses on both sides.' : 'Stalemate — attackers withdraw.');
|
|
61
|
+
} else {
|
|
62
|
+
// Defeat
|
|
63
|
+
outcome = 'defeat';
|
|
64
|
+
attackerCasualties = Math.max(0, Math.floor(attackerUnits * 0.6));
|
|
65
|
+
defenderCasualties = Math.max(0, Math.floor(defenderUnits * 0.15));
|
|
66
|
+
captured = false;
|
|
67
|
+
log.push('Defeat! The attack has failed.');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
log.push(`Attacker losses: ${attackerCasualties} units`);
|
|
71
|
+
log.push(`Defender losses: ${defenderCasualties} units`);
|
|
72
|
+
|
|
73
|
+
return { attackerCasualties, defenderCasualties, outcome, captured, log };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Simulate AI faction auto-attack decision: returns true if AI should attack.
|
|
78
|
+
*/
|
|
79
|
+
export function shouldAiAttack(
|
|
80
|
+
personality: string,
|
|
81
|
+
attackerUnits: number,
|
|
82
|
+
defenderUnits: number
|
|
83
|
+
): boolean {
|
|
84
|
+
const ratio = attackerUnits / (defenderUnits || 1);
|
|
85
|
+
switch (personality) {
|
|
86
|
+
case 'aggressive': return ratio >= 1.0;
|
|
87
|
+
case 'defensive': return ratio >= 2.0;
|
|
88
|
+
case 'mercantile': return ratio >= 1.5;
|
|
89
|
+
case 'diplomatic': return ratio >= 2.5;
|
|
90
|
+
default: return ratio >= 1.5;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Resource collection and upkeep calculations for each faction per turn
|
|
2
|
+
|
|
3
|
+
import type { Faction, Territory, Resources } from '../game-types.js';
|
|
4
|
+
|
|
5
|
+
const ARMY_FOOD_UPKEEP = 1; // food per army unit per turn
|
|
6
|
+
const ARMY_GOLD_UPKEEP = 0; // gold per army unit (free for now)
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Calculate total resource income for a faction based on owned territories.
|
|
10
|
+
*/
|
|
11
|
+
export function collectResources(
|
|
12
|
+
faction: Faction,
|
|
13
|
+
territories: Map<string, Territory>
|
|
14
|
+
): Resources {
|
|
15
|
+
const income: Resources = { gold: 0, food: 0, wood: 0, stone: 0 };
|
|
16
|
+
|
|
17
|
+
for (const territoryId of faction.territories) {
|
|
18
|
+
const territory = territories.get(territoryId);
|
|
19
|
+
if (!territory) continue;
|
|
20
|
+
|
|
21
|
+
income.gold += territory.resources.gold;
|
|
22
|
+
income.food += territory.resources.food;
|
|
23
|
+
income.wood += territory.resources.wood;
|
|
24
|
+
income.stone += territory.resources.stone;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return income;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Calculate food upkeep cost for all armies in a faction.
|
|
32
|
+
*/
|
|
33
|
+
export function calculateUpkeep(faction: Faction): Resources {
|
|
34
|
+
return {
|
|
35
|
+
gold: faction.totalArmies * ARMY_GOLD_UPKEEP,
|
|
36
|
+
food: faction.totalArmies * ARMY_FOOD_UPKEEP,
|
|
37
|
+
wood: 0,
|
|
38
|
+
stone: 0,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Apply income and deduct upkeep from faction. Mutates faction in place.
|
|
44
|
+
* Returns net change log messages.
|
|
45
|
+
*/
|
|
46
|
+
export function processTurnResources(
|
|
47
|
+
faction: Faction,
|
|
48
|
+
territories: Map<string, Territory>
|
|
49
|
+
): string[] {
|
|
50
|
+
const income = collectResources(faction, territories);
|
|
51
|
+
const upkeep = calculateUpkeep(faction);
|
|
52
|
+
const log: string[] = [];
|
|
53
|
+
|
|
54
|
+
faction.gold += income.gold - upkeep.gold;
|
|
55
|
+
faction.food += income.food - upkeep.food;
|
|
56
|
+
faction.wood += income.wood - upkeep.wood;
|
|
57
|
+
faction.stone += income.stone - upkeep.stone;
|
|
58
|
+
|
|
59
|
+
// Clamp negatives — starvation means morale hit (handled elsewhere)
|
|
60
|
+
if (faction.food < 0) {
|
|
61
|
+
log.push(`${faction.name} is starving! Food deficit: ${faction.food}`);
|
|
62
|
+
faction.food = 0;
|
|
63
|
+
}
|
|
64
|
+
if (faction.gold < 0) {
|
|
65
|
+
log.push(`${faction.name} is bankrupt! Gold deficit: ${faction.gold}`);
|
|
66
|
+
faction.gold = 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
log.push(
|
|
70
|
+
`${faction.name} collected: +${income.gold}g +${income.food}f +${income.wood}w +${income.stone}s`
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return log;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check if a faction can afford a given resource cost.
|
|
78
|
+
*/
|
|
79
|
+
export function canAfford(faction: Faction, cost: Partial<Resources>): boolean {
|
|
80
|
+
if (cost.gold !== undefined && faction.gold < cost.gold) return false;
|
|
81
|
+
if (cost.food !== undefined && faction.food < cost.food) return false;
|
|
82
|
+
if (cost.wood !== undefined && faction.wood < cost.wood) return false;
|
|
83
|
+
if (cost.stone !== undefined && faction.stone < cost.stone) return false;
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Deduct resources from faction. Assumes canAfford check done first.
|
|
89
|
+
*/
|
|
90
|
+
export function deductResources(faction: Faction, cost: Partial<Resources>): void {
|
|
91
|
+
if (cost.gold) faction.gold -= cost.gold;
|
|
92
|
+
if (cost.food) faction.food -= cost.food;
|
|
93
|
+
if (cost.wood) faction.wood -= cost.wood;
|
|
94
|
+
if (cost.stone) faction.stone -= cost.stone;
|
|
95
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Core game type definitions for Empire CLI strategy RPG
|
|
2
|
+
|
|
3
|
+
export type TerritoryType = 'plains' | 'forest' | 'mountain' | 'city';
|
|
4
|
+
|
|
5
|
+
export type FactionPersonality = 'aggressive' | 'defensive' | 'diplomatic' | 'mercantile';
|
|
6
|
+
|
|
7
|
+
export interface Resources {
|
|
8
|
+
gold: number;
|
|
9
|
+
food: number;
|
|
10
|
+
wood: number;
|
|
11
|
+
stone: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Territory {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
type: TerritoryType;
|
|
18
|
+
owner: string | null; // faction id or null for unclaimed
|
|
19
|
+
armies: number;
|
|
20
|
+
resources: Resources; // base income per turn
|
|
21
|
+
adjacentTo: string[]; // territory ids
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Faction {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
personality: FactionPersonality;
|
|
28
|
+
color: string; // chalk color name
|
|
29
|
+
territories: string[]; // territory ids
|
|
30
|
+
gold: number;
|
|
31
|
+
food: number;
|
|
32
|
+
wood: number;
|
|
33
|
+
stone: number;
|
|
34
|
+
totalArmies: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Army {
|
|
38
|
+
id: string;
|
|
39
|
+
factionId: string;
|
|
40
|
+
units: number;
|
|
41
|
+
morale: number; // 0-100
|
|
42
|
+
territoryId: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CombatResult {
|
|
46
|
+
attackerCasualties: number;
|
|
47
|
+
defenderCasualties: number;
|
|
48
|
+
outcome: 'decisive_victory' | 'victory' | 'pyrrhic_victory' | 'defeat';
|
|
49
|
+
captured: boolean;
|
|
50
|
+
log: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface GameState {
|
|
54
|
+
turn: number;
|
|
55
|
+
territories: Map<string, Territory>;
|
|
56
|
+
factions: Map<string, Faction>;
|
|
57
|
+
armies: Map<string, Army>;
|
|
58
|
+
playerFactionId: string;
|
|
59
|
+
gameLog: string[];
|
|
60
|
+
isOver: boolean;
|
|
61
|
+
winner: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface Command {
|
|
65
|
+
type: string;
|
|
66
|
+
args: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Serializable version for JSON save files
|
|
70
|
+
export interface SaveData {
|
|
71
|
+
turn: number;
|
|
72
|
+
territories: Record<string, Territory>;
|
|
73
|
+
factions: Record<string, Faction>;
|
|
74
|
+
armies: Record<string, Army>;
|
|
75
|
+
playerFactionId: string;
|
|
76
|
+
gameLog: string[];
|
|
77
|
+
isOver: boolean;
|
|
78
|
+
winner: string | null;
|
|
79
|
+
savedAt: string;
|
|
80
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Empire CLI — main entry point: menu, game loop, command dispatch
|
|
3
|
+
// General (👑) commands armies from above — no player cursor
|
|
4
|
+
|
|
5
|
+
import readline from 'readline';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import type { GameState, Territory, Faction } from './game-types.js';
|
|
8
|
+
import { newGame, saveGame, loadGame, listSaves, checkWinCondition } from './state/game-state-manager.js';
|
|
9
|
+
import { processTurnResources } from './engine/resource-calculator.js';
|
|
10
|
+
import { resolveCombat } from './engine/combat-resolver.js';
|
|
11
|
+
import { recruitArmy, findArmyInTerritory, applyCasualties, getUnitsInTerritory, ensureArmyRecord } from './engine/army-manager.js';
|
|
12
|
+
import { printLine, printSeparator, printStatus, printHelp, printMap, printTerritoryInfo, ICONS } from './ui/display-helpers.js';
|
|
13
|
+
import { runAiTurns } from './engine/ai-turn-processor.js';
|
|
14
|
+
|
|
15
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
16
|
+
function ask(prompt: string): Promise<string> {
|
|
17
|
+
return new Promise((resolve) => rl.question(prompt, resolve));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ─── Territory finder helper ─────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function findTerritory(state: GameState, name: string): Territory | undefined {
|
|
23
|
+
return [...state.territories.values()].find(
|
|
24
|
+
(t) => t.name.toLowerCase().includes(name.toLowerCase())
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── Command processing ─────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
async function processCommand(input: string, state: GameState): Promise<boolean> {
|
|
31
|
+
const parts = input.trim().split(/\s+/);
|
|
32
|
+
const cmd = parts[0]?.toLowerCase() ?? '';
|
|
33
|
+
const args = parts.slice(1);
|
|
34
|
+
const player = state.factions.get(state.playerFactionId)!;
|
|
35
|
+
|
|
36
|
+
switch (cmd) {
|
|
37
|
+
case 'look': printMap(state); break;
|
|
38
|
+
case 'status': printStatus(state); break;
|
|
39
|
+
case 'help': printHelp(); break;
|
|
40
|
+
|
|
41
|
+
case 'info': {
|
|
42
|
+
if (!args[0]) { printLine('Usage: info <territory>'); break; }
|
|
43
|
+
printTerritoryInfo(state, args.join(' '));
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
case 'move': {
|
|
48
|
+
// move <from> <to> [n]
|
|
49
|
+
if (args.length < 2) { printLine('Usage: move <from> <to> [units]'); break; }
|
|
50
|
+
const lastArg = args[args.length - 1];
|
|
51
|
+
const unitCount = /^\d+$/.test(lastArg) ? parseInt(lastArg, 10) : 0;
|
|
52
|
+
// Split args: try to find two territory names, optional number at end
|
|
53
|
+
const nameArgs = unitCount > 0 ? args.slice(0, -1) : args;
|
|
54
|
+
const { from, to } = parseTwoTerritories(state, nameArgs);
|
|
55
|
+
if (!from || !to) { printLine(chalk.red('Could not find those territories. Try: move northkeep greenwood 3')); break; }
|
|
56
|
+
if (!from.adjacentTo.includes(to.id)) { printLine(chalk.red(`${to.name} is not adjacent to ${from.name}.`)); break; }
|
|
57
|
+
if (from.owner !== player.id) { printLine(chalk.red(`You don't own ${from.name}.`)); break; }
|
|
58
|
+
|
|
59
|
+
ensureArmyRecord(player.id, from, state.armies);
|
|
60
|
+
const army = findArmyInTerritory(player.id, from.id, state.armies);
|
|
61
|
+
if (!army || army.units === 0) { printLine(chalk.red(`No army in ${from.name}.`)); break; }
|
|
62
|
+
|
|
63
|
+
const toMove = unitCount > 0 ? Math.min(unitCount, army.units) : army.units;
|
|
64
|
+
const remaining = army.units - toMove;
|
|
65
|
+
if (toMove === 0) { printLine(chalk.red('Must move at least 1 unit.')); break; }
|
|
66
|
+
|
|
67
|
+
// Update source
|
|
68
|
+
if (remaining > 0) { army.units = remaining; from.armies = remaining; }
|
|
69
|
+
else { from.armies -= toMove; state.armies.delete(army.id); }
|
|
70
|
+
|
|
71
|
+
// Update destination — merge if friendly army exists
|
|
72
|
+
const destArmy = findArmyInTerritory(player.id, to.id, state.armies);
|
|
73
|
+
if (destArmy) { destArmy.units += toMove; }
|
|
74
|
+
else { state.armies.set(`army_${Date.now()}`, { id: `army_${Date.now()}`, factionId: player.id, units: toMove, morale: army.morale, territoryId: to.id }); }
|
|
75
|
+
to.armies += toMove;
|
|
76
|
+
|
|
77
|
+
const leftMsg = remaining > 0 ? chalk.gray(` (${remaining} garrison ${from.name})`) : '';
|
|
78
|
+
printLine(chalk.green(`${ICONS.army} Moved ${toMove} units: ${from.name} → ${to.name}${leftMsg}`));
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case 'recruit': {
|
|
83
|
+
// recruit <territory> <n>
|
|
84
|
+
if (args.length < 2) { printLine('Usage: recruit <territory> <n>'); break; }
|
|
85
|
+
const n = parseInt(args[args.length - 1], 10);
|
|
86
|
+
if (!n || n < 1) { printLine('Usage: recruit <territory> <n>'); break; }
|
|
87
|
+
const terrName = args.slice(0, -1).join(' ');
|
|
88
|
+
const terr = findTerritory(state, terrName);
|
|
89
|
+
if (!terr) { printLine(chalk.red(`Territory "${terrName}" not found.`)); break; }
|
|
90
|
+
if (terr.owner !== player.id) { printLine(chalk.red(`You don't own ${terr.name}.`)); break; }
|
|
91
|
+
|
|
92
|
+
const err = recruitArmy(player, terr, n, state.armies);
|
|
93
|
+
if (err) { printLine(chalk.red(err)); break; }
|
|
94
|
+
printLine(chalk.green(`${ICONS.shield} Recruited ${n} units in ${terr.name}. (${ICONS.gold}-${n * 3} ${ICONS.food}-${n * 2})`));
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
case 'attack': {
|
|
99
|
+
// attack <from> <to>
|
|
100
|
+
if (args.length < 2) { printLine('Usage: attack <from> <to>'); break; }
|
|
101
|
+
const { from, to } = parseTwoTerritories(state, args);
|
|
102
|
+
if (!from || !to) { printLine(chalk.red('Could not find those territories.')); break; }
|
|
103
|
+
if (from.owner !== player.id) { printLine(chalk.red(`You don't own ${from.name}.`)); break; }
|
|
104
|
+
if (to.owner === player.id) { printLine(chalk.red(`You already own ${to.name}.`)); break; }
|
|
105
|
+
if (!from.adjacentTo.includes(to.id)) { printLine(chalk.red(`${to.name} is not adjacent to ${from.name}.`)); break; }
|
|
106
|
+
|
|
107
|
+
ensureArmyRecord(player.id, from, state.armies);
|
|
108
|
+
const attackerArmy = findArmyInTerritory(player.id, from.id, state.armies);
|
|
109
|
+
if (!attackerArmy || attackerArmy.units === 0) { printLine(chalk.red(`No army in ${from.name}.`)); break; }
|
|
110
|
+
|
|
111
|
+
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);
|
|
113
|
+
result.log.forEach((l) => printLine(` ${l}`));
|
|
114
|
+
|
|
115
|
+
applyCasualties(attackerArmy, result.attackerCasualties, player, state.armies, from);
|
|
116
|
+
if (result.captured) {
|
|
117
|
+
const oldOwner = to.owner ? state.factions.get(to.owner) : null;
|
|
118
|
+
if (oldOwner) {
|
|
119
|
+
const defArmy = findArmyInTerritory(to.owner!, to.id, state.armies);
|
|
120
|
+
if (defArmy) applyCasualties(defArmy, result.defenderCasualties, oldOwner, state.armies, to);
|
|
121
|
+
oldOwner.territories = oldOwner.territories.filter((id) => id !== to.id);
|
|
122
|
+
}
|
|
123
|
+
to.owner = player.id;
|
|
124
|
+
to.armies = attackerArmy?.units ?? 0;
|
|
125
|
+
player.territories.push(to.id);
|
|
126
|
+
printLine(chalk.green(`\n ${ICONS.flag} You captured ${to.name}!`));
|
|
127
|
+
state.gameLog.push(`Turn ${state.turn}: Captured ${to.name}`);
|
|
128
|
+
} else {
|
|
129
|
+
printLine(chalk.red(`\n ${ICONS.skull} The attack failed.`));
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
case 'save': {
|
|
135
|
+
saveGame(state, args[0] ?? 'autosave');
|
|
136
|
+
printLine(chalk.green(`Game saved to "${args[0] ?? 'autosave'}".`));
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
case 'next': return true;
|
|
141
|
+
|
|
142
|
+
case 'quit': case 'exit':
|
|
143
|
+
printLine(chalk.gray('Farewell, Emperor.'));
|
|
144
|
+
rl.close(); process.exit(0); break;
|
|
145
|
+
|
|
146
|
+
default:
|
|
147
|
+
printLine(chalk.red(`Unknown command: "${cmd}". Type help.`));
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Parse two territory names from args ─────────────────────────────────────
|
|
153
|
+
// Tries to match territory names greedily from left, then right
|
|
154
|
+
function parseTwoTerritories(state: GameState, args: string[]): { from: Territory | undefined; to: Territory | undefined } {
|
|
155
|
+
// Try splitting at each position
|
|
156
|
+
for (let i = 1; i < args.length; i++) {
|
|
157
|
+
const fromName = args.slice(0, i).join(' ');
|
|
158
|
+
const toName = args.slice(i).join(' ');
|
|
159
|
+
const from = findTerritory(state, fromName);
|
|
160
|
+
const to = findTerritory(state, toName);
|
|
161
|
+
if (from && to && from.id !== to.id) return { from, to };
|
|
162
|
+
}
|
|
163
|
+
return { from: undefined, to: undefined };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── Game loop ───────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
async function runGameLoop(state: GameState): Promise<void> {
|
|
169
|
+
printLine(chalk.bold.cyan(`\n ${ICONS.crown} Welcome to Empire CLI ${ICONS.army}\n`));
|
|
170
|
+
printHelp();
|
|
171
|
+
|
|
172
|
+
const MAX_ACTIONS = 3; // actions per turn (look/info/status/help don't count)
|
|
173
|
+
const FREE_CMDS = new Set(['look', 'info', 'status', 'help', 'where', 'save']);
|
|
174
|
+
|
|
175
|
+
while (!state.isOver) {
|
|
176
|
+
printStatus(state);
|
|
177
|
+
let turnEnded = false;
|
|
178
|
+
let actionsUsed = 0;
|
|
179
|
+
while (!turnEnded) {
|
|
180
|
+
const remaining = MAX_ACTIONS - actionsUsed;
|
|
181
|
+
const input = await ask(chalk.cyan(` Turn ${state.turn} [${remaining}/${MAX_ACTIONS} actions] > `));
|
|
182
|
+
const cmd = input.trim().split(/\s+/)[0]?.toLowerCase() ?? '';
|
|
183
|
+
|
|
184
|
+
// Free commands don't consume actions
|
|
185
|
+
if (FREE_CMDS.has(cmd)) {
|
|
186
|
+
turnEnded = await processCommand(input, state);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (cmd === 'next') { turnEnded = true; continue; }
|
|
190
|
+
if (cmd === 'quit' || cmd === 'exit') { await processCommand(input, state); continue; }
|
|
191
|
+
|
|
192
|
+
// Action commands consume 1 action
|
|
193
|
+
turnEnded = await processCommand(input, state);
|
|
194
|
+
if (!turnEnded) actionsUsed++;
|
|
195
|
+
|
|
196
|
+
// Auto-end turn when actions exhausted
|
|
197
|
+
if (actionsUsed >= MAX_ACTIONS && !turnEnded) {
|
|
198
|
+
printLine(chalk.yellow(`\n ⏰ No actions remaining — turn ends automatically.`));
|
|
199
|
+
turnEnded = true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// === End of Turn ===
|
|
204
|
+
printLine(''); printSeparator();
|
|
205
|
+
printLine(chalk.bold.yellow(` --- End of Turn ${state.turn} ---`));
|
|
206
|
+
printLine('');
|
|
207
|
+
|
|
208
|
+
const resLogs: string[] = [];
|
|
209
|
+
for (const f of state.factions.values()) resLogs.push(...processTurnResources(f, state.territories));
|
|
210
|
+
const playerResLogs = resLogs.filter((l) => l.includes(state.factions.get(state.playerFactionId)!.name));
|
|
211
|
+
playerResLogs.forEach((l) => printLine(chalk.gray(` ${l}`)));
|
|
212
|
+
|
|
213
|
+
const aiLogs = runAiTurns(state);
|
|
214
|
+
if (aiLogs.length > 0) {
|
|
215
|
+
printLine(''); printLine(chalk.bold.red(` ${ICONS.fire} Enemy Actions:`));
|
|
216
|
+
aiLogs.forEach((l) => printLine(chalk.red(` ${l}`)));
|
|
217
|
+
} else {
|
|
218
|
+
printLine(chalk.gray(' The other factions bide their time...'));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
[...resLogs, ...aiLogs].forEach((l) => state.gameLog.push(l));
|
|
222
|
+
printLine(''); printSeparator();
|
|
223
|
+
state.turn++;
|
|
224
|
+
|
|
225
|
+
const winner = checkWinCondition(state);
|
|
226
|
+
if (winner) {
|
|
227
|
+
state.isOver = true; state.winner = winner;
|
|
228
|
+
printLine(chalk.bold.yellow(`\n ${ICONS.crown} ${state.factions.get(winner)!.name} has conquered the world! Game over.\n`));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─── Main menu ───────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
async function mainMenu(): Promise<void> {
|
|
236
|
+
printLine(chalk.bold.cyan('\n ╔════════════════════════════╗'));
|
|
237
|
+
printLine(chalk.bold.cyan(' ║ ⚔️ E M P I R E CLI 👑 ║'));
|
|
238
|
+
printLine(chalk.bold.cyan(' ╚════════════════════════════╝\n'));
|
|
239
|
+
printLine(' 1. New Game');
|
|
240
|
+
printLine(' 2. Load Game');
|
|
241
|
+
printLine(' 3. Quit\n');
|
|
242
|
+
|
|
243
|
+
const choice = await ask(' Choose: ');
|
|
244
|
+
if (choice.trim() === '1') {
|
|
245
|
+
printLine('\nChoose your faction:');
|
|
246
|
+
printLine(' 1. 🔴 Iron Legion (aggressive)');
|
|
247
|
+
printLine(' 2. 🟢 Green Pact (defensive)');
|
|
248
|
+
printLine(' 3. 🟡 Sand Empire (mercantile)');
|
|
249
|
+
printLine(' 4. 🟣 Void Covenant (diplomatic)\n');
|
|
250
|
+
const fc = await ask(' Choose: ');
|
|
251
|
+
const fm: Record<string, string> = { '1': 'iron_legion', '2': 'green_pact', '3': 'sand_empire', '4': 'void_covenant' };
|
|
252
|
+
await runGameLoop(newGame(fm[fc.trim()] ?? 'iron_legion'));
|
|
253
|
+
} else if (choice.trim() === '2') {
|
|
254
|
+
const saves = listSaves();
|
|
255
|
+
if (saves.length === 0) { printLine(chalk.red('No saves found.')); return mainMenu(); }
|
|
256
|
+
saves.forEach((s, i) => printLine(` ${i + 1}. ${s}`));
|
|
257
|
+
const slot = await ask(' Slot name: ');
|
|
258
|
+
const state = loadGame(slot.trim());
|
|
259
|
+
if (!state) { printLine(chalk.red('Save not found.')); return mainMenu(); }
|
|
260
|
+
await runGameLoop(state);
|
|
261
|
+
} else { rl.close(); process.exit(0); }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
mainMenu().catch((err) => { console.error(chalk.red('Fatal:'), err); process.exit(1); });
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Game state creation, serialization, save/load to ~/.empire-cli/saves/
|
|
2
|
+
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
import type { GameState, SaveData, Army } from '../game-types.js';
|
|
7
|
+
import { DEFAULT_TERRITORIES, DEFAULT_FACTIONS } from '../data/default-world-map.js';
|
|
8
|
+
|
|
9
|
+
const SAVES_DIR = join(homedir(), '.empire-cli', 'saves');
|
|
10
|
+
|
|
11
|
+
function ensureSavesDir(): void {
|
|
12
|
+
if (!existsSync(SAVES_DIR)) {
|
|
13
|
+
mkdirSync(SAVES_DIR, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create a fresh game state with default map and factions.
|
|
19
|
+
*/
|
|
20
|
+
export function newGame(playerFactionId: string): GameState {
|
|
21
|
+
const territories = new Map(DEFAULT_TERRITORIES.map((t) => [t.id, { ...t }]));
|
|
22
|
+
const factions = new Map(DEFAULT_FACTIONS.map((f) => [f.id, { ...f, territories: [...f.territories] }]));
|
|
23
|
+
|
|
24
|
+
// Build initial armies from territory data
|
|
25
|
+
const armies = new Map<string, Army>();
|
|
26
|
+
let armyCounter = 1;
|
|
27
|
+
for (const territory of territories.values()) {
|
|
28
|
+
if (territory.owner && territory.armies > 0) {
|
|
29
|
+
const armyId = `army_start_${armyCounter++}`;
|
|
30
|
+
armies.set(armyId, {
|
|
31
|
+
id: armyId,
|
|
32
|
+
factionId: territory.owner,
|
|
33
|
+
units: territory.armies,
|
|
34
|
+
morale: 80,
|
|
35
|
+
territoryId: territory.id,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
turn: 1,
|
|
42
|
+
territories,
|
|
43
|
+
factions,
|
|
44
|
+
armies,
|
|
45
|
+
playerFactionId,
|
|
46
|
+
gameLog: ['A new empire rises. Your destiny awaits.'],
|
|
47
|
+
isOver: false,
|
|
48
|
+
winner: null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Serialize GameState to JSON-compatible SaveData.
|
|
54
|
+
*/
|
|
55
|
+
export function toSaveData(state: GameState): SaveData {
|
|
56
|
+
return {
|
|
57
|
+
turn: state.turn,
|
|
58
|
+
territories: Object.fromEntries(state.territories),
|
|
59
|
+
factions: Object.fromEntries(state.factions),
|
|
60
|
+
armies: Object.fromEntries(state.armies),
|
|
61
|
+
playerFactionId: state.playerFactionId,
|
|
62
|
+
gameLog: state.gameLog.slice(-50), // keep last 50 entries
|
|
63
|
+
isOver: state.isOver,
|
|
64
|
+
winner: state.winner,
|
|
65
|
+
savedAt: new Date().toISOString(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Deserialize SaveData back into a live GameState.
|
|
71
|
+
*/
|
|
72
|
+
export function fromSaveData(data: SaveData): GameState {
|
|
73
|
+
return {
|
|
74
|
+
turn: data.turn,
|
|
75
|
+
territories: new Map(Object.entries(data.territories)),
|
|
76
|
+
factions: new Map(Object.entries(data.factions)),
|
|
77
|
+
armies: new Map(Object.entries(data.armies)),
|
|
78
|
+
playerFactionId: data.playerFactionId,
|
|
79
|
+
gameLog: data.gameLog,
|
|
80
|
+
isOver: data.isOver,
|
|
81
|
+
winner: data.winner,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Save game to ~/.empire-cli/saves/<slot>.json
|
|
87
|
+
*/
|
|
88
|
+
export function saveGame(state: GameState, slot: string = 'autosave'): void {
|
|
89
|
+
ensureSavesDir();
|
|
90
|
+
const filePath = join(SAVES_DIR, `${slot}.json`);
|
|
91
|
+
const data = toSaveData(state);
|
|
92
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Load game from ~/.empire-cli/saves/<slot>.json
|
|
97
|
+
* Returns null if save not found.
|
|
98
|
+
*/
|
|
99
|
+
export function loadGame(slot: string = 'autosave'): GameState | null {
|
|
100
|
+
ensureSavesDir();
|
|
101
|
+
const filePath = join(SAVES_DIR, `${slot}.json`);
|
|
102
|
+
if (!existsSync(filePath)) return null;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
106
|
+
const data: SaveData = JSON.parse(raw);
|
|
107
|
+
return fromSaveData(data);
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* List available save slots.
|
|
115
|
+
*/
|
|
116
|
+
export function listSaves(): string[] {
|
|
117
|
+
ensureSavesDir();
|
|
118
|
+
return readdirSync(SAVES_DIR)
|
|
119
|
+
.filter((f) => f.endsWith('.json'))
|
|
120
|
+
.map((f) => f.replace('.json', ''));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Check win condition: a faction owns all territories.
|
|
125
|
+
*/
|
|
126
|
+
export function checkWinCondition(state: GameState): string | null {
|
|
127
|
+
const totalTerritories = state.territories.size;
|
|
128
|
+
for (const faction of state.factions.values()) {
|
|
129
|
+
if (faction.territories.length >= totalTerritories) {
|
|
130
|
+
return faction.id;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|