From a0ab3d9b6b4c93f06dccb0b6e4b1187677d6208c Mon Sep 17 00:00:00 2001 From: erinepshovel-code <250928284+erinepshovel-code@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:28:00 +0000 Subject: [PATCH 01/15] Add AHBG presentation board for a seven-tile snapshot Grok-owned graphics only. The snapshot is not plane state and does not define mechanics. A0 is a marker on a hex neighborhood with a feed. --- ahbg/README.md | 4 + ahbg/presentation/README.md | 50 ++++++++ ahbg/presentation/board.css | 100 ++++++++++++++++ ahbg/presentation/board.html | 24 ++++ ahbg/presentation/board.js | 143 +++++++++++++++++++++++ ahbg/presentation/sample_snapshot.json | 22 ++++ ahbg/presentation/snapshot.py | 86 ++++++++++++++ ahbg/presentation/tests/test_snapshot.py | 36 ++++++ 8 files changed, 465 insertions(+) create mode 100644 ahbg/presentation/README.md create mode 100644 ahbg/presentation/board.css create mode 100644 ahbg/presentation/board.html create mode 100644 ahbg/presentation/board.js create mode 100644 ahbg/presentation/sample_snapshot.json create mode 100644 ahbg/presentation/snapshot.py create mode 100644 ahbg/presentation/tests/test_snapshot.py diff --git a/ahbg/README.md b/ahbg/README.md index 9f04128..943fd1a 100644 --- a/ahbg/README.md +++ b/ahbg/README.md @@ -19,6 +19,10 @@ Owns visual implementation: Grok does not define game mechanics. +Current Grok surface: [`presentation/`](presentation/) renders a +`ahbg.presentation.snapshot` (seven-tile hex neighborhood, A0 marker, feed, +tile inspect). That snapshot is not plane state. + ### Codex — game engine / runtime Owns executable game semantics: diff --git a/ahbg/presentation/README.md b/ahbg/presentation/README.md new file mode 100644 index 0000000..824a3a8 --- /dev/null +++ b/ahbg/presentation/README.md @@ -0,0 +1,50 @@ +# AHBG presentation + +Grok-owned graphics surface. It renders a presentation snapshot. It does not +define game mechanics. + +AHBG is a despecified handle. This folder uses the instance expansion +"Agent Harness Benchmark Game" only for this workspace README. That expansion +is not identity. + +## Boundary + +- Included: hex neighborhood rendering, unit marker, selection highlight, human feed +- Excluded: turns, movement, construction, War, loyalty, DM rolls, legal observation +- Codex owns engine state. This snapshot is `ahbg.presentation.snapshot`, not plane state. + +## Usage + +Validate the sample snapshot: + +```bash +cd ahbg/presentation +python3 -m unittest discover -s tests -q +``` + +Open the board: + +```bash +python3 -m http.server 8765 --bind 127.0.0.1 +# then visit http://127.0.0.1:8765/board.html +``` + +`board.html` also runs from a file URL by embedding the sample snapshot. + +## Snapshot contract + +A presentation snapshot must include: + +- `kind` equal to `ahbg.presentation.snapshot` +- `standing` equal to `not-mechanics` +- unique tile ids with axial `q`,`r` +- units whose `tile` ids exist +- a feed list (may be empty) + +Unknown mechanic fields are ignored. Missing required visual fields fail closed. + +## hmmm + +- exact sacred-geometry vocabulary of the board (hex is a presentation choice) +- whether Codex plane state will map 1:1 onto this snapshot +- animation of motion/construction once the engine emits events diff --git a/ahbg/presentation/board.css b/ahbg/presentation/board.css new file mode 100644 index 0000000..12c45b3 --- /dev/null +++ b/ahbg/presentation/board.css @@ -0,0 +1,100 @@ +:root { + --ink: #1b1a17; + --paper: #f4efe4; + --tile: #e6dcc8; + --tile-stroke: #6b5d44; + --selected: #c45c26; + --unit: #1f4b99; + --feed: #2a2722; +} + +html, +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font: 16px/1.4 "Iowan Old Style", "Palatino Linotype", Palatino, serif; +} + +main { + display: grid; + grid-template-columns: minmax(0, 1fr) 18rem; + min-height: 100vh; +} + +.board-wrap { + display: flex; + flex-direction: column; + padding: 1.5rem; +} + +h1, +h2 { + font-weight: 600; + letter-spacing: 0.04em; + margin: 0 0 0.75rem; +} + +.note { + margin: 0 0 1rem; + font-size: 0.95rem; +} + +svg { + width: min(100%, 36rem); + height: auto; + align-self: center; +} + +.hex { + fill: var(--tile); + stroke: var(--tile-stroke); + stroke-width: 2; + cursor: pointer; +} + +.hex.selected { + stroke: var(--selected); + stroke-width: 4; +} + +.hex-label { + fill: var(--ink); + font-size: 12px; + pointer-events: none; + text-anchor: middle; +} + +.unit { + fill: var(--unit); + stroke: var(--paper); + stroke-width: 2; + pointer-events: none; +} + +.feed { + background: #efe7d6; + border-left: 1px solid var(--tile-stroke); + padding: 1.5rem; +} + +.feed ol { + margin: 0; + padding-left: 1.2rem; +} + +.inspect { + margin-top: 1rem; + font-size: 0.95rem; +} + +@media (max-width: 720px) { + main { + grid-template-columns: 1fr; + } + + .feed { + border-left: 0; + border-top: 1px solid var(--tile-stroke); + } +} diff --git a/ahbg/presentation/board.html b/ahbg/presentation/board.html new file mode 100644 index 0000000..aafab49 --- /dev/null +++ b/ahbg/presentation/board.html @@ -0,0 +1,24 @@ + + + + + + AHBG presentation board + + + +
+
+

AHBG plane

+

Presentation only. Not mechanics. Click a tile to inspect it.

+ +
+ +
+ + + diff --git a/ahbg/presentation/board.js b/ahbg/presentation/board.js new file mode 100644 index 0000000..e59dcb3 --- /dev/null +++ b/ahbg/presentation/board.js @@ -0,0 +1,143 @@ +const EMBEDDED_SNAPSHOT = { + kind: "ahbg.presentation.snapshot", + standing: "not-mechanics", + plane_id: "plane-0", + turn: 0, + tiles: [ + { id: "c", q: 0, r: 0, label: "origin" }, + { id: "ne", q: 1, r: -1, label: "ne" }, + { id: "e", q: 1, r: 0, label: "e" }, + { id: "se", q: 0, r: 1, label: "se" }, + { id: "sw", q: -1, r: 1, label: "sw" }, + { id: "w", q: -1, r: 0, label: "w" }, + { id: "nw", q: 0, r: -1, label: "nw" }, + ], + units: [{ id: "A0", tile: "c", label: "A0" }], + selected_tile: "c", + feed: [{ turn: 0, text: "plane loaded; A0 at origin" }], +}; + +const SIZE = 42; + +function axialToPixel(q, r) { + return { + x: SIZE * Math.sqrt(3) * (q + r / 2), + y: SIZE * (3 / 2) * r, + }; +} + +function hexPoints(cx, cy) { + const points = []; + for (let i = 0; i < 6; i += 1) { + const angle = (Math.PI / 180) * (60 * i - 30); + points.push(`${cx + SIZE * Math.cos(angle)},${cy + SIZE * Math.sin(angle)}`); + } + return points.join(" "); +} + +function validateSnapshot(snapshot) { + if (snapshot.kind !== "ahbg.presentation.snapshot") { + throw new Error("kind must be ahbg.presentation.snapshot"); + } + if (snapshot.standing !== "not-mechanics") { + throw new Error("standing must be not-mechanics"); + } + if (!Array.isArray(snapshot.tiles) || snapshot.tiles.length === 0) { + throw new Error("tiles must be a non-empty list"); + } + const ids = new Set(snapshot.tiles.map((tile) => tile.id)); + for (const unit of snapshot.units || []) { + if (!ids.has(unit.tile)) { + throw new Error(`unit ${unit.id} tile is not a presented tile`); + } + } + return snapshot; +} + +function render(snapshot) { + const svg = document.getElementById("board"); + const feed = document.getElementById("feed-list"); + const inspect = document.getElementById("inspect"); + svg.replaceChildren(); + feed.replaceChildren(); + + const pixels = snapshot.tiles.map((tile) => axialToPixel(tile.q, tile.r)); + const minX = Math.min(...pixels.map((p) => p.x)) - SIZE * 2; + const minY = Math.min(...pixels.map((p) => p.y)) - SIZE * 2; + const maxX = Math.max(...pixels.map((p) => p.x)) + SIZE * 2; + const maxY = Math.max(...pixels.map((p) => p.y)) + SIZE * 2; + svg.setAttribute("viewBox", `${minX} ${minY} ${maxX - minX} ${maxY - minY}`); + + const byId = Object.fromEntries(snapshot.tiles.map((tile) => [tile.id, tile])); + let selected = snapshot.selected_tile && byId[snapshot.selected_tile] ? snapshot.selected_tile : snapshot.tiles[0].id; + + function paintInspect() { + const tile = byId[selected]; + const occupants = (snapshot.units || []).filter((unit) => unit.tile === selected); + inspect.textContent = `${tile.label || tile.id} (${tile.q},${tile.r})${ + occupants.length ? ` — ${occupants.map((unit) => unit.label || unit.id).join(", ")}` : "" + }`; + } + + snapshot.tiles.forEach((tile) => { + const { x, y } = axialToPixel(tile.q, tile.r); + const poly = document.createElementNS("http://www.w3.org/2000/svg", "polygon"); + poly.setAttribute("points", hexPoints(x, y)); + poly.setAttribute("class", tile.id === selected ? "hex selected" : "hex"); + poly.dataset.tile = tile.id; + poly.addEventListener("click", () => { + selected = tile.id; + svg.querySelectorAll(".hex").forEach((node) => { + node.setAttribute("class", node.dataset.tile === selected ? "hex selected" : "hex"); + }); + paintInspect(); + }); + svg.appendChild(poly); + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); + text.setAttribute("x", x); + text.setAttribute("y", y + 18); + text.setAttribute("class", "hex-label"); + text.textContent = tile.label || tile.id; + svg.appendChild(text); + }); + + (snapshot.units || []).forEach((unit) => { + const tile = byId[unit.tile]; + const { x, y } = axialToPixel(tile.q, tile.r); + const marker = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + marker.setAttribute("cx", x); + marker.setAttribute("cy", y - 6); + marker.setAttribute("r", 10); + marker.setAttribute("class", "unit"); + svg.appendChild(marker); + const label = document.createElementNS("http://www.w3.org/2000/svg", "text"); + label.setAttribute("x", x); + label.setAttribute("y", y - 3); + label.setAttribute("class", "hex-label"); + label.setAttribute("fill", "#f4efe4"); + label.textContent = unit.label || unit.id; + svg.appendChild(label); + }); + + (snapshot.feed || []).forEach((item) => { + const li = document.createElement("li"); + li.textContent = `t${item.turn ?? "?"} ${item.text}`; + feed.appendChild(li); + }); + paintInspect(); +} + +async function boot() { + let snapshot = EMBEDDED_SNAPSHOT; + try { + const response = await fetch("sample_snapshot.json", { cache: "no-store" }); + if (response.ok) { + snapshot = await response.json(); + } + } catch (_error) { + snapshot = EMBEDDED_SNAPSHOT; + } + render(validateSnapshot(snapshot)); +} + +boot(); diff --git a/ahbg/presentation/sample_snapshot.json b/ahbg/presentation/sample_snapshot.json new file mode 100644 index 0000000..66265c3 --- /dev/null +++ b/ahbg/presentation/sample_snapshot.json @@ -0,0 +1,22 @@ +{ + "kind": "ahbg.presentation.snapshot", + "standing": "not-mechanics", + "plane_id": "plane-0", + "turn": 0, + "tiles": [ + {"id": "c", "q": 0, "r": 0, "label": "origin"}, + {"id": "ne", "q": 1, "r": -1, "label": "ne"}, + {"id": "e", "q": 1, "r": 0, "label": "e"}, + {"id": "se", "q": 0, "r": 1, "label": "se"}, + {"id": "sw", "q": -1, "r": 1, "label": "sw"}, + {"id": "w", "q": -1, "r": 0, "label": "w"}, + {"id": "nw", "q": 0, "r": -1, "label": "nw"} + ], + "units": [ + {"id": "A0", "tile": "c", "label": "A0"} + ], + "selected_tile": "c", + "feed": [ + {"turn": 0, "text": "plane loaded; A0 at origin"} + ] +} diff --git a/ahbg/presentation/snapshot.py b/ahbg/presentation/snapshot.py new file mode 100644 index 0000000..a2f7054 --- /dev/null +++ b/ahbg/presentation/snapshot.py @@ -0,0 +1,86 @@ +"""AHBG presentation snapshot — visual fields only. + +This is not plane state and not a mechanics contract. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Mapping + + +KIND = "ahbg.presentation.snapshot" +STANDING = "not-mechanics" +SAMPLE_PATH = Path(__file__).resolve().parent / "sample_snapshot.json" + + +class PresentationSnapshotError(ValueError): + """Fail-closed presentation snapshot error.""" + + +def load_snapshot(path: Path | None = None) -> Mapping[str, Any]: + target = SAMPLE_PATH if path is None else path + return validate_snapshot(json.loads(target.read_text(encoding="utf-8"))) + + +def validate_snapshot(payload: Mapping[str, Any]) -> Mapping[str, Any]: + if not isinstance(payload, Mapping): + raise PresentationSnapshotError("snapshot must be an object") + if payload.get("kind") != KIND: + raise PresentationSnapshotError(f"kind must be {KIND}") + if payload.get("standing") != STANDING: + raise PresentationSnapshotError(f"standing must be {STANDING}") + if not isinstance(payload.get("plane_id"), str) or not payload["plane_id"]: + raise PresentationSnapshotError("plane_id must be exact non-empty text") + if isinstance(payload.get("turn"), bool) or not isinstance(payload.get("turn"), int) or payload["turn"] < 0: + raise PresentationSnapshotError("turn must be a non-negative int") + tiles = payload.get("tiles") + if not isinstance(tiles, list) or not tiles: + raise PresentationSnapshotError("tiles must be a non-empty list") + ids: set[str] = set() + coords: set[tuple[int, int]] = set() + for tile in tiles: + if not isinstance(tile, Mapping): + raise PresentationSnapshotError("each tile must be an object") + tile_id = tile.get("id") + q, r = tile.get("q"), tile.get("r") + if not isinstance(tile_id, str) or not tile_id: + raise PresentationSnapshotError("tile id must be exact non-empty text") + if tile_id in ids: + raise PresentationSnapshotError(f"tile id repeats: {tile_id}") + if isinstance(q, bool) or isinstance(r, bool) or not isinstance(q, int) or not isinstance(r, int): + raise PresentationSnapshotError(f"tile {tile_id} q,r must be ints") + if (q, r) in coords: + raise PresentationSnapshotError(f"tile axial coordinate repeats: {(q, r)}") + ids.add(tile_id) + coords.add((q, r)) + label = tile.get("label") + if label is not None and (not isinstance(label, str) or not label): + raise PresentationSnapshotError(f"tile {tile_id} label must be exact non-empty text when present") + units = payload.get("units") + if not isinstance(units, list): + raise PresentationSnapshotError("units must be a list") + unit_ids: set[str] = set() + for unit in units: + if not isinstance(unit, Mapping): + raise PresentationSnapshotError("each unit must be an object") + unit_id = unit.get("id") + tile_id = unit.get("tile") + if not isinstance(unit_id, str) or not unit_id: + raise PresentationSnapshotError("unit id must be exact non-empty text") + if unit_id in unit_ids: + raise PresentationSnapshotError(f"unit id repeats: {unit_id}") + if tile_id not in ids: + raise PresentationSnapshotError(f"unit {unit_id} tile {tile_id!r} is not a presented tile") + unit_ids.add(unit_id) + selected = payload.get("selected_tile") + if selected is not None and selected not in ids: + raise PresentationSnapshotError("selected_tile must name a presented tile") + feed = payload.get("feed") + if not isinstance(feed, list): + raise PresentationSnapshotError("feed must be a list") + for item in feed: + if not isinstance(item, Mapping) or not isinstance(item.get("text"), str) or not item["text"]: + raise PresentationSnapshotError("each feed item must have exact non-empty text") + return payload diff --git a/ahbg/presentation/tests/test_snapshot.py b/ahbg/presentation/tests/test_snapshot.py new file mode 100644 index 0000000..17513c9 --- /dev/null +++ b/ahbg/presentation/tests/test_snapshot.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import copy +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from snapshot import KIND, PresentationSnapshotError, load_snapshot, validate_snapshot + + +class PresentationSnapshotTest(unittest.TestCase): + def test_sample_loads(self) -> None: + snapshot = load_snapshot() + self.assertEqual(snapshot["kind"], KIND) + self.assertEqual(snapshot["standing"], "not-mechanics") + self.assertEqual(len(snapshot["tiles"]), 7) + self.assertEqual(snapshot["units"][0]["id"], "A0") + + def test_unknown_unit_tile_fails_closed(self) -> None: + snapshot = copy.deepcopy(dict(load_snapshot())) + snapshot["units"] = [{"id": "A0", "tile": "missing", "label": "A0"}] + with self.assertRaisesRegex(PresentationSnapshotError, "not a presented tile"): + validate_snapshot(snapshot) + + def test_wrong_kind_fails_closed(self) -> None: + snapshot = copy.deepcopy(dict(load_snapshot())) + snapshot["kind"] = "ahbg.plane" + with self.assertRaisesRegex(PresentationSnapshotError, "kind must be"): + validate_snapshot(snapshot) + + +if __name__ == "__main__": + unittest.main() From 554eb069b05f207ad2e456a9d6ceac39ab342685 Mon Sep 17 00:00:00 2001 From: erinepshovel-code <250928284+erinepshovel-code@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:36:12 +0000 Subject: [PATCH 02/15] Render AHBG tiles as Seed of Life centerpoints Each tile is a centerpoint. Circles have radius equal to adjacent-center distance and pass through neighboring tiles. Hex polygons are removed. --- ahbg/README.md | 4 +- ahbg/presentation/README.md | 5 +- ahbg/presentation/board.css | 35 +++++++--- ahbg/presentation/board.html | 4 +- ahbg/presentation/board.js | 86 ++++++++++++++---------- ahbg/presentation/geometry.py | 40 +++++++++++ ahbg/presentation/tests/test_snapshot.py | 15 +++++ 7 files changed, 141 insertions(+), 48 deletions(-) create mode 100644 ahbg/presentation/geometry.py diff --git a/ahbg/README.md b/ahbg/README.md index 943fd1a..db56635 100644 --- a/ahbg/README.md +++ b/ahbg/README.md @@ -20,8 +20,8 @@ Owns visual implementation: Grok does not define game mechanics. Current Grok surface: [`presentation/`](presentation/) renders a -`ahbg.presentation.snapshot` (seven-tile hex neighborhood, A0 marker, feed, -tile inspect). That snapshot is not plane state. +`ahbg.presentation.snapshot` (Seed of Life circles, tile = centerpoint, A0 +marker, feed, inspect). That snapshot is not plane state. ### Codex — game engine / runtime diff --git a/ahbg/presentation/README.md b/ahbg/presentation/README.md index 824a3a8..566e59d 100644 --- a/ahbg/presentation/README.md +++ b/ahbg/presentation/README.md @@ -9,9 +9,10 @@ is not identity. ## Boundary -- Included: hex neighborhood rendering, unit marker, selection highlight, human feed +- Included: Seed of Life circle rendering, tile-as-centerpoint, unit marker, selection, human feed - Excluded: turns, movement, construction, War, loyalty, DM rolls, legal observation - Codex owns engine state. This snapshot is `ahbg.presentation.snapshot`, not plane state. +- A tile is the centerpoint. The circle around it is geometry, not the tile. ## Usage @@ -45,6 +46,6 @@ Unknown mechanic fields are ignored. Missing required visual fields fail closed. ## hmmm -- exact sacred-geometry vocabulary of the board (hex is a presentation choice) +- whether later Flower-of-Life rings are presentation-only extensions of this Seed - whether Codex plane state will map 1:1 onto this snapshot - animation of motion/construction once the engine emits events diff --git a/ahbg/presentation/board.css b/ahbg/presentation/board.css index 12c45b3..2cf4a1e 100644 --- a/ahbg/presentation/board.css +++ b/ahbg/presentation/board.css @@ -46,21 +46,33 @@ svg { align-self: center; } -.hex { - fill: var(--tile); +.seed-circle { + fill: none; stroke: var(--tile-stroke); - stroke-width: 2; - cursor: pointer; + stroke-width: 1.5; + pointer-events: none; +} + +.tile-point { + fill: var(--tile-stroke); + stroke: var(--paper); + stroke-width: 1.5; + pointer-events: none; } -.hex.selected { +.tile-point.selected { + fill: var(--selected); stroke: var(--selected); - stroke-width: 4; } -.hex-label { +.tile-hit { + fill: transparent; + cursor: pointer; +} + +.tile-label { fill: var(--ink); - font-size: 12px; + font-size: 11px; pointer-events: none; text-anchor: middle; } @@ -72,6 +84,13 @@ svg { pointer-events: none; } +.unit-label { + fill: var(--paper); + font-size: 10px; + pointer-events: none; + text-anchor: middle; +} + .feed { background: #efe7d6; border-left: 1px solid var(--tile-stroke); diff --git a/ahbg/presentation/board.html b/ahbg/presentation/board.html index aafab49..db18d98 100644 --- a/ahbg/presentation/board.html +++ b/ahbg/presentation/board.html @@ -10,8 +10,8 @@

AHBG plane

-

Presentation only. Not mechanics. Click a tile to inspect it.

- +

Presentation only. Not mechanics. Each tile is a centerpoint. Circles are Seed of Life geometry through neighboring centers. Click a center to inspect.

+