diff --git a/.github/workflows/ahbg-presentation.yml b/.github/workflows/ahbg-presentation.yml new file mode 100644 index 0000000..81840c5 --- /dev/null +++ b/.github/workflows/ahbg-presentation.yml @@ -0,0 +1,28 @@ +name: ahbg-presentation + +on: + pull_request: + paths: + - "ahbg/presentation/**" + - ".github/workflows/ahbg-presentation.yml" + push: + branches: [main] + paths: + - "ahbg/presentation/**" + - ".github/workflows/ahbg-presentation.yml" + +permissions: + contents: read + +jobs: + presentation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Python presentation contract + run: python -m unittest discover -s ahbg/presentation/tests -p 'test*.py' + - name: Browser JavaScript syntax + run: node --check ahbg/presentation/board.js diff --git a/ahbg/presentation/README.md b/ahbg/presentation/README.md new file mode 100644 index 0000000..c46b141 --- /dev/null +++ b/ahbg/presentation/README.md @@ -0,0 +1,95 @@ +# AHBG presentation + +Grok-owned graphics surface. It renders validated presentation snapshots and +already-resolved motion traces. It does **not** define game mechanics or derive +UCNS geometry. + +## Boundary + +- Included: Seed-of-Life circle rendering, UCNS-supplied tile centerpoints, + unit markers, selection/inspection, public feed text, and visual traces of + already-resolved moves. +- Excluded: legal movement, adjacency authority, turns, War resolution, + construction, permissions, RNG, DM state, private prompt state, or agent policy. +- Snapshot standing is `ahbg.presentation.snapshot` / `not-mechanics`. +- Every snapshot carries an exact `geometry_source` identity plus each tile's + UCNS-derived `x`, `y`, and `source_slot`. The renderer only scales those + coordinates for SVG display; it never rebuilds centers from local axial rules. +- The current sample pins `The-Interdependency/ucns@1975fe70cf4e0826a8020c2da3047569e277af64`, + the canonical `libs/ucns/` identity in the stack manifest when this surface was + authored. Its `mobius_seed` module remains an explicit nonselecting UCNS + candidate; presentation consumption does not promote its standing. +- `project.py` accepts already-sanitized observation data, source-backed display + coordinates, and resolved move events. It never decides whether those moves + were legal and copies only declared browser-facing fields. +- `geometry.py` scales supplied source coordinates into display pixels only. + +## Usage guidance + +Validate the package: + +```bash +python -m unittest discover -s ahbg/presentation/tests -p 'test*.py' +node --check ahbg/presentation/board.js +``` + +Serve the board locally: + +```bash +cd ahbg/presentation +python -m http.server 8765 --bind 127.0.0.1 +# open http://127.0.0.1:8765/board.html +``` + +Project a sanitized observation only after the owning adapter has attached the +UCNS-derived centers and exact source identity: + +```python +from ahbg.presentation.project import snapshot_from_observation + +geometry_source = { + "repository": "The-Interdependency/ucns", + "commit": "1975fe70cf4e0826a8020c2da3047569e277af64", + "module": "src/ucns/mobius_seed.py", + "schema_id": "ucns.mobius-seed-of-life", + "schema_version": "0.1.0", + "projection_id": "seed-of-life-seven-equal-circles", + "selection_effect": "none", +} +observation = { + "turn": 1, + "geometry_source": geometry_source, + "tiles": [ + {"tile_id": "CENTER", "ucns_slot": "CENTER", "x": 0.0, "y": 0.0}, + {"tile_id": "RING_0", "ucns_slot": "RING_0", "x": 1.0, "y": 0.0}, + ], + "units": [{"unit_id": "A0", "tile_id": "RING_0", "label": "A0"}], +} +move = { + "kind": "move", + "data": {"unit_id": "A0", "from_tile_id": "CENTER", "to_tile_id": "RING_0"}, +} +snapshot = snapshot_from_observation( + observation, + plane_id="plane-0", + move_events=[move], + feed=[{"turn": 1, "text": "public display text only"}], +) +``` + +The snapshot validator is allowlist-based. Unknown root, geometry, tile, unit, +feed, or motion fields fail closed. The projector drops undeclared observation, +move-event, and feed metadata before validation, preventing a visual snapshot +from becoming a transport for private/internal fields. + +The browser exposes tile inspection to pointer and keyboard input, permits +interactive descendants under an SVG `group` role, scales unit-marker spread by +occupant count, and renders the selection ring above the unit layer. + +## hmmm + +- whether later Flower-of-Life rings belong on this presentation surface; +- the exact live engine-to-observation adapter that attaches UCNS positions is + owned by the eventual engine integration, not by this graphics package; +- construction animation remains unavailable until an owning mechanics layer + emits an already-resolved construction event contract. diff --git a/ahbg/presentation/__init__.py b/ahbg/presentation/__init__.py new file mode 100644 index 0000000..90b0e75 --- /dev/null +++ b/ahbg/presentation/__init__.py @@ -0,0 +1,36 @@ +# === MODULE_BUILD === +# id: ahbg_presentation_public_boundary +# module_name: presentation +# module_kind: adapter +# summary: exposes the validated presentation snapshot and projection boundary without exporting mechanics +# owner: AHBG presentation +# public_surface: PresentationSnapshotError, load_snapshot, snapshot_from_observation, validate_snapshot +# internal_surface: none +# auth_boundary: none +# storage_boundary: none +# network_boundary: none +# user_data_boundary: none +# admin_only: false +# tests: ahbg/presentation/tests/test_presentation.py +# rollout: import explicitly from ahbg.presentation +# rollback: remove package exports with presentation package +# requires: ahbg_presentation_snapshot_contract, ahbg_presentation_observation_projector +# since: 2026-08-31 +# unresolved: none +# === END MODULE_BUILD === + +"""Presentation-only AHBG graphics boundary. + +Usage guidance: import only validated presentation helpers from this package; +game mechanics remain outside this namespace. +""" + +from .project import snapshot_from_observation +from .snapshot import PresentationSnapshotError, load_snapshot, validate_snapshot + +__all__ = [ + "PresentationSnapshotError", + "load_snapshot", + "snapshot_from_observation", + "validate_snapshot", +] diff --git a/ahbg/presentation/board.css b/ahbg/presentation/board.css new file mode 100644 index 0000000..12e271d --- /dev/null +++ b/ahbg/presentation/board.css @@ -0,0 +1,45 @@ +:root { + --ink: #1b1a17; + --paper: #f4efe4; + --tile: #e6dcc8; + --tile-stroke: #6b5d44; + --selected: #c45c26; + --unit: #1f4b99; +} + +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; } +.seed-circle { fill: none; stroke: var(--tile-stroke); stroke-width: 1.5; pointer-events: none; } +.tile-point { fill: var(--tile-stroke); stroke: var(--paper); stroke-width: 1.5; pointer-events: none; } +.tile-point.selected { fill: var(--selected); stroke: var(--selected); } +.motion-path { fill: none; stroke: var(--selected); stroke-width: 2.5; stroke-dasharray: 6 4; pointer-events: none; } +.tile-hit { fill: transparent; cursor: pointer; stroke: transparent; stroke-width: 3; } +.tile-hit:focus { outline: none; stroke: var(--selected); } +.tile-label { fill: var(--ink); font-size: 11px; pointer-events: none; text-anchor: middle; } +.unit { fill: var(--unit); stroke: var(--paper); stroke-width: 2; pointer-events: none; } +.unit-label { fill: var(--paper); font-size: 10px; pointer-events: none; text-anchor: middle; } +.selection-ring { fill: none; stroke: var(--selected); stroke-width: 3; 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..5ecc6c6 --- /dev/null +++ b/ahbg/presentation/board.html @@ -0,0 +1,24 @@ + + + + + + AHBG presentation board + + + +
+
+

AHBG plane

+

Presentation only. Not mechanics. Tile centers are supplied by the declared UCNS source; the browser only scales them for display. Dashed traces show already-resolved motion. Click a center, or focus it and press Enter/Space, to inspect.

+ +
+ +
+ + + diff --git a/ahbg/presentation/board.js b/ahbg/presentation/board.js new file mode 100644 index 0000000..ee7d3f3 --- /dev/null +++ b/ahbg/presentation/board.js @@ -0,0 +1,350 @@ +// === MODULE_BUILD === +// id: ahbg_presentation_browser_board +// module_name: board +// module_kind: ui_panel +// summary: renders strict presentation snapshots using source-supplied UCNS center coordinates, accessible tile inspection, resolved motion traces, and nonoverlapping unit markers +// owner: AHBG presentation +// public_surface: browser board UI +// internal_surface: validateSnapshot, render, sourceToPixel, offsetFor, boot +// auth_boundary: none +// storage_boundary: none +// network_boundary: internal +// user_data_boundary: read +// admin_only: false +// tests: ahbg/presentation/tests/test_presentation.py; node --check ahbg/presentation/board.js +// rollout: static presentation page only +// rollback: remove ahbg/presentation browser files without changing mechanics +// requires: ahbg_presentation_snapshot_contract; UCNS-derived x/y and geometry_source carried by snapshot +// since: 2026-08-31 +// unresolved: live engine-to-observation adapter remains outside presentation +// === END MODULE_BUILD === + +const UCNS_COMMIT = "1975fe70cf4e0826a8020c2da3047569e277af64"; +const SQRT3_HALF = Math.sqrt(3) / 2; +const EMBEDDED_SNAPSHOT = { + kind: "ahbg.presentation.snapshot", + standing: "not-mechanics", + plane_id: "plane-0", + turn: 1, + geometry_source: { + repository: "The-Interdependency/ucns", + commit: UCNS_COMMIT, + module: "src/ucns/mobius_seed.py", + schema_id: "ucns.mobius-seed-of-life", + schema_version: "0.1.0", + projection_id: "seed-of-life-seven-equal-circles", + selection_effect: "none", + }, + tiles: [ + { id: "CENTER", source_slot: "CENTER", x: 0, y: 0, label: "origin" }, + { id: "RING_0", source_slot: "RING_0", x: 1, y: 0, label: "ring 0" }, + { id: "RING_1", source_slot: "RING_1", x: 0.5, y: SQRT3_HALF, label: "ring 1" }, + { id: "RING_2", source_slot: "RING_2", x: -0.5, y: SQRT3_HALF, label: "ring 2" }, + { id: "RING_3", source_slot: "RING_3", x: -1, y: 0, label: "ring 3" }, + { id: "RING_4", source_slot: "RING_4", x: -0.5, y: -SQRT3_HALF, label: "ring 4" }, + { id: "RING_5", source_slot: "RING_5", x: 0.5, y: -SQRT3_HALF, label: "ring 5" }, + ], + units: [{ id: "A0", tile: "RING_0", label: "A0" }], + selected_tile: "RING_0", + motions: [{ unit: "A0", from: "CENTER", to: "RING_0" }], + feed: [ + { turn: 0, text: "plane loaded; A0 at origin" }, + { turn: 1, text: "A0 trace CENTER to RING_0" }, + ], +}; + +const RADIUS = 64; +const TILE_POINT = 6; +const UNIT_RADIUS = 11; +const ROOT_FIELDS = new Set(["kind", "standing", "plane_id", "turn", "geometry_source", "tiles", "units", "selected_tile", "feed", "motions"]); +const GEOMETRY_FIELDS = new Set(["repository", "commit", "module", "schema_id", "schema_version", "projection_id", "selection_effect"]); +const TILE_FIELDS = new Set(["id", "label", "source_slot", "x", "y"]); +const UNIT_FIELDS = new Set(["id", "tile", "label"]); +const FEED_FIELDS = new Set(["turn", "text"]); +const MOTION_FIELDS = new Set(["unit", "from", "to"]); + +function sourceToPixel(tile) { + return { x: RADIUS * tile.x, y: -RADIUS * tile.y }; +} + +function exactText(value) { + return typeof value === "string" && value.length > 0; +} + +function numeric(value) { + return typeof value === "number" && Number.isFinite(value); +} + +function plainInteger(value) { + return Number.isInteger(value); +} + +function rejectUnknown(value, allowed, surface) { + const unknown = Object.keys(value).filter((key) => !allowed.has(key)).sort(); + if (unknown.length) throw new Error(`${surface} has undeclared fields: ${unknown.join(", ")}`); +} + +function validateSnapshot(snapshot) { + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { + throw new Error("snapshot must be an object"); + } + rejectUnknown(snapshot, ROOT_FIELDS, "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 (!exactText(snapshot.plane_id)) throw new Error("plane_id must be exact non-empty text"); + if (!plainInteger(snapshot.turn) || snapshot.turn < 0) throw new Error("turn must be a non-negative integer"); + + const geometry = snapshot.geometry_source; + if (!geometry || typeof geometry !== "object" || Array.isArray(geometry)) throw new Error("geometry_source must be an object"); + rejectUnknown(geometry, GEOMETRY_FIELDS, "geometry_source"); + for (const field of GEOMETRY_FIELDS) { + if (!exactText(geometry[field])) throw new Error(`geometry_source.${field} must be exact non-empty text`); + } + if (!/^[0-9a-f]{40}$/.test(geometry.commit)) throw new Error("geometry_source.commit must be a lowercase 40-hex commit"); + + if (!Array.isArray(snapshot.tiles) || snapshot.tiles.length === 0) throw new Error("tiles must be a non-empty list"); + const ids = new Set(); + const sourceSlots = new Set(); + const positions = new Set(); + for (const tile of snapshot.tiles) { + if (!tile || typeof tile !== "object" || Array.isArray(tile)) throw new Error("each tile must be an object"); + rejectUnknown(tile, TILE_FIELDS, "tile"); + if (!exactText(tile.id)) throw new Error("tile id must be exact non-empty text"); + if (ids.has(tile.id)) throw new Error(`tile id repeats: ${tile.id}`); + if (!exactText(tile.source_slot)) throw new Error(`tile ${tile.id} source_slot must be exact non-empty text`); + if (sourceSlots.has(tile.source_slot)) throw new Error(`UCNS source slot repeats: ${tile.source_slot}`); + if (!numeric(tile.x) || !numeric(tile.y)) throw new Error(`tile ${tile.id} x,y must be finite numbers`); + const position = `${tile.x},${tile.y}`; + if (positions.has(position)) throw new Error(`tile source position repeats: ${position}`); + if (tile.label !== undefined && !exactText(tile.label)) throw new Error(`tile ${tile.id} label must be exact non-empty text when present`); + ids.add(tile.id); + sourceSlots.add(tile.source_slot); + positions.add(position); + } + + if (!Array.isArray(snapshot.units)) throw new Error("units must be a list"); + const unitIds = new Set(); + const unitById = new Map(); + for (const unit of snapshot.units) { + if (!unit || typeof unit !== "object" || Array.isArray(unit)) throw new Error("each unit must be an object"); + rejectUnknown(unit, UNIT_FIELDS, "unit"); + if (!exactText(unit.id)) throw new Error("unit id must be exact non-empty text"); + if (unitIds.has(unit.id)) throw new Error(`unit id repeats: ${unit.id}`); + if (!exactText(unit.tile) || !ids.has(unit.tile)) throw new Error(`unit ${unit.id} tile is not a presented tile`); + if (unit.label !== undefined && !exactText(unit.label)) throw new Error(`unit ${unit.id} label must be exact non-empty text when present`); + unitIds.add(unit.id); + unitById.set(unit.id, unit); + } + + if (snapshot.selected_tile !== undefined && snapshot.selected_tile !== null) { + if (!exactText(snapshot.selected_tile) || !ids.has(snapshot.selected_tile)) throw new Error("selected_tile must name a presented tile"); + } + if (!Array.isArray(snapshot.feed)) throw new Error("feed must be a list"); + for (const item of snapshot.feed) { + if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("each feed item must be an object"); + rejectUnknown(item, FEED_FIELDS, "feed item"); + if (!exactText(item.text)) throw new Error("each feed item must have exact non-empty text"); + if (item.turn !== undefined && (!plainInteger(item.turn) || item.turn < 0)) throw new Error("feed turn must be a non-negative integer when present"); + } + + let motions = []; + if (Object.prototype.hasOwnProperty.call(snapshot, "motions")) { + if (!Array.isArray(snapshot.motions)) throw new Error("motions must be a list when present"); + motions = snapshot.motions; + } + const motionUnits = new Set(); + for (const motion of motions) { + if (!motion || typeof motion !== "object" || Array.isArray(motion)) throw new Error("each motion must be an object"); + rejectUnknown(motion, MOTION_FIELDS, "motion"); + if (!exactText(motion.unit) || !unitById.has(motion.unit)) throw new Error(`motion unit ${motion.unit} is not a presented unit`); + if (motionUnits.has(motion.unit)) throw new Error(`motion repeats unit ${motion.unit}`); + if (!exactText(motion.from) || !ids.has(motion.from)) throw new Error(`motion from ${motion.from} is not a presented tile`); + if (!exactText(motion.to) || !ids.has(motion.to)) throw new Error(`motion to ${motion.to} is not a presented tile`); + if (motion.from === motion.to) throw new Error(`motion for ${motion.unit} must change tiles`); + if (unitById.get(motion.unit).tile !== motion.to) throw new Error(`motion destination for ${motion.unit} must match its presented tile`); + motionUnits.add(motion.unit); + } + 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 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; + const hitByTile = new Map(); + let selectionRing = null; + + const motionByUnit = Object.fromEntries((snapshot.motions || []).map((motion) => [motion.unit, motion])); + function groupUnitsBy(getTile) { + const groups = new Map(); + snapshot.units.forEach((unit) => { + const tile = getTile(unit); + const group = groups.get(tile) || []; + group.push(unit.id); + groups.set(tile, group); + }); + return groups; + } + // Initial occupancy is computed from each unit's motion origin, not its + // presented (final) tile. A unit that moved off a crowded tile must use the + // initial group there; a unit that arrived on a crowded tile must use the + // final group here. Offsets are computed separately for each end. + const finalGroups = groupUnitsBy((unit) => unit.tile); + const initialGroups = groupUnitsBy((unit) => (motionByUnit[unit.id] ? motionByUnit[unit.id].from : unit.tile)); + + function offsetFor(unit, groups, tile) { + const group = groups.get(tile) || [unit.id]; + if (group.length === 1) return { x: 0, y: 0 }; + const index = group.indexOf(unit.id); + const angle = (Math.PI * 2 * index) / group.length - Math.PI / 2; + const minimumChord = UNIT_RADIUS * 2 + 4; + const spread = Math.max(UNIT_RADIUS * 1.35, minimumChord / (2 * Math.sin(Math.PI / group.length))); + return { x: Math.cos(angle) * spread, y: Math.sin(angle) * spread }; + } + + const placements = snapshot.units.map((unit) => { + const motion = motionByUnit[unit.id]; + const initialTile = motion ? motion.from : unit.tile; + const initialCenter = sourceToPixel(byId[initialTile]); + const finalCenter = sourceToPixel(byId[unit.tile]); + const initialOffset = offsetFor(unit, initialGroups, initialTile); + const finalOffset = offsetFor(unit, finalGroups, unit.tile); + return { + unit, + motion, + origin: { x: initialCenter.x + initialOffset.x, y: initialCenter.y + initialOffset.y }, + dest: { x: finalCenter.x + finalOffset.x, y: finalCenter.y + finalOffset.y }, + }; + }); + + // viewBox covers seed circles and every displaced unit marker at both its + // motion origin and its presented destination, so offset markers never clip. + const markerExtent = UNIT_RADIUS + 8; + let minX = Math.min(...snapshot.tiles.map((tile) => sourceToPixel(tile).x - RADIUS)); + let minY = Math.min(...snapshot.tiles.map((tile) => sourceToPixel(tile).y - RADIUS)); + let maxX = Math.max(...snapshot.tiles.map((tile) => sourceToPixel(tile).x + RADIUS)); + let maxY = Math.max(...snapshot.tiles.map((tile) => sourceToPixel(tile).y + RADIUS)); + placements.forEach(({ origin, dest }) => { + minX = Math.min(minX, origin.x - markerExtent, dest.x - markerExtent); + minY = Math.min(minY, origin.y - markerExtent, dest.y - markerExtent); + maxX = Math.max(maxX, origin.x + markerExtent, dest.x + markerExtent); + maxY = Math.max(maxY, origin.y + markerExtent, dest.y + markerExtent); + }); + svg.setAttribute("viewBox", `${minX} ${minY} ${maxX - minX} ${maxY - minY}`); + + function paintInspect() { + const tile = byId[selected]; + const occupants = snapshot.units.filter((unit) => unit.tile === selected); + inspect.textContent = `tile ${tile.label || tile.id} — UCNS ${tile.source_slot} @ (${tile.x}, ${tile.y})${occupants.length ? ` — ${occupants.map((unit) => unit.label || unit.id).join(", ")}` : ""}`; + } + + function paintSelection() { + svg.querySelectorAll(".tile-point").forEach((node) => { + node.setAttribute("class", node.dataset.tile === selected ? "tile-point selected" : "tile-point"); + }); + for (const [tileId, node] of hitByTile.entries()) node.setAttribute("aria-pressed", tileId === selected ? "true" : "false"); + if (selectionRing) { + const { x, y } = sourceToPixel(byId[selected]); + selectionRing.setAttribute("cx", x); + selectionRing.setAttribute("cy", y); + } + paintInspect(); + } + + snapshot.tiles.forEach((tile) => { + const { x, y } = sourceToPixel(tile); + const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circle.setAttribute("cx", x); + circle.setAttribute("cy", y); + circle.setAttribute("r", RADIUS); + circle.setAttribute("class", "seed-circle"); + svg.appendChild(circle); + }); + + (snapshot.motions || []).forEach((motion) => { + const from = sourceToPixel(byId[motion.from]); + const to = sourceToPixel(byId[motion.to]); + const path = document.createElementNS("http://www.w3.org/2000/svg", "line"); + path.setAttribute("x1", from.x); + path.setAttribute("y1", from.y); + path.setAttribute("x2", to.x); + path.setAttribute("y2", to.y); + path.setAttribute("class", "motion-path"); + svg.appendChild(path); + }); + + snapshot.tiles.forEach((tile) => { + const { x, y } = sourceToPixel(tile); + const point = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + point.setAttribute("cx", x); + point.setAttribute("cy", y); + point.setAttribute("r", TILE_POINT); + point.setAttribute("class", tile.id === selected ? "tile-point selected" : "tile-point"); + point.dataset.tile = tile.id; + svg.appendChild(point); + + const hit = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + hit.setAttribute("cx", x); + hit.setAttribute("cy", y); + hit.setAttribute("r", RADIUS * 0.28); + hit.setAttribute("class", "tile-hit"); + hit.setAttribute("tabindex", "0"); + hit.setAttribute("role", "button"); + hit.setAttribute("aria-label", `Inspect tile ${tile.label || tile.id}`); + hit.setAttribute("aria-pressed", tile.id === selected ? "true" : "false"); + const selectTile = () => { selected = tile.id; paintSelection(); }; + hit.addEventListener("click", selectTile); + hit.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { event.preventDefault(); selectTile(); } + }); + hitByTile.set(tile.id, hit); + svg.appendChild(hit); + + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); + text.setAttribute("x", x); + text.setAttribute("y", y + RADIUS * 0.38); + text.setAttribute("class", "tile-label"); + text.textContent = tile.label || tile.id; + svg.appendChild(text); + }); + + placements.forEach(({ unit, motion, origin, dest }) => { + const marker = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + marker.setAttribute("cx", origin.x); marker.setAttribute("cy", origin.y); marker.setAttribute("r", UNIT_RADIUS); marker.setAttribute("class", "unit"); + svg.appendChild(marker); + const label = document.createElementNS("http://www.w3.org/2000/svg", "text"); + label.setAttribute("x", origin.x); label.setAttribute("y", origin.y + 4); label.setAttribute("class", "unit-label"); label.textContent = unit.label || unit.id; + svg.appendChild(label); + + if (motion) { + const dur = "0.8s"; + [["cx", origin.x, dest.x, marker], ["cy", origin.y, dest.y, marker], ["x", origin.x, dest.x, label], ["y", origin.y + 4, dest.y + 4, label]].forEach(([name, from, to, node]) => { + const animate = document.createElementNS("http://www.w3.org/2000/svg", "animate"); + animate.setAttribute("attributeName", name); animate.setAttribute("from", from); animate.setAttribute("to", to); animate.setAttribute("dur", dur); animate.setAttribute("fill", "freeze"); node.appendChild(animate); + }); + } + }); + + selectionRing = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + selectionRing.setAttribute("r", UNIT_RADIUS + 6); selectionRing.setAttribute("class", "selection-ring"); svg.appendChild(selectionRing); + snapshot.feed.forEach((item) => { const li = document.createElement("li"); li.textContent = `t${item.turn ?? "?"} ${item.text}`; feed.appendChild(li); }); + paintSelection(); +} + +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/geometry.py b/ahbg/presentation/geometry.py new file mode 100644 index 0000000..e66d333 --- /dev/null +++ b/ahbg/presentation/geometry.py @@ -0,0 +1,58 @@ +# === MODULE_BUILD === +# id: ahbg_presentation_display_transform +# module_name: geometry +# module_kind: adapter +# summary: scales already-supplied UCNS source coordinates into SVG display coordinates without reconstructing or selecting geometry +# owner: AHBG presentation +# public_surface: source_to_display, center_distance +# internal_surface: none +# auth_boundary: none +# storage_boundary: none +# network_boundary: none +# user_data_boundary: none +# admin_only: false +# tests: ahbg/presentation/tests/test_presentation.py +# rollout: used only by presentation tests and equivalent browser transform +# rollback: inline display scaling or remove presentation package; no mechanics effect +# requires: caller-supplied UCNS-derived x/y source coordinates +# since: 2026-08-31 +# unresolved: none +# === END MODULE_BUILD === + +"""Presentation-only transform of source-backed center coordinates. + +This module does not derive Seed-of-Life centers, adjacency, orientation, or +nesting. It only scales coordinates already supplied by the declared UCNS source +into display pixels; optional y inversion is the SVG screen-coordinate transform. + +Usage guidance: + ``source_to_display(x, y, scale=64.0)`` after validating the snapshot. +""" + +from __future__ import annotations + +import math + + +def source_to_display( + x: float, + y: float, + scale: float, + *, + invert_y: bool = True, +) -> tuple[float, float]: + """Scale source coordinates for display without changing their topology.""" + + if isinstance(x, bool) or not isinstance(x, (int, float)): + raise ValueError("x must be numeric and nonboolean") + if isinstance(y, bool) or not isinstance(y, (int, float)): + raise ValueError("y must be numeric and nonboolean") + if isinstance(scale, bool) or not isinstance(scale, (int, float)) or scale <= 0: + raise ValueError("scale must be positive numeric and nonboolean") + return float(x) * float(scale), (-float(y) if invert_y else float(y)) * float(scale) + + +def center_distance(left: tuple[float, float], right: tuple[float, float]) -> float: + """Measure distance between already-supplied source/display points.""" + + return math.hypot(left[0] - right[0], left[1] - right[1]) diff --git a/ahbg/presentation/project.py b/ahbg/presentation/project.py new file mode 100644 index 0000000..aae4583 --- /dev/null +++ b/ahbg/presentation/project.py @@ -0,0 +1,149 @@ +# === MODULE_BUILD === +# id: ahbg_presentation_observation_projector +# module_name: project +# module_kind: adapter +# summary: projects a sanitized public observation plus UCNS-derived display coordinates into the strict presentation snapshot schema +# owner: AHBG presentation +# public_surface: snapshot_from_observation +# internal_surface: declared-field projection for geometry source, tiles, units, feed, and resolved motion events +# auth_boundary: none +# storage_boundary: none +# network_boundary: none +# user_data_boundary: read +# admin_only: false +# tests: ahbg/presentation/tests/test_presentation.py +# rollout: explicit caller use only +# rollback: remove projector while retaining snapshot schema and static sample +# requires: ahbg_presentation_snapshot_contract; caller-supplied UCNS-derived x/y positions and exact geometry source identity +# since: 2026-08-31 +# unresolved: live engine adapter that supplies UCNS positions is outside presentation ownership +# === END MODULE_BUILD === + +"""Project a sanitized observation into an AHBG presentation snapshot. + +This is graphics. It does not decide adjacency, legality, turn resolution, or +UCNS geometry. Callers must supply UCNS-derived display ``x``/``y`` positions +and exact geometry source identity. Unknown observation/feed fields are dropped +rather than copied into the browser-facing envelope. + +Usage guidance: + Call ``snapshot_from_observation`` only after the owning engine/adapter has + attached source-backed UCNS positions to the public observation. +""" + +from __future__ import annotations + +from typing import Any, Mapping, Sequence + +from .snapshot import KIND, STANDING, PresentationSnapshotError, validate_snapshot + + +_GEOMETRY_FIELDS = ( + "repository", + "commit", + "module", + "schema_id", + "schema_version", + "projection_id", + "selection_effect", +) + + +def snapshot_from_observation( + observation: Mapping[str, Any], + *, + plane_id: str, + selected_tile: str | None = None, + feed: Sequence[Mapping[str, Any]] = (), + move_events: Sequence[Mapping[str, Any]] = (), +) -> dict[str, Any]: + """Map declared public visual data and already-resolved moves to display data.""" + + if not isinstance(observation, Mapping): + raise PresentationSnapshotError("observation must be an object") + if not isinstance(plane_id, str) or not plane_id: + raise PresentationSnapshotError("plane_id must be exact non-empty text") + + raw_geometry = observation.get("geometry_source") + if not isinstance(raw_geometry, Mapping): + raise PresentationSnapshotError("observation geometry_source must be an object") + geometry_source = {field: raw_geometry.get(field) for field in _GEOMETRY_FIELDS} + + raw_tiles = observation.get("tiles") + raw_units = observation.get("units") + if not isinstance(raw_tiles, list) or not raw_tiles: + raise PresentationSnapshotError("observation tiles must be a non-empty list") + if not isinstance(raw_units, list): + raise PresentationSnapshotError("observation units must be a list") + + tiles: list[dict[str, Any]] = [] + for tile in raw_tiles: + if not isinstance(tile, Mapping): + raise PresentationSnapshotError("each observation tile must be an object") + tile_id = tile.get("tile_id", tile.get("id")) + presented: dict[str, Any] = { + "id": tile_id, + "source_slot": tile.get("source_slot", tile.get("ucns_slot")), + "x": tile.get("x"), + "y": tile.get("y"), + } + if tile.get("label") is not None: + presented["label"] = tile.get("label") + tiles.append(presented) + + units: list[dict[str, Any]] = [] + for unit in raw_units: + if not isinstance(unit, Mapping): + raise PresentationSnapshotError("each observation unit must be an object") + presented_unit: dict[str, Any] = { + "id": unit.get("unit_id", unit.get("id")), + "tile": unit.get("tile_id", unit.get("tile")), + } + if unit.get("label") is not None: + presented_unit["label"] = unit.get("label") + units.append(presented_unit) + + motions: list[dict[str, Any]] = [] + for event in move_events: + if not isinstance(event, Mapping): + raise PresentationSnapshotError("each move event must be an object") + kind = event.get("kind") + if kind not in (None, "move"): + continue + data = event.get("data", event) + if not isinstance(data, Mapping): + raise PresentationSnapshotError("move event data must be an object") + motions.append( + { + "unit": data.get("unit_id"), + "from": data.get("from_tile_id"), + "to": data.get("to_tile_id"), + } + ) + + presented_feed: list[dict[str, Any]] = [] + for item in feed: + if not isinstance(item, Mapping): + raise PresentationSnapshotError("each feed item must be an object") + row: dict[str, Any] = {"text": item.get("text")} + if "turn" in item: + row["turn"] = item.get("turn") + presented_feed.append(row) + + if selected_tile is None and units and isinstance(units[0].get("tile"), str): + selected_tile = units[0]["tile"] + + snapshot: dict[str, Any] = { + "kind": KIND, + "standing": STANDING, + "plane_id": plane_id, + "turn": observation.get("turn"), + "geometry_source": geometry_source, + "tiles": tiles, + "units": units, + "selected_tile": selected_tile, + "feed": presented_feed, + } + if motions: + snapshot["motions"] = motions + return dict(validate_snapshot(snapshot)) diff --git a/ahbg/presentation/sample_snapshot.json b/ahbg/presentation/sample_snapshot.json new file mode 100644 index 0000000..42afe84 --- /dev/null +++ b/ahbg/presentation/sample_snapshot.json @@ -0,0 +1,35 @@ +{ + "kind": "ahbg.presentation.snapshot", + "standing": "not-mechanics", + "plane_id": "plane-0", + "turn": 1, + "geometry_source": { + "repository": "The-Interdependency/ucns", + "commit": "1975fe70cf4e0826a8020c2da3047569e277af64", + "module": "src/ucns/mobius_seed.py", + "schema_id": "ucns.mobius-seed-of-life", + "schema_version": "0.1.0", + "projection_id": "seed-of-life-seven-equal-circles", + "selection_effect": "none" + }, + "tiles": [ + {"id": "CENTER", "source_slot": "CENTER", "x": 0.0, "y": 0.0, "label": "origin"}, + {"id": "RING_0", "source_slot": "RING_0", "x": 1.0, "y": 0.0, "label": "ring 0"}, + {"id": "RING_1", "source_slot": "RING_1", "x": 0.5, "y": 0.8660254037844386, "label": "ring 1"}, + {"id": "RING_2", "source_slot": "RING_2", "x": -0.5, "y": 0.8660254037844386, "label": "ring 2"}, + {"id": "RING_3", "source_slot": "RING_3", "x": -1.0, "y": 0.0, "label": "ring 3"}, + {"id": "RING_4", "source_slot": "RING_4", "x": -0.5, "y": -0.8660254037844386, "label": "ring 4"}, + {"id": "RING_5", "source_slot": "RING_5", "x": 0.5, "y": -0.8660254037844386, "label": "ring 5"} + ], + "units": [ + {"id": "A0", "tile": "RING_0", "label": "A0"} + ], + "selected_tile": "RING_0", + "motions": [ + {"unit": "A0", "from": "CENTER", "to": "RING_0"} + ], + "feed": [ + {"turn": 0, "text": "plane loaded; A0 at origin"}, + {"turn": 1, "text": "A0 trace CENTER to RING_0"} + ] +} diff --git a/ahbg/presentation/snapshot.py b/ahbg/presentation/snapshot.py new file mode 100644 index 0000000..2c91200 --- /dev/null +++ b/ahbg/presentation/snapshot.py @@ -0,0 +1,213 @@ +# === MODULE_BUILD === +# id: ahbg_presentation_snapshot_contract +# module_name: snapshot +# module_kind: schema +# summary: validates the presentation-only AHBG snapshot envelope, including UCNS-derived display positions and exact geometry source identity +# owner: AHBG presentation +# public_surface: KIND, STANDING, PresentationSnapshotError, load_snapshot, validate_snapshot +# internal_surface: _plain_int, _finite_number, _reject_unknown +# auth_boundary: none +# storage_boundary: read +# network_boundary: none +# user_data_boundary: none +# admin_only: false +# tests: ahbg/presentation/tests/test_presentation.py +# rollout: consumed by presentation projector and browser sample +# rollback: remove presentation package without changing AHBG mechanics or UCNS +# requires: pinned UCNS geometry identity supplied in snapshot.geometry_source +# since: 2026-08-31 +# unresolved: live engine-to-observation geometry adapter remains owned outside presentation +# === END MODULE_BUILD === + +"""AHBG presentation snapshot — visual fields only. + +This is not plane state and not a mechanics contract. Tile ``x``/``y`` values +must already be derived from the declared UCNS geometry source; this module does +not reconstruct geometry from AHBG-local coordinates. Optional motions are +traces of already-resolved unit relocation between presented tiles. + +Usage guidance: + ``python -m unittest ahbg.presentation.tests.test_presentation`` +""" + +from __future__ import annotations + +import json +import math +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" + +_ROOT_FIELDS = {"kind", "standing", "plane_id", "turn", "geometry_source", "tiles", "units", "selected_tile", "feed", "motions"} +_GEOMETRY_FIELDS = {"repository", "commit", "module", "schema_id", "schema_version", "projection_id", "selection_effect"} +_TILE_FIELDS = {"id", "label", "source_slot", "x", "y"} +_UNIT_FIELDS = {"id", "tile", "label"} +_FEED_FIELDS = {"turn", "text"} +_MOTION_FIELDS = {"unit", "from", "to"} + + +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 + try: + payload = json.loads(target.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise PresentationSnapshotError(f"cannot load snapshot: {exc}") from exc + return validate_snapshot(payload) + + +def _plain_int(value: object) -> bool: + return isinstance(value, int) and not isinstance(value, bool) + + +def _finite_number(value: object) -> bool: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return False + try: + return math.isfinite(float(value)) + except (OverflowError, ValueError): + return False + + +def _reject_unknown(mapping: Mapping[str, Any], allowed: set[str], surface: str) -> None: + unknown = sorted(set(mapping) - allowed) + if unknown: + raise PresentationSnapshotError(f"{surface} has undeclared fields: {', '.join(unknown)}") + + +def validate_snapshot(payload: Mapping[str, Any]) -> Mapping[str, Any]: + if not isinstance(payload, Mapping): + raise PresentationSnapshotError("snapshot must be an object") + _reject_unknown(payload, _ROOT_FIELDS, "snapshot") + 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 not _plain_int(payload.get("turn")) or payload["turn"] < 0: + raise PresentationSnapshotError("turn must be a non-negative int") + + geometry_source = payload.get("geometry_source") + if not isinstance(geometry_source, Mapping): + raise PresentationSnapshotError("geometry_source must be an object") + _reject_unknown(geometry_source, _GEOMETRY_FIELDS, "geometry_source") + for field in _GEOMETRY_FIELDS: + value = geometry_source.get(field) + if not isinstance(value, str) or not value: + raise PresentationSnapshotError(f"geometry_source.{field} must be exact non-empty text") + commit = geometry_source["commit"] + if len(commit) != 40 or any(ch not in "0123456789abcdef" for ch in commit): + raise PresentationSnapshotError("geometry_source.commit must be a lowercase 40-hex commit") + + 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() + source_slots: set[str] = set() + positions: set[tuple[float, float]] = set() + for tile in tiles: + if not isinstance(tile, Mapping): + raise PresentationSnapshotError("each tile must be an object") + _reject_unknown(tile, _TILE_FIELDS, "tile") + tile_id = tile.get("id") + source_slot = tile.get("source_slot") + x, y = tile.get("x"), tile.get("y") + 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 not isinstance(source_slot, str) or not source_slot: + raise PresentationSnapshotError(f"tile {tile_id} source_slot must be exact non-empty text") + if source_slot in source_slots: + raise PresentationSnapshotError(f"UCNS source slot repeats: {source_slot}") + if not _finite_number(x) or not _finite_number(y): + raise PresentationSnapshotError(f"tile {tile_id} x,y must be finite numeric and nonboolean") + position = (float(x), float(y)) + if position in positions: + raise PresentationSnapshotError(f"tile source position repeats: {position}") + if "label" in tile and (not isinstance(tile["label"], str) or not tile["label"]): + raise PresentationSnapshotError(f"tile {tile_id} label must be exact non-empty text when present") + ids.add(tile_id) + source_slots.add(source_slot) + positions.add(position) + + units = payload.get("units") + if not isinstance(units, list): + raise PresentationSnapshotError("units must be a list") + unit_ids: set[str] = set() + unit_tiles: dict[str, str] = {} + for unit in units: + if not isinstance(unit, Mapping): + raise PresentationSnapshotError("each unit must be an object") + _reject_unknown(unit, _UNIT_FIELDS, "unit") + 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 not isinstance(tile_id, str) or not tile_id: + raise PresentationSnapshotError(f"unit {unit_id} tile must be exact non-empty text") + if tile_id not in ids: + raise PresentationSnapshotError(f"unit {unit_id} tile {tile_id!r} is not a presented tile") + if "label" in unit and (not isinstance(unit["label"], str) or not unit["label"]): + raise PresentationSnapshotError(f"unit {unit_id} label must be exact non-empty text when present") + unit_ids.add(unit_id) + unit_tiles[unit_id] = tile_id + + selected = payload.get("selected_tile") + if selected is not None: + if not isinstance(selected, str) or not selected: + raise PresentationSnapshotError("selected_tile must be exact non-empty text") + if 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): + raise PresentationSnapshotError("each feed item must be an object") + _reject_unknown(item, _FEED_FIELDS, "feed item") + if not isinstance(item.get("text"), str) or not item["text"]: + raise PresentationSnapshotError("each feed item must have exact non-empty text") + if "turn" in item and (not _plain_int(item["turn"]) or item["turn"] < 0): + raise PresentationSnapshotError("feed turn must be a non-negative int when present") + + if "motions" not in payload: + return payload + motions = payload["motions"] + if not isinstance(motions, list): + raise PresentationSnapshotError("motions must be a list when present") + seen_motion_units: set[str] = set() + for motion in motions: + if not isinstance(motion, Mapping): + raise PresentationSnapshotError("each motion must be an object") + _reject_unknown(motion, _MOTION_FIELDS, "motion") + unit_id = motion.get("unit") + from_tile = motion.get("from") + to_tile = motion.get("to") + for name, value in (("unit", unit_id), ("from", from_tile), ("to", to_tile)): + if not isinstance(value, str) or not value: + raise PresentationSnapshotError(f"motion {name} must be exact non-empty text") + if unit_id not in unit_ids: + raise PresentationSnapshotError(f"motion unit {unit_id!r} is not a presented unit") + if unit_id in seen_motion_units: + raise PresentationSnapshotError(f"motion repeats unit {unit_id}") + if from_tile not in ids: + raise PresentationSnapshotError(f"motion from {from_tile!r} is not a presented tile") + if to_tile not in ids: + raise PresentationSnapshotError(f"motion to {to_tile!r} is not a presented tile") + if from_tile == to_tile: + raise PresentationSnapshotError(f"motion for {unit_id} must change tiles") + if unit_tiles[unit_id] != to_tile: + raise PresentationSnapshotError(f"motion destination {to_tile!r} does not match unit {unit_id} tile {unit_tiles[unit_id]!r}") + seen_motion_units.add(unit_id) + return payload diff --git a/ahbg/presentation/tests/test_presentation.py b/ahbg/presentation/tests/test_presentation.py new file mode 100644 index 0000000..e6382aa --- /dev/null +++ b/ahbg/presentation/tests/test_presentation.py @@ -0,0 +1,217 @@ +"""Regression tests for the AHBG presentation-only boundary. + +Usage guidance: + Focused: ``python -m unittest ahbg.presentation.tests.test_presentation`` + Repo discovery: ``python -m unittest discover -s ahbg/presentation/tests -p 'test*.py'`` +""" + +from __future__ import annotations + +import copy +import json +import math +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from ahbg.presentation.geometry import center_distance, source_to_display +from ahbg.presentation.project import snapshot_from_observation +from ahbg.presentation.snapshot import PresentationSnapshotError, load_snapshot, validate_snapshot + + +UCNS_COMMIT = "1975fe70cf4e0826a8020c2da3047569e277af64" +GEOMETRY_SOURCE = { + "repository": "The-Interdependency/ucns", + "commit": UCNS_COMMIT, + "module": "src/ucns/mobius_seed.py", + "schema_id": "ucns.mobius-seed-of-life", + "schema_version": "0.1.0", + "projection_id": "seed-of-life-seven-equal-circles", + "selection_effect": "none", +} + + +class PresentationTests(unittest.TestCase): + def setUp(self) -> None: + self.sample = dict(load_snapshot()) + + def test_sample_is_valid_and_pins_ucns_source(self) -> None: + self.assertEqual(self.sample["standing"], "not-mechanics") + self.assertEqual(self.sample["geometry_source"], GEOMETRY_SOURCE) + self.assertEqual(self.sample["motions"][0]["to"], self.sample["units"][0]["tile"]) + self.assertEqual({tile["source_slot"] for tile in self.sample["tiles"]}, {"CENTER", *(f"RING_{i}" for i in range(6))}) + + def test_source_centers_are_scaled_not_reconstructed(self) -> None: + radius = 64.0 + center = source_to_display(0.0, 0.0, radius) + ring0 = source_to_display(1.0, 0.0, radius) + self.assertAlmostEqual(center_distance(center, ring0), radius) + ring1 = source_to_display(0.5, math.sqrt(3) / 2, radius) + self.assertAlmostEqual(center_distance(center, ring1), radius) + + def test_duplicate_tile_id_source_slot_and_position_fail_closed(self) -> None: + for mutation in ("id", "slot", "position"): + payload = copy.deepcopy(self.sample) + if mutation == "id": + payload["tiles"][1]["id"] = payload["tiles"][0]["id"] + elif mutation == "slot": + payload["tiles"][1]["source_slot"] = payload["tiles"][0]["source_slot"] + else: + payload["tiles"][1]["x"] = payload["tiles"][0]["x"] + payload["tiles"][1]["y"] = payload["tiles"][0]["y"] + with self.assertRaises(PresentationSnapshotError): + validate_snapshot(payload) + + def test_unknown_fields_and_bad_unit_fields_fail_closed(self) -> None: + payload = copy.deepcopy(self.sample) + payload["private_prompt"] = "must not cross boundary" + with self.assertRaisesRegex(PresentationSnapshotError, "undeclared fields"): + validate_snapshot(payload) + + payload = copy.deepcopy(self.sample) + payload["feed"][0]["dm_state"] = "secret" + with self.assertRaisesRegex(PresentationSnapshotError, "undeclared fields"): + validate_snapshot(payload) + + payload = copy.deepcopy(self.sample) + payload["units"][0]["tile"] = [] + with self.assertRaises(PresentationSnapshotError): + validate_snapshot(payload) + + payload = copy.deepcopy(self.sample) + payload["units"][0]["label"] = {"bad": True} + with self.assertRaises(PresentationSnapshotError): + validate_snapshot(payload) + + def test_motion_destination_must_equal_presented_unit_tile(self) -> None: + payload = copy.deepcopy(self.sample) + payload["motions"][0]["to"] = "RING_1" + with self.assertRaisesRegex(PresentationSnapshotError, "does not match"): + validate_snapshot(payload) + + payload = copy.deepcopy(self.sample) + payload["motions"] = False + with self.assertRaisesRegex(PresentationSnapshotError, "must be a list"): + validate_snapshot(payload) + + def test_projector_drops_internal_observation_and_feed_fields(self) -> None: + observation = { + "turn": 2, + "geometry_source": {**GEOMETRY_SOURCE, "internal_note": "drop me"}, + "tiles": [ + {"tile_id": "CENTER", "ucns_slot": "CENTER", "x": 0.0, "y": 0.0, "private": "drop"}, + {"tile_id": "RING_0", "ucns_slot": "RING_0", "x": 1.0, "y": 0.0}, + ], + "units": [{"unit_id": "A0", "tile_id": "RING_0", "label": "A0", "private": "drop"}], + "seed": "must-not-leak", + } + move = { + "kind": "move", + "data": {"unit_id": "A0", "from_tile_id": "CENTER", "to_tile_id": "RING_0", "dm": "drop"}, + } + projected = snapshot_from_observation( + observation, + plane_id="plane-0", + move_events=[move], + feed=[{"turn": 2, "text": "public", "private_prompt": "must-not-leak"}], + ) + self.assertNotIn("seed", projected) + self.assertNotIn("internal_note", projected["geometry_source"]) + self.assertEqual(projected["feed"], [{"turn": 2, "text": "public"}]) + self.assertEqual(projected["motions"], [{"unit": "A0", "from": "CENTER", "to": "RING_0"}]) + + def test_browser_source_contains_accessibility_and_validation_guards(self) -> None: + source = (ROOT / "ahbg" / "presentation" / "board.js").read_text(encoding="utf-8") + html = (ROOT / "ahbg" / "presentation" / "board.html").read_text(encoding="utf-8") + for phrase in ( + 'hit.setAttribute("tabindex", "0")', + 'event.key === "Enter" || event.key === " "', + "UCNS source slot repeats", + "motions must be a list when present", + "motion destination for", + 'selectionRing.setAttribute("class", "selection-ring")', + "minimumChord", + "sourceToPixel", + ): + self.assertIn(phrase, source) + self.assertIn('role="group"', html) + self.assertNotIn('role="img"', html) + self.assertNotIn("axialToPixel", source) + + @staticmethod + def _js_offset(index: int, group_size: int) -> tuple[float, float]: + """Mirror of board.js offsetFor math, kept here as the frozen contract.""" + if group_size == 1: + return (0.0, 0.0) + unit_radius = 11.0 + minimum_chord = unit_radius * 2 + 4 + spread = max(unit_radius * 1.35, minimum_chord / (2 * math.sin(math.pi / group_size))) + angle = (math.pi * 2 * index) / group_size - math.pi / 2 + return (math.cos(angle) * spread, math.sin(angle) * spread) + + def test_initial_and_final_multi_unit_offsets_use_their_own_occupancy(self) -> None: + # Two units start stacked on CENTER and move to two different tiles: + # initial offsets must spread them apart on CENTER, final offsets zero. + initial = [self._js_offset(i, 2) for i in range(2)] + final = [self._js_offset(i, 1) for i in range(2)] + self.assertNotEqual(initial[0], initial[1]) + self.assertTrue(any(value != 0.0 for pair in initial for value in pair)) + self.assertEqual(final, [(0.0, 0.0), (0.0, 0.0)]) + + source = (ROOT / "ahbg" / "presentation" / "board.js").read_text(encoding="utf-8") + self.assertIn("groupUnitsBy", source) + self.assertIn("initialGroups", source) + self.assertIn("offsetFor(unit, initialGroups, initialTile)", source) + self.assertIn("offsetFor(unit, finalGroups, unit.tile)", source) + + def test_viewbox_bounds_cover_displaced_unit_markers(self) -> None: + # Seven units stacked on one tile produce the largest spread. The + # viewBox contract must derive from rendered marker extents at both the + # motion origin and the presented destination, not from a fixed + # seed-circle margin. + radius = 64.0 + marker_extent = 11.0 + 8 + offset = self._js_offset(0, 7) + center = (0.0, 0.0) + displaced = (center[0] + offset[0], center[1] + offset[1]) + self.assertGreater(max(abs(displaced[0]), abs(displaced[1])), 0.0) + + min_x = min(center[0] - radius, displaced[0] - marker_extent) + max_x = max(center[0] + radius, displaced[0] + marker_extent) + min_y = min(center[1] - radius, displaced[1] - marker_extent) + max_y = max(center[1] + radius, displaced[1] + marker_extent) + self.assertLessEqual(min_x, displaced[0] - marker_extent) + self.assertGreaterEqual(max_x, displaced[0] + marker_extent) + self.assertLessEqual(min_y, displaced[1] - marker_extent) + self.assertGreaterEqual(max_y, displaced[1] + marker_extent) + + source = (ROOT / "ahbg" / "presentation" / "board.js").read_text(encoding="utf-8") + self.assertIn("markerExtent", source) + self.assertIn("origin.x - markerExtent", source) + self.assertIn("dest.x - markerExtent", source) + self.assertNotIn("RADIUS * 1.2", source) + + def test_behavior_modules_declare_module_build(self) -> None: + for relative in ( + "ahbg/presentation/__init__.py", + "ahbg/presentation/snapshot.py", + "ahbg/presentation/project.py", + "ahbg/presentation/geometry.py", + "ahbg/presentation/board.js", + ): + text = (ROOT / relative).read_text(encoding="utf-8") + self.assertIn("=== MODULE_BUILD ===", text, relative) + self.assertIn("=== END MODULE_BUILD ===", text, relative) + + def test_sample_json_is_plain_data(self) -> None: + path = ROOT / "ahbg" / "presentation" / "sample_snapshot.json" + parsed = json.loads(path.read_text(encoding="utf-8")) + self.assertEqual(parsed, self.sample) + + +if __name__ == "__main__": + unittest.main()