mirror of
https://github.com/Heretek-AI/AetherTable.git
synced 2026-11-10 04:31:26 -10:00
Replaces greedy first-fit with STRIPS-style forward search: actions declare preconditions and effects (thresholds + delta wrappers), the planner runs uniform-cost A* over world-state snapshots with deterministic tie-breaking, dominance dedup, depth/expansion caps bounding EspionageRaid's positive resource loop. Risk folds into planning cost; execution stays deterministic. Legacy tick behavior is preserved verbatim when no goal is set — same three actions, same log lines — so existing callers are untouched. New API: set_goal/clear_ goal/plan/execute_plan/goal_progress_pending. 19 new tests including optimality-over-greedy and legacy-equivalence pins. Co-Authored-By: Claude <[email protected]>
429 lines
16 KiB
Python
429 lines
16 KiB
Python
"""Off-screen faction simulation with genuine Goal-Oriented Action Planning.
|
|
|
|
Backlog 4.12: the simulator was a greedy first-fit walk over a fixed action
|
|
list despite its GOAP name. Actions are now STRIPS-style operators
|
|
(preconditions + effects + cost) and a forward uniform-cost search (A* with a
|
|
zero heuristic, i.e. Dijkstra by action cost) finds an ordered plan from the
|
|
current world state to a goal state.
|
|
|
|
Backward compatibility: ``FactionSimulationGOAP.advance_simulation_tick`` with
|
|
no goal set still runs the original legacy heuristic over the original three
|
|
actions, producing byte-identical log lines and resource accounting. Existing
|
|
callers (``server.py`` reads ``faction_sim.faction_name``, ``.resources``,
|
|
``.world_state`` and calls ``advance_simulation_tick``) are unaffected unless a
|
|
goal is explicitly installed via :meth:`set_goal`.
|
|
"""
|
|
|
|
import heapq
|
|
import itertools
|
|
from typing import Any, Dict, Iterable, List, Optional
|
|
|
|
__all__ = [
|
|
"EffectDelta",
|
|
"delta",
|
|
"FactionAction",
|
|
"FactionGoal",
|
|
"FactionSimulationGOAP",
|
|
]
|
|
|
|
|
|
class EffectDelta:
|
|
"""Marker wrapper meaning "add this amount" instead of "set this value".
|
|
|
|
Numeric world facts are modelled STRIPS-style: effects are deltas applied
|
|
to the current fact value. Legacy actions keep bare (absolute) values so
|
|
their historical behaviour is untouched.
|
|
"""
|
|
|
|
__slots__ = ("amount",)
|
|
|
|
def __init__(self, amount: float):
|
|
self.amount = amount
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
return f"EffectDelta({self.amount:+g})"
|
|
|
|
def __eq__(self, other: Any) -> bool:
|
|
return isinstance(other, EffectDelta) and other.amount == self.amount
|
|
|
|
|
|
def delta(amount: float) -> EffectDelta:
|
|
"""Shorthand factory for :class:`EffectDelta`."""
|
|
return EffectDelta(amount)
|
|
|
|
|
|
def _is_number(value: Any) -> bool:
|
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
|
|
|
|
class FactionAction:
|
|
"""A STRIPS-style operator: preconditions, effects, cost, and risk.
|
|
|
|
``preconditions`` map fact -> threshold. Numeric thresholds are satisfied
|
|
when the current fact value ``>=`` threshold; boolean thresholds require
|
|
equality (this matches the legacy checker's semantics).
|
|
|
|
``effects`` map fact -> new value, where wrapping the value in
|
|
:func:`delta` marks it as an additive delta instead of an absolute set.
|
|
|
|
``risk`` scales the action's planning cost (``cost * (1 + risk)``) so the
|
|
planner prefers safer routes at equal price, without changing how much is
|
|
deducted when the action executes.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
name: str,
|
|
cost: float,
|
|
preconditions: Dict[str, Any],
|
|
effects: Dict[str, Any],
|
|
risk: float = 0.0,
|
|
):
|
|
self.name = name
|
|
self.cost = cost
|
|
self.preconditions = preconditions
|
|
self.effects = effects
|
|
self.risk = risk
|
|
|
|
@property
|
|
def effective_cost(self) -> float:
|
|
"""Cost used by the planner; risk makes risky actions less attractive."""
|
|
return self.cost * (1.0 + self.risk)
|
|
|
|
def preconditions_met(self, state: Dict[str, Any]) -> bool:
|
|
for key, required in self.preconditions.items():
|
|
current = state.get(key)
|
|
if _is_number(required):
|
|
if current is None or not _is_number(current) or current < required:
|
|
return False
|
|
elif current != required:
|
|
return False
|
|
return True
|
|
|
|
def apply(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Return a new state dict with this action's effects applied."""
|
|
nxt = dict(state)
|
|
for key, value in self.effects.items():
|
|
if isinstance(value, EffectDelta):
|
|
base = nxt.get(key, 0)
|
|
if not _is_number(base): # pragma: no cover - guarded by schema
|
|
base = 0
|
|
nxt[key] = base + value.amount
|
|
else:
|
|
nxt[key] = value
|
|
return nxt
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
return (
|
|
f"FactionAction(name={self.name!r}, cost={self.cost}, "
|
|
f"pre={self.preconditions!r}, effects={self.effects!r}, "
|
|
f"risk={self.risk})"
|
|
)
|
|
|
|
|
|
class FactionGoal:
|
|
"""A desired world state: fact -> threshold (numeric ``>=`` / bool equality)."""
|
|
|
|
def __init__(self, name: str, priority: float, desired_world_state: Dict[str, Any]):
|
|
self.name = name
|
|
self.priority = priority
|
|
self.desired_world_state = desired_world_state
|
|
|
|
def satisfied_by(self, state: Dict[str, Any]) -> bool:
|
|
for key, target in self.desired_world_state.items():
|
|
current = state.get(key)
|
|
if _is_number(target):
|
|
if current is None or not _is_number(current) or current < target:
|
|
return False
|
|
elif current != target:
|
|
return False
|
|
return True
|
|
|
|
|
|
# Default planning bounds. Plans in practice are 1-5 steps; these caps keep the
|
|
# forward search terminating even on unreachable goals (resource-generating
|
|
# loops such as EspionageRaid could otherwise expand forever).
|
|
MAX_PLAN_LENGTH = 12
|
|
MAX_EXPANSIONS = 50_000
|
|
|
|
|
|
class FactionSimulationGOAP:
|
|
"""
|
|
Goal-Oriented Action Planning (GOAP) and Utility AI for off-screen faction simulation.
|
|
|
|
World state is a plain dict of numeric/boolean facts. Planning runs a
|
|
deterministic uniform-cost forward search (ties broken by action
|
|
declaration order, then insertion sequence) from the current state toward a
|
|
:class:`FactionGoal`, returning the cheapest executable action sequence or
|
|
``None`` when the goal is unreachable.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
faction_name: str,
|
|
resources: int = 100,
|
|
initial_world_state: Optional[Dict[str, Any]] = None,
|
|
):
|
|
self.faction_name = faction_name
|
|
self.resources = resources
|
|
self.world_state: Dict[str, Any] = {
|
|
# Legacy facts (preserved verbatim for backward compatibility).
|
|
"has_relic": False,
|
|
"influence_level": 20,
|
|
"scouted_keep": False,
|
|
"mobilized_army": False,
|
|
# Numeric faction facts consumed by the GOAP action set.
|
|
"military": 0,
|
|
"defense": 0,
|
|
"influence": 20,
|
|
"reputation": 5,
|
|
"intel": False,
|
|
"trade_route": False,
|
|
}
|
|
if initial_world_state:
|
|
self.world_state.update(initial_world_state)
|
|
|
|
self.available_actions: List[FactionAction] = [
|
|
# --- Legacy actions (absolute effects; drive the legacy tick). ---
|
|
FactionAction(
|
|
name="Scout Keep",
|
|
cost=15,
|
|
preconditions={},
|
|
effects={"scouted_keep": True, "influence_level": 25},
|
|
),
|
|
FactionAction(
|
|
name="Infiltrate Sanctum",
|
|
cost=40,
|
|
preconditions={"scouted_keep": True},
|
|
effects={"has_relic": True, "influence_level": 50},
|
|
),
|
|
FactionAction(
|
|
name="Mobilize Faction Guard",
|
|
cost=50,
|
|
preconditions={"influence_level": 50},
|
|
effects={"mobilized_army": True},
|
|
),
|
|
# --- Themed GOAP actions (delta effects). ---
|
|
FactionAction(
|
|
name="GatherIntel",
|
|
cost=5,
|
|
preconditions={"resources": 5},
|
|
effects={"intel": True},
|
|
),
|
|
FactionAction(
|
|
# One-shot: opens a (boolean) trade route rather than being
|
|
# endlessly repeatable, so resource goals stay bounded.
|
|
name="TradeCaravan",
|
|
cost=4,
|
|
preconditions={"resources": 4, "military": 1, "trade_route": False},
|
|
effects={"trade_route": True, "resources": delta(8)},
|
|
),
|
|
FactionAction(
|
|
name="RecruitForces",
|
|
cost=10,
|
|
preconditions={"resources": 10},
|
|
effects={"military": delta(3)},
|
|
),
|
|
FactionAction(
|
|
name="FortifyHoldings",
|
|
cost=12,
|
|
preconditions={"military": 2},
|
|
effects={"defense": delta(4)},
|
|
),
|
|
FactionAction(
|
|
name="HostFestival",
|
|
cost=15,
|
|
preconditions={"resources": 15},
|
|
effects={"reputation": delta(2)},
|
|
),
|
|
FactionAction(
|
|
name="ForgeAlliance",
|
|
cost=8,
|
|
preconditions={"reputation": 5},
|
|
effects={"influence": delta(3)},
|
|
),
|
|
FactionAction(
|
|
name="EspionageRaid",
|
|
cost=5,
|
|
preconditions={"military": 3, "intel": True},
|
|
effects={"resources": delta(8)},
|
|
risk=0.3,
|
|
),
|
|
]
|
|
# The legacy heuristic tick only ever considered the original three
|
|
# actions; preserve that exactly so existing callers see identical
|
|
# behaviour when no goal is installed.
|
|
self._legacy_actions: List[FactionAction] = list(self.available_actions[:3])
|
|
self.goal: Optional[FactionGoal] = None
|
|
|
|
# ------------------------------------------------------------------
|
|
# Planning
|
|
# ------------------------------------------------------------------
|
|
|
|
def set_goal(self, goal: Optional[FactionGoal]) -> None:
|
|
"""Install (or clear, with ``None``) the faction's active goal."""
|
|
self.goal = goal
|
|
|
|
def clear_goal(self) -> None:
|
|
"""Remove the active goal, restoring legacy heuristic behaviour."""
|
|
self.goal = None
|
|
|
|
def goal_progress_pending(self) -> bool:
|
|
"""True while a goal is installed and not yet satisfied."""
|
|
return self.goal is not None and not self.goal.satisfied_by(self._planning_state())
|
|
|
|
def plan(
|
|
self,
|
|
goal: Optional[FactionGoal] = None,
|
|
max_plan_length: int = MAX_PLAN_LENGTH,
|
|
max_expansions: int = MAX_EXPANSIONS,
|
|
) -> Optional[List[FactionAction]]:
|
|
"""Plan the cheapest action sequence reaching ``goal``.
|
|
|
|
Forward uniform-cost search (A* with zero heuristic) over the world
|
|
state graph. Deterministic: sibling actions are expanded in declaration
|
|
order and heap ties break on (cost, plan length, insertion sequence).
|
|
Returns ``[]`` if the goal is already satisfied, ``None`` if it is
|
|
unreachable (within the search bounds).
|
|
"""
|
|
goal = goal if goal is not None else self.goal
|
|
if goal is None:
|
|
return None
|
|
|
|
start = self._planning_state()
|
|
if goal.satisfied_by(start):
|
|
return []
|
|
|
|
sequence = itertools.count()
|
|
# Heap entries: (cost_so_far, plan_length, tiebreak, state, plan indices)
|
|
open_heap: List[Any] = [(0.0, 0, next(sequence), start, [])]
|
|
best_cost: Dict[Any, float] = {self._signature(start): 0.0}
|
|
expansions = 0
|
|
|
|
while open_heap and expansions < max_expansions:
|
|
cost, length, _, state, plan_indices = heapq.heappop(open_heap)
|
|
signature = self._signature(state)
|
|
if cost > best_cost.get(signature, float("inf")) + 1e-9:
|
|
continue # stale entry
|
|
if goal.satisfied_by(state):
|
|
return [self.available_actions[i] for i in plan_indices]
|
|
if length >= max_plan_length:
|
|
continue
|
|
expansions += 1
|
|
for index, action in enumerate(self.available_actions):
|
|
if not self._applicable(state, action):
|
|
continue
|
|
successor = action.apply(state)
|
|
# Spending is part of the world state too: paying an action's
|
|
# cost must deplete the liquid-resources fact so downstream
|
|
# affordability checks match real execution.
|
|
successor["resources"] = successor.get("resources", 0) - action.cost
|
|
successor_cost = cost + action.effective_cost
|
|
successor_signature = self._signature(successor)
|
|
known = best_cost.get(successor_signature)
|
|
if known is None or successor_cost < known - 1e-9:
|
|
best_cost[successor_signature] = successor_cost
|
|
heapq.heappush(
|
|
open_heap,
|
|
(
|
|
successor_cost,
|
|
length + 1,
|
|
next(sequence),
|
|
successor,
|
|
plan_indices + [index],
|
|
),
|
|
)
|
|
return None
|
|
|
|
def execute_plan(self, plan: Iterable[FactionAction], goal: Optional[FactionGoal] = None) -> bool:
|
|
"""Apply an ordered plan to live simulation state.
|
|
|
|
Returns True when the resulting world state satisfies ``goal``
|
|
(defaulting to the active goal).
|
|
"""
|
|
goal = goal if goal is not None else self.goal
|
|
for action in plan:
|
|
if not self._applicable(self._planning_state(), action):
|
|
return False
|
|
self._commit(action)
|
|
return goal.satisfied_by(self._planning_state()) if goal is not None else True
|
|
|
|
# ------------------------------------------------------------------
|
|
# Ticking
|
|
# ------------------------------------------------------------------
|
|
|
|
def advance_simulation_tick(self) -> List[str]:
|
|
"""Runs a simulation tick during player downtime.
|
|
|
|
With an active goal, replans and executes the first step of the plan.
|
|
Without one, falls back to the legacy greedy first-fit heuristic so
|
|
existing callers observe unchanged behaviour.
|
|
"""
|
|
if self.goal is None:
|
|
return self._legacy_tick()
|
|
|
|
plan = self.plan()
|
|
if not plan:
|
|
return []
|
|
action = plan[0]
|
|
self._commit(action)
|
|
return [f"{self.faction_name} executed: {action.name}"]
|
|
|
|
def _legacy_tick(self) -> List[str]:
|
|
"""Original greedy first-fit behaviour over the legacy action list."""
|
|
executed = []
|
|
for action in self._legacy_actions:
|
|
# Check preconditions
|
|
can_execute = True
|
|
for k, v in action.preconditions.items():
|
|
if self.world_state.get(k) != v and self.world_state.get(k, 0) < v:
|
|
can_execute = False
|
|
break
|
|
|
|
if can_execute and self.resources >= action.cost:
|
|
# Check if effect is already satisfied
|
|
needs_execution = any(self.world_state.get(k) != v for k, v in action.effects.items())
|
|
if needs_execution:
|
|
self.resources -= int(action.cost)
|
|
self.world_state.update(action.effects)
|
|
executed.append(f"{self.faction_name} executed: {action.name}")
|
|
break
|
|
|
|
return executed
|
|
|
|
# ------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _planning_state(self) -> Dict[str, Any]:
|
|
"""World state snapshot used for planning (includes liquid resources)."""
|
|
state = dict(self.world_state)
|
|
state["resources"] = self.resources
|
|
return state
|
|
|
|
@staticmethod
|
|
def _applicable(state: Dict[str, Any], action: FactionAction) -> bool:
|
|
return action.preconditions_met(state) and state.get("resources", 0) >= action.cost
|
|
|
|
def _commit(self, action: FactionAction) -> None:
|
|
"""Execute an action against live simulation state."""
|
|
self.resources -= int(action.cost)
|
|
for key, value in action.effects.items():
|
|
if isinstance(value, EffectDelta):
|
|
if key == "resources":
|
|
# Liquid resources live on the simulation, not in
|
|
# ``world_state``; route wallet deltas there.
|
|
self.resources = int(self.resources + value.amount)
|
|
continue
|
|
base = self.world_state.get(key, 0)
|
|
if not _is_number(base):
|
|
base = 0
|
|
self.world_state[key] = base + value.amount
|
|
else:
|
|
self.world_state[key] = value
|
|
|
|
@staticmethod
|
|
def _signature(state: Dict[str, Any]) -> Any:
|
|
"""Hashable, order-independent state key for visited/dominance checks."""
|
|
return tuple(sorted((k, v) for k, v in state.items()))
|