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.
- histrategy_engine/__init__.py +71 -0
- histrategy_engine/ai/__init__.py +348 -0
- histrategy_engine/ai/fog_of_war.py +354 -0
- histrategy_engine/ai/npc_planner.py +358 -0
- histrategy_engine/ai/recon.py +84 -0
- histrategy_engine/character/__init__.py +227 -0
- histrategy_engine/character/loyalty.py +10 -0
- histrategy_engine/domestic/__init__.py +493 -0
- histrategy_engine/domestic/grain.py +8 -0
- histrategy_engine/governance/__init__.py +0 -0
- histrategy_engine/governance/legitimacy.py +21 -0
- histrategy_engine/history/__init__.py +503 -0
- histrategy_engine/history/rag.py +145 -0
- histrategy_engine/map/__init__.py +244 -0
- histrategy_engine/military/__init__.py +470 -0
- histrategy_engine/military/sabotage.py +235 -0
- histrategy_engine/rules/__init__.py +1 -0
- histrategy_engine/rules/economy.yaml +61 -0
- histrategy_engine/rules/historical_events.yaml +27 -0
- histrategy_engine/rules/interpreter.py +143 -0
- histrategy_engine/turn/__init__.py +592 -0
- histrategy_engine/world/__init__.py +398 -0
- histrategy_engine-0.2.0.dist-info/METADATA +21 -0
- histrategy_engine-0.2.0.dist-info/RECORD +26 -0
- histrategy_engine-0.2.0.dist-info/WHEEL +5 -0
- histrategy_engine-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|