feat(hub): live game-master state + bump firmware submodule
Repo State / repo-state (push) Failing after 42s
Repo State / repo-state (pull_request) Failing after 14m5s
Validate Zacus refactor / validate (pull_request) Successful in 12m54s

Gateway (tools/zacus-gateway):
- /v1/state now aggregates live per-group progress from the hints engine
  /hints/sessions (the only backend exposing game state), with graceful
  fallback when the engine is offline or admin-protected. Flows through both
  the REST endpoint and the WS push (shared build_state). Documents
  HINTS_ADMIN_KEY.

App (apps/zacus-hub):
- GameMaster "Groupes en cours" section: active groups, hint/penalty counts,
  current puzzle, and a state_detail note. SessionSnapshot DTO + extended
  GameState.

Submodule:
- ESP32_ZACUS -> feat/idf-migration b2267f2 (ESP-NOW scenario hot-load
  receivers; three firmwares build green under ESP-IDF 5.4.4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Worker claude2
2026-06-09 04:11:45 +02:00
parent 6788d9b249
commit 26969640a3
5 changed files with 127 additions and 5 deletions
@@ -12,6 +12,7 @@ struct GameMasterView: View {
VStack(alignment: .leading, spacing: 24) {
header
backendsSection
sessionsSection
sceneSection
actionsSection
if !actionLog.isEmpty { logSection }
@@ -61,6 +62,39 @@ struct GameMasterView: View {
}
}
private var sessionsSection: some View {
GroupBox("Groupes en cours") {
VStack(alignment: .leading, spacing: 8) {
HStack {
Label("\(session.state.sessions_active) groupe(s)", systemImage: "person.3.fill")
.font(.caption.bold())
Spacer()
Label("\(session.state.hints_total) indice(s)", systemImage: "lightbulb")
.font(.caption).foregroundStyle(.secondary)
}
if session.state.sessions.isEmpty {
Text(session.state.state_detail ?? "Aucune session active.")
.font(.caption).foregroundStyle(.secondary)
} else {
ForEach(session.state.sessions) { snap in
HStack(spacing: 10) {
Image(systemName: "circle.fill")
.font(.system(size: 8))
.foregroundStyle(snap.total_penalty > 0 ? .orange : .green)
Text(snap.session_id).font(.body.monospaced())
if let puzzle = snap.active_puzzle {
Text(puzzle).font(.caption.monospaced()).foregroundStyle(.secondary)
}
Spacer()
Text("\(snap.total_hints) ind · \(snap.total_penalty) pén")
.font(.caption2).foregroundStyle(.secondary)
}
}
}
}
}
}
private var sceneSection: some View {
GroupBox("Partie") {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
@@ -119,11 +119,24 @@ struct HubConfig: Codable, Equatable {
}
}
struct SessionSnapshot: Codable, Equatable, Identifiable, Hashable {
var session_id: String
var total_hints: Int = 0
var total_penalty: Int = 0
var active_puzzle: String?
var last_activity_ms: Int = 0
var id: String { session_id }
}
struct GameState: Codable, Equatable {
var scene_id: String?
var scene_index: Int = 0
var last_hint: String?
var voice_session: String?
var backends: [BackendHealth] = []
var sessions: [SessionSnapshot] = []
var hints_total: Int = 0
var sessions_active: Int = 0
var state_detail: String?
}
+2 -1
View File
@@ -24,6 +24,7 @@ The token is printed to stdout — paste it into the iOS/macOS app
| `VOICE_BRIDGE_URL` | `http://studio:8200` | upstream voice-bridge |
| `HINTS_URL` | `http://macm1:8311` | upstream hints engine |
| `ESP32_URL` | unset | optional master REST (LAN-only) |
| `HINTS_ADMIN_KEY` | unset | `X-Admin-Key` for the hints engine `/hints/sessions` read-model, when it enforces auth |
| `REQUEST_TIMEOUT` | `10.0` | seconds, per upstream call |
## Endpoints (sprint 0)
@@ -32,7 +33,7 @@ The token is printed to stdout — paste it into the iOS/macOS app
|------|------|-------|
| `GET /healthz` | no | liveness |
| `GET /v1/auth/ping` | bearer | client sanity check |
| `GET /v1/state` | bearer | aggregated game state (stub) |
| `GET /v1/state` | bearer | aggregated game state (live: backend health + hints-engine session read-model) |
| `WS /v1/state/ws?token=…` | query token | live state push |
| `POST /v1/companion/voice/intent` | bearer | proxy → voice-bridge |
| `POST /v1/companion/hint` | bearer | proxy → hints |
+77 -3
View File
@@ -41,6 +41,7 @@ class Settings(BaseSettings):
token: str = "change-me-run-gen_token.py"
voice_bridge_url: str = "http://studio:8200"
hints_url: str = "http://macm1:8311"
hints_admin_key: str | None = None # X-Admin-Key for /hints/sessions, if the engine enforces it
esp32_url: str | None = None
scenarios_dir: str = "" # empty → REPO_ROOT/game/scenarios
request_timeout: float = 10.0
@@ -151,19 +152,92 @@ async def backends_health(request: Request, _: None = Depends(require_token)) ->
# ---------- Game master state ----------
class SessionSnapshot(BaseModel):
"""Per-group live state derived from the hints engine session tracker."""
session_id: str
total_hints: int = 0
total_penalty: int = 0
active_puzzle: str | None = None # most recently touched puzzle for the group
last_activity_ms: int = 0
class GameState(BaseModel):
scene_id: str | None = None
scene_index: int = 0
last_hint: str | None = None
voice_session: str | None = None
backends: list[BackendHealth] = []
# Live game read-model aggregated from the hints engine (sprint 1).
sessions: list[SessionSnapshot] = []
hints_total: int = 0
sessions_active: int = 0
state_detail: str | None = None # human note when live state is partial/unavailable
def _hints_admin_headers() -> dict[str, str]:
return {"X-Admin-Key": settings.hints_admin_key} if settings.hints_admin_key else {}
def _summarise_sessions(payload: dict) -> tuple[list[SessionSnapshot], int]:
"""Reduce the hints engine /hints/sessions dump to the hub read-model."""
snapshots: list[SessionSnapshot] = []
total_hints = 0
for raw in payload.get("sessions", []) or []:
puzzles = raw.get("puzzles", []) or []
# The most recently touched puzzle stands in for the group's current step.
latest = max(puzzles, key=lambda p: p.get("last_at_ms", 0), default=None)
session_hints = int(raw.get("total_hints", 0))
total_hints += session_hints
snapshots.append(SessionSnapshot(
session_id=str(raw.get("session_id", "?")),
total_hints=session_hints,
total_penalty=int(raw.get("total_penalty", 0)),
active_puzzle=(latest or {}).get("puzzle_id"),
last_activity_ms=int((latest or {}).get("last_at_ms", 0)),
))
snapshots.sort(key=lambda s: s.last_activity_ms, reverse=True)
return snapshots, total_hints
async def build_state(request: Request) -> GameState:
health = await request.app.state.health_cache.get()
# TODO: pull real scene/voice_session from ESP32 + voice-bridge when those
# endpoints expose state. For now we report what we can verify.
return GameState(backends=health)
state = GameState(backends=health)
hints_online = any(b.name == "hints" and b.online for b in health)
if not hints_online:
state.state_detail = "hints engine offline — live session state unavailable"
return state
# Pull the live session read-model from the hints engine. This is the only
# backend that currently exposes per-group game progress; scene_id/voice_session
# stay null until the ESP32 master / voice-bridge expose equivalent state.
try:
resp = await request.app.state.http.get(
f"{settings.hints_url.rstrip('/')}/hints/sessions",
headers=_hints_admin_headers(),
timeout=3.0,
)
if resp.status_code == 401:
state.state_detail = "hints /hints/sessions requires X-Admin-Key — set ZACUS_HUB_HINTS_ADMIN_KEY"
return state
if resp.status_code >= 400:
state.state_detail = f"hints /hints/sessions → HTTP {resp.status_code}"
return state
sessions, total_hints = _summarise_sessions(resp.json())
except Exception as exc:
state.state_detail = f"hints state probe failed: {str(exc)[:120]}"
return state
state.sessions = sessions
state.hints_total = total_hints
state.sessions_active = len(sessions)
# Surface the most-recently-active group's puzzle as the headline scene so the
# GM panel reflects real progress even before scene-level state exists upstream.
if sessions:
head = sessions[0]
state.scene_id = head.active_puzzle
state.last_hint = f"{head.session_id}: {head.total_hints} indice(s), pénalité {head.total_penalty}"
return state
@app.get("/v1/state", response_model=GameState)