385 lines
14 KiB
Python
385 lines
14 KiB
Python
"""Plausible candidate actions derived from the observation pairs (P0.3).
|
|
|
|
The engine stays the arbiter: ``candidates`` is a pure function of the
|
|
``Observation`` snapshot (no ``engine.*`` access) that proposes a
|
|
deterministic menu of plausible actions for the acting combatant. Every
|
|
candidate is derived from the per-pair decision helpers (``weapon_for``,
|
|
``can_charge``, ``can_step5_to_attack``, ``distance_ft``,
|
|
``has_line_of_effect``) and the combatant snapshots (``resources_left``
|
|
including ``spell_slot_{level}``, the enriched ``spells`` list). A candidate
|
|
that fails a legality gate at execution is rejected with explicit feedback
|
|
(``last_turn_results``) — never silently dropped here.
|
|
|
|
Candidate categories (docs ``phase-2-llm-architecture.md`` P0.3, D4/D5)
|
|
-----------------------------------------------------------------------
|
|
- ``attack`` / ``full_attack`` for every enemy the acting combatant can
|
|
already hit (``weapon_for`` is set).
|
|
- ``charge`` for every enemy whose pair says a straight-line charge is
|
|
possible (``can_charge``).
|
|
- ``5foot_step`` for every enemy one step brings into reach
|
|
(``can_step5_to_attack``).
|
|
- ``move`` toward every enemy (auto-pathed by the engine); skipped while
|
|
grappled (``grappled_with`` — the engine forbids movement then).
|
|
- ``cast_spell`` for every known spell with an available slot
|
|
(``spell_slot_{level}``, cantrips free), a castable ``cast_time`` (not
|
|
``long``), at every enemy it reaches (``distance_ft <= range_ft`` and
|
|
``has_line_of_effect`` — the same gate ``_cast_spell`` applies). Harmless
|
|
spells (cure, buffs) target the caster: a beneficial spell on a hostile
|
|
target would heal or buff the enemy.
|
|
- ``channel_energy`` (heal and harm modes) / ``hex`` (evil_eye, slumber per
|
|
enemy) / ``deed`` (up_close_and_deadly, quick_clear) when the matching
|
|
resource (``channel_energy`` / ``hexes`` / ``grit``) is present.
|
|
- ``total_defense`` and ``delay`` (always plausible, no target).
|
|
- ``ready`` with each trigger, readied attack on the nearest enemy.
|
|
|
|
Kinds emitted (D4): ``attack``, ``full_attack``, ``charge``, ``5foot_step``,
|
|
``move``, ``cast_spell``, ``channel_energy``, ``hex``, ``deed``,
|
|
``total_defense``, ``ready``, ``delay``. ``swift``/``free``/``full_round``
|
|
are excluded (``full_attack`` is the supported full-round kind). The menu is
|
|
capped at ``MAX_CANDIDATES`` (32), trimmed from the tail of the deterministic
|
|
order so the core options always survive.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, TypeGuard
|
|
|
|
from .combat import Action, Observation
|
|
|
|
if TYPE_CHECKING:
|
|
from .combat import ReadyTrigger
|
|
|
|
# Ready triggers offered for the ``ready`` candidate (the engine's
|
|
# ``ReadyTrigger`` literal).
|
|
_READY_TRIGGERS: tuple[ReadyTrigger, ...] = (
|
|
"enemy_enters_reach",
|
|
"enemy_casts",
|
|
"enemy_attacks",
|
|
"enemy_hits_ally",
|
|
)
|
|
|
|
# Class-ability resource keys (in ``resources_left``) that gate the
|
|
# channel/hex/deed candidates.
|
|
_CHANNEL_RESOURCE = "channel_energy"
|
|
_HEX_RESOURCE = "hexes"
|
|
_DEED_RESOURCE = "grit"
|
|
|
|
# Candidate kinds the generator may emit (D4). The generic minor kinds
|
|
# (``swift``/``free``/``full_round``) are excluded: a policy proposes real
|
|
# actions, not placeholders.
|
|
_CANDIDATE_KINDS: frozenset[str] = frozenset(
|
|
{
|
|
"attack",
|
|
"full_attack",
|
|
"charge",
|
|
"5foot_step",
|
|
"move",
|
|
"cast_spell",
|
|
"channel_energy",
|
|
"hex",
|
|
"deed",
|
|
"total_defense",
|
|
"ready",
|
|
"delay",
|
|
}
|
|
)
|
|
|
|
# Deterministic menu order: most decisive first, so the cap trims the least
|
|
# useful tail (extra ready triggers, delay) rather than the core options.
|
|
_KIND_PRIORITY: dict[str, int] = {
|
|
"full_attack": 0,
|
|
"attack": 1,
|
|
"charge": 2,
|
|
"5foot_step": 3,
|
|
"move": 4,
|
|
"cast_spell": 5,
|
|
"channel_energy": 6,
|
|
"hex": 7,
|
|
"deed": 8,
|
|
"total_defense": 9,
|
|
"ready": 10,
|
|
"delay": 11,
|
|
}
|
|
|
|
# Upper bound on the returned menu: keeps the LLM prompt compact while
|
|
# covering every category.
|
|
MAX_CANDIDATES = 32
|
|
|
|
|
|
def candidates(observation: Observation) -> tuple[Action, ...]:
|
|
"""Deterministic menu of plausible actions for the acting combatant.
|
|
|
|
Pure read of the snapshot: no engine access, no RNG, no mutation. The
|
|
engine stays the arbiter — a candidate that fails a legality gate is
|
|
rejected with explicit feedback, never silently dropped here.
|
|
"""
|
|
combatants = observation.combatants
|
|
if not (0 <= observation.acting_combatant < len(combatants)):
|
|
return ()
|
|
acting = combatants[observation.acting_combatant]
|
|
if acting.get("active") is not True:
|
|
return ()
|
|
acting_side = acting.get("side")
|
|
pairs = tuple(
|
|
pair
|
|
for pair in observation.pairs
|
|
if _pair_target_is_enemy(pair, combatants, acting_side)
|
|
)
|
|
result: list[Action] = []
|
|
_attack_candidates(result, pairs, combatants)
|
|
_charge_candidates(result, pairs, combatants)
|
|
_step_candidates(result, pairs, combatants)
|
|
_move_candidates(result, acting, pairs, combatants)
|
|
_spell_candidates(result, acting, pairs, combatants)
|
|
_class_ability_candidates(result, acting, pairs, combatants)
|
|
result.append(Action(kind="total_defense"))
|
|
_ready_candidates(result, pairs, combatants)
|
|
result.append(Action(kind="delay"))
|
|
result.sort(key=_sort_key)
|
|
return tuple(result[:MAX_CANDIDATES])
|
|
|
|
|
|
def _attack_candidates(
|
|
result: list[Action],
|
|
pairs: tuple[dict[str, object], ...],
|
|
combatants: tuple[dict[str, object], ...],
|
|
) -> None:
|
|
"""Standard and full-round attacks for every enemy already in reach."""
|
|
for pair in pairs:
|
|
if pair.get("weapon_for") is not None:
|
|
target = _target_id(pair, combatants)
|
|
result.append(Action(kind="attack", target_id=target))
|
|
result.append(Action(kind="full_attack", target_id=target))
|
|
|
|
|
|
def _charge_candidates(
|
|
result: list[Action],
|
|
pairs: tuple[dict[str, object], ...],
|
|
combatants: tuple[dict[str, object], ...],
|
|
) -> None:
|
|
"""Charge for every enemy with a clear straight-line path."""
|
|
result.extend(
|
|
Action(kind="charge", target_id=_target_id(pair, combatants))
|
|
for pair in pairs
|
|
if pair.get("can_charge") is True
|
|
)
|
|
|
|
|
|
def _step_candidates(
|
|
result: list[Action],
|
|
pairs: tuple[dict[str, object], ...],
|
|
combatants: tuple[dict[str, object], ...],
|
|
) -> None:
|
|
"""5-foot step for every enemy one step brings into reach."""
|
|
result.extend(
|
|
Action(kind="5foot_step", target_id=_target_id(pair, combatants))
|
|
for pair in pairs
|
|
if pair.get("can_step5_to_attack") is True
|
|
)
|
|
|
|
|
|
def _move_candidates(
|
|
result: list[Action],
|
|
acting: dict[str, object],
|
|
pairs: tuple[dict[str, object], ...],
|
|
combatants: tuple[dict[str, object], ...],
|
|
) -> None:
|
|
"""Move toward every enemy; skipped while grappled (movement forbidden)."""
|
|
if acting.get("grappled_with") is not None:
|
|
return
|
|
result.extend(
|
|
Action(kind="move", target_id=_target_id(pair, combatants)) for pair in pairs
|
|
)
|
|
|
|
|
|
def _spell_candidates(
|
|
result: list[Action],
|
|
acting: dict[str, object],
|
|
pairs: tuple[dict[str, object], ...],
|
|
combatants: tuple[dict[str, object], ...],
|
|
) -> None:
|
|
"""Cast every known spell with a slot, at every enemy it reaches.
|
|
|
|
Harmless spells (cure, buffs) target the caster: a beneficial spell on a
|
|
hostile target would heal or buff the enemy. Offensive spells are
|
|
proposed per enemy the spell reaches.
|
|
"""
|
|
spells = acting.get("spells")
|
|
resources = acting.get("resources_left")
|
|
if not _is_tuple(spells) or not _is_str_object_dict(resources):
|
|
return
|
|
acting_id = _as_str(acting["id"])
|
|
for spell in spells:
|
|
if not _is_str_object_dict(spell):
|
|
continue
|
|
if not _spell_has_slot(spell, resources):
|
|
continue
|
|
if _as_str(spell.get("cast_time", "standard")) == "long":
|
|
continue
|
|
name = _as_str(spell["name"])
|
|
if spell.get("harmless") is True:
|
|
result.append(
|
|
Action(kind="cast_spell", target_id=acting_id, spell_name=name)
|
|
)
|
|
continue
|
|
result.extend(
|
|
Action(
|
|
kind="cast_spell",
|
|
target_id=_target_id(pair, combatants),
|
|
spell_name=name,
|
|
)
|
|
for pair in pairs
|
|
if _spell_reaches_pair(spell, pair)
|
|
)
|
|
|
|
|
|
def _class_ability_candidates(
|
|
result: list[Action],
|
|
acting: dict[str, object],
|
|
pairs: tuple[dict[str, object], ...],
|
|
combatants: tuple[dict[str, object], ...],
|
|
) -> None:
|
|
"""Channel/hex/deed candidates, each gated on its resource being present."""
|
|
resources = acting.get("resources_left")
|
|
if not _is_str_object_dict(resources):
|
|
return
|
|
if _resource_available(resources, _CHANNEL_RESOURCE):
|
|
result.append(Action(kind="channel_energy", channel_mode="heal"))
|
|
result.append(Action(kind="channel_energy", channel_mode="harm"))
|
|
if _resource_available(resources, _HEX_RESOURCE):
|
|
for pair in pairs:
|
|
target = _target_id(pair, combatants)
|
|
result.append(Action(kind="hex", hex_name="evil_eye", target_id=target))
|
|
result.append(Action(kind="hex", hex_name="slumber", target_id=target))
|
|
if _resource_available(resources, _DEED_RESOURCE):
|
|
result.append(Action(kind="deed", deed_name="up_close_and_deadly"))
|
|
result.append(Action(kind="deed", deed_name="quick_clear"))
|
|
|
|
|
|
def _ready_candidates(
|
|
result: list[Action],
|
|
pairs: tuple[dict[str, object], ...],
|
|
combatants: tuple[dict[str, object], ...],
|
|
) -> None:
|
|
"""Ready with each trigger, readied attack on the nearest enemy."""
|
|
nearest = _nearest_enemy(pairs, combatants)
|
|
if nearest is None:
|
|
return
|
|
result.extend(
|
|
Action(kind="ready", ready_trigger=trigger, target_id=nearest)
|
|
for trigger in _READY_TRIGGERS
|
|
)
|
|
|
|
|
|
def _sort_key(action: Action) -> tuple[int, str, str, str, str, str, str]:
|
|
"""Deterministic ordering of the candidate menu (decisive first)."""
|
|
return (
|
|
_KIND_PRIORITY.get(action.kind, 99),
|
|
action.target_id or "",
|
|
action.spell_name or "",
|
|
action.hex_name or "",
|
|
action.deed_name or "",
|
|
action.ready_trigger or "",
|
|
action.channel_mode or "",
|
|
)
|
|
|
|
|
|
def _target_id(
|
|
pair: dict[str, object], combatants: tuple[dict[str, object], ...]
|
|
) -> str:
|
|
"""Stable id of the pair's target (falls back to the index)."""
|
|
index = pair.get("target_index")
|
|
if isinstance(index, int) and 0 <= index < len(combatants):
|
|
cid = combatants[index].get("id")
|
|
if isinstance(cid, str):
|
|
return cid
|
|
return str(index)
|
|
|
|
|
|
def _pair_target_is_enemy(
|
|
pair: dict[str, object],
|
|
combatants: tuple[dict[str, object], ...],
|
|
acting_side: object,
|
|
) -> bool:
|
|
"""True when the pair's target is an active combatant on the other side.
|
|
|
|
The observation's pairs cover every other combatant (allies included),
|
|
so the side check is what keeps the menu hostile-only.
|
|
"""
|
|
index = pair.get("target_index")
|
|
if not isinstance(index, int) or not (0 <= index < len(combatants)):
|
|
return False
|
|
target = combatants[index]
|
|
return target.get("active") is True and target.get("side") != acting_side
|
|
|
|
|
|
def _resource_available(resources: dict[str, object], key: str) -> bool:
|
|
"""True when the class-ability resource has at least one use left."""
|
|
count = resources.get(key)
|
|
return isinstance(count, int) and count > 0
|
|
|
|
|
|
def _spell_has_slot(spell: dict[str, object], resources: dict[str, object]) -> bool:
|
|
"""True when the caster still has a slot for the spell's level.
|
|
|
|
Cantrips (level 0) are cast freely — the snapshot has no level-0 slot
|
|
key, mirroring ``_has_slot`` in the engine.
|
|
"""
|
|
level = spell.get("level")
|
|
if not isinstance(level, int):
|
|
return False
|
|
if level == 0:
|
|
return True
|
|
count = resources.get(f"spell_slot_{level}")
|
|
return isinstance(count, int) and count > 0
|
|
|
|
|
|
def _spell_reaches_pair(spell: dict[str, object], pair: dict[str, object]) -> bool:
|
|
"""True when the spell's range covers the pair with line of effect.
|
|
|
|
Mirrors the engine's ``_spell_reaches`` gate: ``distance_ft`` (the pair's
|
|
creature distance) within the snapshot's precomputed ``range_ft``, plus
|
|
the pair's ``has_line_of_effect``.
|
|
"""
|
|
range_ft = spell.get("range_ft")
|
|
distance_ft = pair.get("distance_ft")
|
|
return (
|
|
isinstance(range_ft, int)
|
|
and isinstance(distance_ft, int)
|
|
and distance_ft <= range_ft
|
|
and pair.get("has_line_of_effect") is True
|
|
)
|
|
|
|
|
|
def _nearest_enemy(
|
|
pairs: tuple[dict[str, object], ...], combatants: tuple[dict[str, object], ...]
|
|
) -> str | None:
|
|
"""Id of the nearest enemy (lowest ``distance_ft``), or None."""
|
|
best: str | None = None
|
|
best_distance: int | None = None
|
|
for pair in pairs:
|
|
distance = pair.get("distance_ft")
|
|
if not isinstance(distance, int):
|
|
continue
|
|
if best_distance is None or distance < best_distance:
|
|
best_distance = distance
|
|
best = _target_id(pair, combatants)
|
|
return best
|
|
|
|
|
|
def _is_tuple(value: object) -> TypeGuard[tuple[object, ...]]:
|
|
"""Narrow ``object`` to a tuple (snapshot collections are tuples)."""
|
|
return isinstance(value, tuple)
|
|
|
|
|
|
def _is_str_object_dict(value: object) -> TypeGuard[dict[str, object]]:
|
|
"""Narrow ``object`` to the snapshot's ``dict[str, object]`` shape."""
|
|
return isinstance(value, dict)
|
|
|
|
|
|
def _as_str(value: object) -> str:
|
|
if not isinstance(value, str):
|
|
msg = f"expected str, got {type(value).__name__}"
|
|
raise TypeError(msg)
|
|
return value
|