Skip to content

Commit cef4953

Browse files
Bind AHBG engine state to presentation snapshots
1 parent cafa636 commit cef4953

10 files changed

Lines changed: 303 additions & 16 deletions

File tree

ahbg/README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ Engine entry points are exported from `ahbg/engine/__init__.py`:
7878
agent; seed, RNG streams, event log, and DM state stay internal.
7979
- `save_plane()`, `load_plane()`, and `replay()` bind persistence to event-log
8080
replay equivalence and the event hash chain.
81+
- `snapshot_from_plane(plane, log)` emits `ahbg.presentation.snapshot` data
82+
only after the supplied log replays exactly to the supplied plane.
8183

8284
The only resolving action is:
8385

@@ -92,8 +94,10 @@ unknown action kinds fail closed.
9294
Presentation consumes `ahbg.presentation.snapshot` only. `motions` are optional
9395
visual traces with `unit`, `from`, and `to`; they validate referenced ids but do
9496
not validate adjacency or legality. `presentation/project.py` maps a legal
95-
observation plus resolved `move` events into that snapshot and drops seed and
96-
schema. It is not a 1:1 identity with plane state.
97+
observation plus caller-supplied resolved `move` events for graphics-local use.
98+
For live engine planes, `snapshot_from_plane()` copies traces from canonical
99+
engine `move` events for the last completed turn and drops seed, RNG, schema,
100+
and event-log internals. It is not a 1:1 identity with plane state.
97101

98102
## Tool responsibilities
99103

ahbg/engine/README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,19 @@ kernel. Unknown kinds fail closed.
5959

6060
A save directory holds `plane.json` (snapshot) and `events.jsonl` (log).
6161
`save_plane` refuses to write unless the snapshot equals `replay(log)`;
62-
`load_plane` re-verifies both before returning.
62+
`load_plane` re-verifies both before returning. Replay also rejects an event
63+
log that ends inside an open turn; a turn must close with `turn.end` before it
64+
can become a replayable boundary.
65+
66+
## Presentation projection
67+
68+
`snapshot_from_plane(plane, log)` is the engine-owned bridge into
69+
`ahbg.presentation.snapshot`. It verifies that the event log replays exactly to
70+
the supplied plane, then projects tiles, units, a compact feed, and visual
71+
motion traces copied from canonical `move` events for the last completed turn.
72+
73+
The projection is display data. It does not decide adjacency, War, construction,
74+
DM effects, or any unresolved rule.
6375

6476
## Initial board
6577

ahbg/engine/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@
2727
from .movement import MOVE_ACTION, MoveSpec, axial_neighbors
2828
from .persistence import load_plane, new_game, replay, save_plane
2929
from .plane import Plane, Tile, Unit
30+
from .presentation import (
31+
PRESENTATION_KIND,
32+
PRESENTATION_STANDING,
33+
feed_from_log,
34+
motion_traces_from_log,
35+
snapshot_from_plane,
36+
)
3037
from .rng import (
3138
DM_DOMAIN,
3239
PROMPT_INJECTION_DOMAIN,
@@ -48,6 +55,8 @@
4855
"MOVE_ACTION",
4956
"MoveSpec",
5057
"Observation",
58+
"PRESENTATION_KIND",
59+
"PRESENTATION_STANDING",
5160
"Plan",
5261
"Plane",
5362
"PROMPT_INJECTION_DOMAIN",
@@ -60,9 +69,12 @@
6069
"ValidationError",
6170
"WAR_DOMAIN",
6271
"axial_neighbors",
72+
"feed_from_log",
6373
"legal_observation",
6474
"load_plane",
75+
"motion_traces_from_log",
6576
"new_game",
6677
"replay",
6778
"save_plane",
79+
"snapshot_from_plane",
6880
]

ahbg/engine/persistence.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ def replay(log: EventLog) -> Plane:
120120
raise ReplayMismatch(
121121
f"event kind {event.kind!r} is not canonical"
122122
)
123+
if phase != "awaiting_begin":
124+
raise ReplayMismatch("event log ended before turn.end")
123125
return plane
124126

125127

ahbg/engine/presentation.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Engine-owned projection into the AHBG presentation snapshot contract.
2+
3+
This module converts already-resolved engine state into
4+
``ahbg.presentation.snapshot`` data. It does not validate or decide mechanics;
5+
movement legality has already been settled by the engine before a ``move``
6+
event exists.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from typing import Any
12+
13+
from ahbg.presentation.snapshot import (
14+
KIND as PRESENTATION_KIND,
15+
STANDING as PRESENTATION_STANDING,
16+
validate_snapshot,
17+
)
18+
19+
from .errors import ReplayMismatch, ValidationError
20+
from .events import KIND_MOVE, KIND_PLANE_INIT, EventLog
21+
from .movement import spec_from_event_data
22+
from .persistence import replay
23+
from .plane import Plane
24+
25+
26+
def motion_traces_from_log(log: EventLog, turn: int | None = None) -> list[dict[str, str]]:
27+
"""Return presentation traces for canonical ``move`` events.
28+
29+
If ``turn`` is supplied, only move events from that engine turn are
30+
returned. The trace fields name already-presented unit and tile ids; they
31+
do not re-check adjacency or War conditions.
32+
"""
33+
log.verify()
34+
traces: list[dict[str, str]] = []
35+
for event in log.events:
36+
if event.kind != KIND_MOVE:
37+
continue
38+
if turn is not None and event.turn != turn:
39+
continue
40+
spec = spec_from_event_data(event.data)
41+
traces.append(
42+
{
43+
"unit": spec.unit_id,
44+
"from": spec.from_tile_id,
45+
"to": spec.to_tile_id,
46+
}
47+
)
48+
return traces
49+
50+
51+
def feed_from_log(log: EventLog) -> list[dict[str, Any]]:
52+
"""Build a compact human feed from engine provenance events."""
53+
log.verify()
54+
feed: list[dict[str, Any]] = []
55+
for event in log.events:
56+
if event.kind == KIND_PLANE_INIT:
57+
units = event.data.get("plane", {}).get("units", [])
58+
if units:
59+
placements = ", ".join(
60+
f"{unit.get('label') or unit.get('unit_id')} at {unit.get('tile_id')}"
61+
for unit in units
62+
)
63+
feed.append(
64+
{"turn": event.turn, "text": f"plane loaded; {placements}"}
65+
)
66+
else:
67+
feed.append({"turn": event.turn, "text": "plane loaded"})
68+
elif event.kind == KIND_MOVE:
69+
spec = spec_from_event_data(event.data)
70+
feed.append(
71+
{
72+
"turn": event.turn,
73+
"text": (
74+
f"{spec.unit_id} move "
75+
f"{spec.from_tile_id} to {spec.to_tile_id}"
76+
),
77+
}
78+
)
79+
return feed
80+
81+
82+
def snapshot_from_plane(
83+
plane: Plane,
84+
log: EventLog | None = None,
85+
*,
86+
plane_id: str = "plane-0",
87+
selected_tile_id: str | None = None,
88+
) -> dict[str, Any]:
89+
"""Project an engine plane into the presentation snapshot format.
90+
91+
When a log is supplied, it must replay exactly to ``plane`` before any
92+
presentation data is emitted. Default motion traces are the moves from the
93+
last completed turn, matching the current visual transition into the
94+
presented plane state.
95+
"""
96+
plane.validate()
97+
if not isinstance(plane_id, str) or not plane_id:
98+
raise ValidationError("presentation plane_id must be exact non-empty text")
99+
if log is not None:
100+
replayed = replay(log)
101+
if replayed.canonical_dict() != plane.canonical_dict():
102+
raise ReplayMismatch("presentation snapshot source log does not replay to plane")
103+
104+
tiles = [
105+
{"id": tile.tile_id, "q": tile.q, "r": tile.r, "label": tile.tile_id}
106+
for tile in sorted(plane.tiles.values(), key=lambda item: item.tile_id)
107+
]
108+
units = [
109+
{
110+
"id": unit.unit_id,
111+
"tile": unit.tile_id,
112+
"label": unit.label or unit.unit_id,
113+
}
114+
for unit in sorted(plane.units.values(), key=lambda item: item.unit_id)
115+
]
116+
tile_ids = {tile["id"] for tile in tiles}
117+
if selected_tile_id is None and units:
118+
selected_tile_id = units[0]["tile"]
119+
if selected_tile_id is not None and selected_tile_id not in tile_ids:
120+
raise ValidationError("selected_tile_id must name a plane tile")
121+
122+
payload: dict[str, Any] = {
123+
"kind": PRESENTATION_KIND,
124+
"standing": PRESENTATION_STANDING,
125+
"plane_id": plane_id,
126+
"turn": plane.turn,
127+
"tiles": tiles,
128+
"units": units,
129+
"selected_tile": selected_tile_id,
130+
"feed": feed_from_log(log) if log is not None else [],
131+
}
132+
if log is not None:
133+
payload["motions"] = motion_traces_from_log(
134+
log,
135+
turn=plane.turn - 1 if plane.turn > 0 else None,
136+
)
137+
return dict(validate_snapshot(payload))

ahbg/engine/tests/test_persistence.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
ROOT = Path(__file__).resolve().parents[3]
99
sys.path.insert(0, str(ROOT))
1010

11+
from ahbg.engine.adapter import Action, Plan
1112
from ahbg.engine.errors import ReplayMismatch, ValidationError
1213
from ahbg.engine.events import EventLog
1314
from ahbg.engine.persistence import (
@@ -105,6 +106,25 @@ def test_replay_rejects_turn_phase_violations(self) -> None:
105106
with self.assertRaisesRegex(ReplayMismatch, "awaiting_begin"):
106107
replay(log)
107108

109+
def test_replay_rejects_unclosed_turn(self) -> None:
110+
plane, log = new_game(seed=7, tiles=TILES, units=UNITS)
111+
TurnEngine(plane=plane, log=log).begin_turn()
112+
113+
with self.assertRaisesRegex(ReplayMismatch, "before turn.end"):
114+
replay(log)
115+
116+
def test_replay_rejects_unclosed_buffered_move(self) -> None:
117+
plane, log = new_game(seed=7, tiles=TILES, units=UNITS)
118+
engine = TurnEngine(plane=plane, log=log)
119+
engine.begin_turn()
120+
engine.resolve([Plan(turn=0, actions=(Action("move", {
121+
"unit_id": "A0",
122+
"to_tile_id": "e",
123+
}),))])
124+
125+
with self.assertRaisesRegex(ReplayMismatch, "before turn.end"):
126+
replay(log)
127+
108128

109129
if __name__ == "__main__":
110130
unittest.main()
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
import unittest
5+
from pathlib import Path
6+
7+
ROOT = Path(__file__).resolve().parents[3]
8+
sys.path.insert(0, str(ROOT))
9+
10+
from ahbg.engine import (
11+
MOVE_ACTION,
12+
Action,
13+
Plan,
14+
ReplayMismatch,
15+
TurnEngine,
16+
ValidationError,
17+
new_game,
18+
snapshot_from_plane,
19+
)
20+
from ahbg.presentation.snapshot import KIND, validate_snapshot
21+
22+
TILES = [
23+
{"tile_id": "c", "q": 0, "r": 0},
24+
{"tile_id": "e", "q": 1, "r": 0},
25+
{"tile_id": "ne", "q": 1, "r": -1},
26+
]
27+
UNITS = [{"unit_id": "A0", "tile_id": "c", "label": "A0"}]
28+
29+
30+
def move_plan(turn: int, unit_id: str, to_tile_id: str) -> Plan:
31+
return Plan(turn=turn, actions=(Action(MOVE_ACTION, {
32+
"unit_id": unit_id,
33+
"to_tile_id": to_tile_id,
34+
}),))
35+
36+
37+
class PresentationProjectionTests(unittest.TestCase):
38+
def test_engine_plane_exports_valid_presentation_snapshot(self) -> None:
39+
plane, log = new_game(seed=7, tiles=TILES, units=UNITS)
40+
engine = TurnEngine(plane=plane, log=log)
41+
engine.begin_turn()
42+
engine.resolve([move_plan(0, "A0", "ne")])
43+
engine.end_turn()
44+
45+
snapshot = snapshot_from_plane(plane, log, plane_id="plane-0")
46+
47+
self.assertEqual(snapshot["kind"], KIND)
48+
self.assertEqual(snapshot["turn"], 1)
49+
self.assertEqual(snapshot["units"], [{"id": "A0", "tile": "ne", "label": "A0"}])
50+
self.assertEqual(snapshot["selected_tile"], "ne")
51+
self.assertEqual(snapshot["motions"], [{"unit": "A0", "from": "c", "to": "ne"}])
52+
self.assertIn({"turn": 0, "text": "A0 move c to ne"}, snapshot["feed"])
53+
validate_snapshot(snapshot)
54+
55+
def test_default_motion_traces_are_last_completed_turn_only(self) -> None:
56+
plane, log = new_game(seed=7, tiles=TILES, units=UNITS)
57+
engine = TurnEngine(plane=plane, log=log)
58+
engine.begin_turn()
59+
engine.resolve([move_plan(0, "A0", "e")])
60+
engine.end_turn()
61+
engine.begin_turn()
62+
engine.resolve([move_plan(1, "A0", "ne")])
63+
engine.end_turn()
64+
65+
snapshot = snapshot_from_plane(plane, log)
66+
67+
self.assertEqual(snapshot["turn"], 2)
68+
self.assertEqual(snapshot["motions"], [{"unit": "A0", "from": "e", "to": "ne"}])
69+
validate_snapshot(snapshot)
70+
71+
def test_unreplayed_log_refuses_presentation_snapshot(self) -> None:
72+
plane, log = new_game(seed=7, tiles=TILES, units=UNITS)
73+
plane.turn = 3
74+
75+
with self.assertRaisesRegex(ReplayMismatch, "does not replay"):
76+
snapshot_from_plane(plane, log)
77+
78+
def test_unknown_selected_tile_fails_closed(self) -> None:
79+
plane, log = new_game(seed=7, tiles=TILES, units=UNITS)
80+
81+
with self.assertRaisesRegex(ValidationError, "selected_tile_id"):
82+
snapshot_from_plane(plane, log, selected_tile_id="missing")
83+
84+
85+
if __name__ == "__main__":
86+
unittest.main()

0 commit comments

Comments
 (0)