feat(hub): live game-master state + bump firmware submodule
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:
+1
-1
Submodule ESP32_ZACUS updated: d220d94607...b2267f2261
@@ -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?
|
||||
}
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user