histrategy-engine 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,71 @@
1
+ """
2
+ histrategy-engine — Deterministic physics engine for historical strategy games.
3
+
4
+ Seven-engine architecture (Map, Character, Domestic, Military, Decision,
5
+ History, Narrative) — no LLM dependency in the core engines.
6
+ """
7
+
8
+ from .ai import DecisionEngine
9
+ from .character import CharacterEngine
10
+ from .domestic import ClimateSystem, DomesticEngine, TerritoryResult
11
+ from .history import HistoryEngine
12
+ from .history.rag import HistoricalRAG
13
+ from .map import MapEngine, PathResult
14
+ from .military import MilitaryEngine, MoveResult, RecruitResult, SupplyStatus
15
+ from .turn import TurnController
16
+ from .world import (
17
+ Army,
18
+ Character,
19
+ ClimateEvent,
20
+ CombatResult,
21
+ Command,
22
+ EventProposal,
23
+ FactionState,
24
+ HistoricalEvent,
25
+ HistoricalMode,
26
+ Season,
27
+ StrategicPoint,
28
+ TerrainType,
29
+ Territory,
30
+ TurnResult,
31
+ UnitType,
32
+ WorldState,
33
+ )
34
+
35
+ __all__ = [
36
+ # World
37
+ "WorldState",
38
+ "Territory",
39
+ "Character",
40
+ "FactionState",
41
+ "Army",
42
+ "StrategicPoint",
43
+ "CombatResult",
44
+ "Command",
45
+ "TurnResult",
46
+ "HistoricalEvent",
47
+ "EventProposal",
48
+ # Enums
49
+ "Season",
50
+ "ClimateEvent",
51
+ "TerrainType",
52
+ "UnitType",
53
+ "HistoricalMode",
54
+ # Engines
55
+ "MapEngine",
56
+ "CharacterEngine",
57
+ "DomesticEngine",
58
+ "ClimateSystem",
59
+ "MilitaryEngine",
60
+ "DecisionEngine",
61
+ "TurnController",
62
+ "HistoryEngine",
63
+ "HistoricalRAG",
64
+ # Results
65
+ "PathResult",
66
+ "TerritoryResult",
67
+ "RecruitResult",
68
+ "MoveResult",
69
+ "SupplyStatus",
70
+ ]
71
+ __version__ = "0.1.0"
@@ -0,0 +1,348 @@
1
+ """
2
+ Decision Engine — NPC AI for autonomous faction behavior.
3
+
4
+ Evaluates threats, identifies opportunities, and generates commands
5
+ based on personality profiles. Pure-math, no LLM.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ from ..world import Command, FactionState, WorldState
13
+
14
+ if TYPE_CHECKING:
15
+ from ..map import MapEngine
16
+
17
+ # ─── Personality profiles ────────────────────────────────────────
18
+
19
+ DEFAULT_PROFILES: dict[str, dict[str, float]] = {
20
+ "caocao": {
21
+ "aggression": 0.8,
22
+ "cunning": 0.9,
23
+ "caution": 0.3,
24
+ "diplomacy": 0.5,
25
+ "development": 0.6,
26
+ "mercy": 0.2,
27
+ },
28
+ "liubei": {
29
+ "aggression": 0.3,
30
+ "cunning": 0.3,
31
+ "caution": 0.7,
32
+ "diplomacy": 0.8,
33
+ "development": 0.8,
34
+ "mercy": 0.95,
35
+ },
36
+ "sunquan": {
37
+ "aggression": 0.6,
38
+ "cunning": 0.6,
39
+ "caution": 0.5,
40
+ "diplomacy": 0.6,
41
+ "development": 0.6,
42
+ "mercy": 0.5,
43
+ },
44
+ "yuanshao": {
45
+ "aggression": 0.5,
46
+ "cunning": 0.6,
47
+ "caution": 0.7,
48
+ "diplomacy": 0.7,
49
+ "development": 0.5,
50
+ "mercy": 0.6,
51
+ },
52
+ "dongzhuo": {
53
+ "aggression": 0.9,
54
+ "cunning": 0.5,
55
+ "caution": 0.1,
56
+ "diplomacy": 0.1,
57
+ "development": 0.2,
58
+ "mercy": 0.05,
59
+ },
60
+ }
61
+
62
+
63
+ class DecisionEngine:
64
+ """Generates strategic commands for NPC factions."""
65
+
66
+ def __init__(self, personality_profiles: dict[str, dict[str, float]] | None = None):
67
+ self._profiles = personality_profiles or DEFAULT_PROFILES
68
+
69
+ def get_profile(self, faction_id: str) -> dict[str, float]:
70
+ """Get personality profile, falling back to a balanced default."""
71
+ return self._profiles.get(
72
+ faction_id,
73
+ {
74
+ "aggression": 0.5,
75
+ "cunning": 0.5,
76
+ "caution": 0.5,
77
+ "diplomacy": 0.5,
78
+ "development": 0.5,
79
+ "mercy": 0.5,
80
+ },
81
+ )
82
+
83
+ # ── Threat evaluation ──
84
+
85
+ def evaluate_threats(
86
+ self,
87
+ faction_id: str,
88
+ world_state: WorldState,
89
+ map_engine: MapEngine,
90
+ ) -> dict[str, dict]:
91
+ """
92
+ Evaluate military threats from neighboring factions.
93
+
94
+ Returns: {neighbor_faction_id: {ratio, level, neighbor_strength, my_strength}}
95
+ """
96
+ faction = world_state.factions.get(faction_id)
97
+ if not faction or not faction.is_active:
98
+ return {}
99
+
100
+ my_strength = faction.strength_actual
101
+ if my_strength <= 0:
102
+ my_strength = 1
103
+
104
+ # Find all neighboring factions and their strength
105
+ neighbor_strengths: dict[str, int] = {}
106
+ for tid in faction.territories:
107
+ for neighbor_id in map_engine.get_neighbors(tid):
108
+ neighbor_territory = world_state.territories.get(neighbor_id)
109
+ if not neighbor_territory or not neighbor_territory.owner_id:
110
+ continue
111
+ nfid = neighbor_territory.owner_id
112
+ if nfid == faction_id:
113
+ continue
114
+ nfaction = world_state.factions.get(nfid)
115
+ if nfaction and nfaction.is_active:
116
+ neighbor_strengths[nfid] = max(
117
+ neighbor_strengths.get(nfid, 0),
118
+ nfaction.strength_actual,
119
+ )
120
+
121
+ threats = {}
122
+ for nfid, nstrength in neighbor_strengths.items():
123
+ ratio = nstrength / my_strength
124
+ if ratio > 1.5:
125
+ level = "HIGH"
126
+ elif ratio > 0.8:
127
+ level = "MEDIUM"
128
+ else:
129
+ level = "LOW"
130
+
131
+ threats[nfid] = {
132
+ "ratio": ratio,
133
+ "level": level,
134
+ "neighbor_strength": nstrength,
135
+ "my_strength": my_strength,
136
+ }
137
+
138
+ return threats
139
+
140
+ # ── Opportunity evaluation ──
141
+
142
+ def evaluate_opportunities(
143
+ self,
144
+ faction_id: str,
145
+ world_state: WorldState,
146
+ map_engine: MapEngine,
147
+ ) -> list[dict]:
148
+ """
149
+ Identify expansion opportunities.
150
+
151
+ Returns list of opportunities, each with type and target.
152
+ Types: "attack" (weak neighbor), "occupy" (unowned territory)
153
+ """
154
+ faction = world_state.factions.get(faction_id)
155
+ if not faction or not faction.is_active:
156
+ return []
157
+
158
+ my_strength = max(faction.strength_actual, 1)
159
+ opportunities: list[dict] = []
160
+
161
+ seen_neighbors: set[str] = set()
162
+
163
+ for tid in faction.territories:
164
+ for neighbor_id in map_engine.get_neighbors(tid):
165
+ if neighbor_id in seen_neighbors:
166
+ continue
167
+ seen_neighbors.add(neighbor_id)
168
+
169
+ neighbor_territory = world_state.territories.get(neighbor_id)
170
+ if not neighbor_territory:
171
+ continue
172
+
173
+ # Unowned territory → occupy
174
+ if not neighbor_territory.owner_id:
175
+ opportunities.append(
176
+ {
177
+ "type": "occupy",
178
+ "territory_id": neighbor_id,
179
+ "territory_name": neighbor_territory.name,
180
+ "score": 0.8,
181
+ }
182
+ )
183
+ continue
184
+
185
+ # Own territory → skip
186
+ if neighbor_territory.owner_id == faction_id:
187
+ continue
188
+
189
+ # Enemy territory → evaluate for attack
190
+ enemy_id = neighbor_territory.owner_id
191
+ enemy_faction = world_state.factions.get(enemy_id)
192
+ if not enemy_faction or not enemy_faction.is_active:
193
+ continue
194
+
195
+ enemy_strength = enemy_faction.strength_actual
196
+ strength_ratio = enemy_strength / my_strength if my_strength > 0 else float("inf")
197
+
198
+ if strength_ratio < 0.6:
199
+ opportunities.append(
200
+ {
201
+ "type": "attack",
202
+ "territory_id": neighbor_id,
203
+ "territory_name": neighbor_territory.name,
204
+ "enemy_faction_id": enemy_id,
205
+ "enemy_faction_name": enemy_faction.name,
206
+ "strength_ratio": strength_ratio,
207
+ "score": 0.7 * (1.0 - strength_ratio),
208
+ }
209
+ )
210
+
211
+ # Sort by score descending
212
+ opportunities.sort(key=lambda o: o["score"], reverse=True)
213
+ return opportunities
214
+
215
+ # ── Command generation ──
216
+
217
+ def generate_commands(
218
+ self,
219
+ faction_id: str,
220
+ world_state: WorldState,
221
+ map_engine: MapEngine,
222
+ ) -> list[Command]:
223
+ """
224
+ Generate a set of commands for an NPC faction based on
225
+ personality-weighted evaluation of the current situation.
226
+ """
227
+ faction = world_state.factions.get(faction_id)
228
+ if not faction or not faction.is_active:
229
+ return []
230
+
231
+ profile = self.get_profile(faction_id)
232
+ aggression = profile.get("aggression", 0.5)
233
+ development = profile.get("development", 0.5)
234
+ caution = profile.get("caution", 0.5)
235
+
236
+ threats = self.evaluate_threats(faction_id, world_state, map_engine)
237
+ opportunities = self.evaluate_opportunities(faction_id, world_state, map_engine)
238
+
239
+ # Calculate overall opportunity score (0.0 - 1.0)
240
+ opp_score = 0.0
241
+ if opportunities:
242
+ opp_score = opportunities[0]["score"]
243
+
244
+ # Determine if there are high threats
245
+ high_threats = any(t["level"] == "HIGH" for t in threats.values())
246
+ medium_threats = any(t["level"] in ("HIGH", "MEDIUM") for t in threats.values())
247
+
248
+ commands: list[Command] = []
249
+
250
+ # Assess resource needs
251
+ food_low = faction.food < 2000
252
+ troops_low = faction.strength_actual < 3000
253
+ treasury_ok = faction.treasury > 2000
254
+
255
+ from ..world import HistoricalMode
256
+
257
+ is_historical = (
258
+ getattr(world_state, "historical_mode", HistoricalMode.HISTORICAL)
259
+ == HistoricalMode.HISTORICAL
260
+ )
261
+
262
+ if is_historical:
263
+ commands: list[Command] = []
264
+ if treasury_ok and troops_low and faction.territories:
265
+ commands.append(self._make_recruit_command(faction))
266
+ elif faction.territories:
267
+ commands.append(self._make_develop_command(faction))
268
+ return commands
269
+
270
+ # Decision weights
271
+ attack_score = aggression * opp_score
272
+ develop_score = development * (1.0 - opp_score)
273
+
274
+ # If under high threat, prioritize defense/recruitment
275
+ if high_threats and caution > 0.3:
276
+ if troops_low and treasury_ok and faction.territories:
277
+ commands.append(self._make_recruit_command(faction))
278
+ else:
279
+ commands.append(self._make_develop_command(faction))
280
+ elif food_low:
281
+ commands.append(self._make_develop_command(faction))
282
+ elif troops_low and treasury_ok and faction.territories:
283
+ commands.append(self._make_recruit_command(faction))
284
+ elif attack_score > develop_score and opportunities:
285
+ # Find an attack/move opportunity
286
+ attack_opps = [o for o in opportunities if o["type"] == "attack"]
287
+ occupy_opps = [o for o in opportunities if o["type"] == "occupy"]
288
+
289
+ if attack_opps and aggression > 0.4:
290
+ opp = attack_opps[0]
291
+ commands.append(
292
+ Command(
293
+ type="attack",
294
+ params={"target_territory": opp["territory_id"]},
295
+ faction_id=faction_id,
296
+ )
297
+ )
298
+ elif occupy_opps and aggression > 0.2:
299
+ opp = occupy_opps[0]
300
+ commands.append(
301
+ Command(
302
+ type="move",
303
+ params={"destination": opp["territory_id"]},
304
+ faction_id=faction_id,
305
+ )
306
+ )
307
+ elif faction.territories:
308
+ commands.append(self._make_develop_command(faction))
309
+ else:
310
+ # Default: develop or recruit
311
+ if treasury_ok and troops_low and faction.territories:
312
+ commands.append(self._make_recruit_command(faction))
313
+ elif faction.territories:
314
+ commands.append(self._make_develop_command(faction))
315
+
316
+ # Cautious factions may add a second defensive command
317
+ if (
318
+ medium_threats
319
+ and caution > 0.5
320
+ and len(commands) < 2
321
+ and treasury_ok
322
+ and faction.territories
323
+ ):
324
+ commands.append(self._make_recruit_command(faction))
325
+
326
+ return commands
327
+
328
+ def _make_recruit_command(self, faction: FactionState) -> Command:
329
+ return Command(
330
+ type="recruit",
331
+ params={
332
+ "territory": faction.capital
333
+ or (faction.territories[0] if faction.territories else ""),
334
+ "unit_type": "infantry",
335
+ "amount": 500,
336
+ },
337
+ faction_id=faction.id,
338
+ )
339
+
340
+ def _make_develop_command(self, faction: FactionState) -> Command:
341
+ return Command(
342
+ type="develop",
343
+ params={
344
+ "territory": faction.capital
345
+ or (faction.territories[0] if faction.territories else ""),
346
+ },
347
+ faction_id=faction.id,
348
+ )