// Copyright (c) 2025-2026, RTE (https://www.rte-france.com) // This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. // If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, // you can obtain one at http://mozilla.org/MPL/2.0/. // SPDX-License-Identifier: MPL-2.0 // This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study. import type { ChosenActionRecord, GameStudy } from './types'; // --------------------------------------------------------------------------- // gameBridge — decoupling layer between the Game Mode shell and the App. // // The Game shell lives *outside* and must (a) drive the workspace // (load a study's network + contingency) and (b) observe its physical result // (which actions the player starred and the loading they achieve). Rather // than plumb props through App's whole tree, both sides talk through this // module-level singleton — mirroring the existing `interactionLogger` // pattern. App registers a loader and pushes snapshots; the shell sends load // commands and subscribes to snapshots. When game mode is off, App never // touches the bridge, so normal operation is unaffected. // --------------------------------------------------------------------------- /** Physical state of the current study, published by App on every change. */ export interface GameStudySnapshot { contingencyElementIds: string[]; /** Worst loading of the bare N-1 state (per-unit), or null if unknown. */ baselineMaxRho: number | null; /** Remedial actions the player has starred, with their resulting loading. */ chosenActions: ChosenActionRecord[]; } const EMPTY_SNAPSHOT: GameStudySnapshot = { contingencyElementIds: [], baselineMaxRho: null, chosenActions: [], }; type SnapshotListener = (s: GameStudySnapshot) => void; type StudyLoader = (study: GameStudy) => Promise; class GameBridge { private snapshot: GameStudySnapshot = EMPTY_SNAPSHOT; private listeners = new Set(); private loader: StudyLoader | null = null; private maxActions = 3; /** * True when the app was launched in game mode. Two triggers: * - `?game=1` query param (local dev / shareable link), or * - a `VITE_GAME_MODE=1` build flag, so a dedicated deployment (e.g. the * HuggingFace Space) boots straight into the game without a query string. */ isGameMode(): boolean { if (import.meta.env.VITE_GAME_MODE === '1') return true; if (typeof window === 'undefined') return false; const params = new URLSearchParams(window.location.search); return params.get('game') === '1' || params.has('game'); } // --- App side ---------------------------------------------------------- /** App registers the function the shell calls to load a study. */ registerLoader(loader: StudyLoader): void { this.loader = loader; } /** App publishes the current physical snapshot; shell listeners fire. */ publishSnapshot(snapshot: GameStudySnapshot): void { this.snapshot = snapshot; this.listeners.forEach((l) => l(snapshot)); } /** * App asks whether starring one more action is allowed under the active * session's cap. Returns true when game mode is off (no constraint). */ canStarAnotherAction(currentlyStarred: number): boolean { if (!this.isGameMode()) return true; return currentlyStarred < this.maxActions; } // --- Shell side -------------------------------------------------------- /** Shell sets the per-study action cap for the running session. */ setMaxActions(n: number): void { this.maxActions = n; } getMaxActions(): number { return this.maxActions; } /** Shell requests App to load a study; resolves when the load completes. * * App registers its loader from a mount effect, which only runs once the * shell has flipped to the `loading`/`playing` phase that mounts ``. * The very first load therefore races that effect, so we poll briefly for * the loader to appear before giving up. */ async loadStudy(study: GameStudy): Promise { const start = Date.now(); while (!this.loader) { if (Date.now() - start > 5000) { throw new Error('gameBridge: no study loader registered by App'); } await new Promise((r) => setTimeout(r, 50)); } return this.loader(study); } getSnapshot(): GameStudySnapshot { return this.snapshot; } subscribe(listener: SnapshotListener): () => void { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } /** Reset published state between studies. */ reset(): void { this.snapshot = EMPTY_SNAPSHOT; } } export const gameBridge = new GameBridge();