diff --git a/.env.example b/.env.example index f6c0133..b6e4cf2 100644 --- a/.env.example +++ b/.env.example @@ -1,28 +1,96 @@ -GAMA_WS_PORT=1000 -GAMA_IP_ADDRESS=localhost +# ============================================================================= +# SIMPLE WebPlatform — configuration reference +# +# Copy this file to `.env` and adjust the values to your setup. +# All boolean options accept `true`, `1` or `yes` (case-insensitive); +# anything else is treated as `false`. +# +# Remove the leading "#" to apply the value you want to each option +# ============================================================================= -HEADSET_WS_PORT=8080 +# ============================================================================= +# GAMA simulation server +# ============================================================================= -MONITOR_WS_PORT=8001 +# WebSocket port of the GAMA headless server the platform connects to +# Default: 1000 +#GAMA_WS_PORT=1000 -WEB_APPLICATION_HOST=0.0.0.0 +# IP address (or hostname) of the machine running GAMA +# Default: localhost +#GAMA_IP_ADDRESS=localhost + +# Run the platform WITHOUT a GAMA simulation (headset management/streaming only). +# When enabled, the GAMA connection and learning packages are not used. +# Legacy alias `GAMALESS` is also supported. +# Default: false +#GAMALESS=false + +# Pro-actively remove a player entirely from GAMA / the middleware as soon as +# its device disconnects (instead of keeping it for a later reconnection) +# Default: false +#AGGRESSIVE_DISCONNECT=false + +# ============================================================================= +# Learning packages (GAMA models) +# ============================================================================= + +# Folder scanned for learning packages (GAMA models + settings) at startup. +# Relative paths are resolved from the executable/working directory. +# Mandatory while using the platform with GAMA +# Default: ./learning-packages +#LEARNING_PACKAGE_PATH="./learning-packages" + +# (Optional) Additional folder scanned for learning packages, on top of +# LEARNING_PACKAGE_PATH (e.g. a personal models folder outside the repo) +# Default: (unset — disabled) +#EXTRA_LEARNING_PACKAGE_PATH="/uncomment/and/set/another/path/to/check" + +# ============================================================================= +# Headsets (Unity VR clients) +# ============================================================================= + +# WebSocket port the platform listens on for Unity headset connections +# !! DO NOT MODIFY THIS ONE !! +# Default: 8080 +#HEADSET_WS_PORT=8080 + +# IP addresses of headsets the middleware should manage/stream (scrcpy). +# Separate each IP with a ";" as shown below. +# When unset, the platform auto-detects headsets on the local network instead. +# Default: (unset — auto-detection) +#HEADSETS_IP="192.168.68.101;192.168.68.102;" + +# ============================================================================= +# Web application (admin UI) +# ============================================================================= + +# Network interface the web servers bind to (0.0.0.0 = all interfaces) +# Default: 0.0.0.0 +#WEB_APPLICATION_HOST=0.0.0.0 + +# HTTP port serving the web admin UI +# Default: 8000 (setup wizard) / 5173 when unset (Vite dev server default) WEB_APPLICATION_PORT=8000 -# Allow reaching webplatform with any mDNS hostname -# http://.local: -# => Default to http://simple.local:5173 -#WEB_HOSTNAME=simple -VERBOSE=false +# WebSocket port used by the admin UI for live monitoring data +# (headset status, streaming state, etc.) +# Default: 8001 +#MONITOR_WS_PORT=8001 -LEARNING_PACKAGE_PATH="./learning-packages" -# Be careful, this will display A LOT of messages -#EXTRA_LEARNING_PACKAGE_PATH="/uncommment/and/set/another/path/to/check" +# WebSocket port streaming the headsets' video (scrcpy) to the admin UI +# Default: 8082 +#VIDEO_WS_PORT=8082 -# List IP addresses of headsets you want the middleware to scrcpy -# Each IP should be separated with a ";" as shown below -#HEADSETS_IP="192.168.68.178;192.168.68.180;" +# ============================================================================= +# Debug / logging +# ============================================================================= -# Will pro-actively entirely remove player from GAMA / Middleware if device disconnect -# Default to `false` -#AGGRESSIVE_DISCONNECT=false +# Enable debug-level logging +# Default: false +#VERBOSE=false +# Enable trace-level logging (implies VERBOSE). +# Be careful, this will display **A LOT** of messages. +# Default: false +#EXTRA_VERBOSE=false diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3574bb8..83f793e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -132,7 +132,9 @@ jobs: - name: Get release metadata id: meta - run: echo "date=$(date +'%d/%m/%y %R')" >> "$GITHUB_OUTPUT" + run: | + echo "date=$(date +'%d/%m/%y %R')" >> "$GITHUB_OUTPUT" + chmod +x release-files/* - name: Publish to Github uses: softprops/action-gh-release@v2 diff --git a/src/api/android/adb/AdbManager.ts b/src/api/android/adb/AdbManager.ts index fafc3ad..52ad587 100644 --- a/src/api/android/adb/AdbManager.ts +++ b/src/api/android/adb/AdbManager.ts @@ -160,7 +160,10 @@ export class AdbManager { } async startNewStream(device: Device) { - if (!this.isDeviceReady(device)) return; + if (!this.isDeviceReady(device)) { + logger.debug(`[${device.serial}] Not ready to interact with ADB. Skipping...`) + return; + } // Ensure having only one streaming per device — compare by serial, not reference if (this.clientCurrentlyStreaming.some((d) => d.serial === device.serial)) { @@ -214,7 +217,10 @@ export class AdbManager { isDeviceReady(device: Device): boolean { let isReady = false; - switch (device.state) { + + if (device.serial.endsWith("._adb-tls-connect._tcp")) + logger.debug(`[${device.serial}] Not a real device. Skipping...`); + else switch (device.state) { case "device": isReady = true; break; @@ -233,6 +239,7 @@ export class AdbManager { default: logger.warn(`[${device.serial}] Device is not ready with an unknown state (${device.state}), skipping`); } + return isReady; } diff --git a/src/api/core/Constants.ts b/src/api/core/Constants.ts index 187871d..ff84cec 100644 --- a/src/api/core/Constants.ts +++ b/src/api/core/Constants.ts @@ -78,6 +78,25 @@ export const GAMA_ERROR_MESSAGES: string[] = [ "UnableToExecuteRequest", ]; +// Foreign-experiment probing ============================================== +// Evaluated against an experiment the platform didn't launch (e.g. opened in +// GAMA's GUI) to check whether it is actually usable: `cycle` fails unless a +// live simulation exists, and `paused` captures the current run state. +// Successful reply content looks like "false|3565" (paused|cycle). +export const GAMA_PROBE_EXPR = 'string(paused) + "|" + string(cycle)'; +export const GAMA_PROBE_TIMEOUT_MS = 3000; + +/** + * Extract the player id from a `do create_player("...")` / `do remove_player("...")` + * expression echoed back by Gama Server in its command payloads. + * @returns the player id, or null if the expression is not a create_player call + */ +export function extractCreatePlayerId(expr: unknown): string | null { + if (typeof expr !== "string") return null; + const match = expr.match(/do create_player\("([^"]+)"\)/); + return match ? match[1] : null; +} + /** * ANSI colors for console output */ diff --git a/src/api/index.ts b/src/api/index.ts index d02081d..6b5f4d9 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -142,10 +142,13 @@ async function loadConfiguration(): Promise { ? ["true", "1", "yes"].includes(process.env.VERBOSE.toLowerCase()) : false; + // Supports legacy `ENV_GAMALESS` and linter `GAMALESS` ENV_GAMALESS = process.env.ENV_GAMALESS !== undefined ? ["true", "1", "yes"].includes(process.env.ENV_GAMALESS.toLowerCase()) - : false; + : process.env.GAMALESS !== undefined + ? ["true", "1", "yes"].includes(process.env.GAMALESS.toLowerCase()) + : false; /* SETUP LOGGING SYSTEM ================================ diff --git a/src/api/multiplayer/PlayerManager.ts b/src/api/multiplayer/PlayerManager.ts index b79be8f..ab1618b 100644 --- a/src/api/multiplayer/PlayerManager.ts +++ b/src/api/multiplayer/PlayerManager.ts @@ -320,10 +320,7 @@ class PlayerManager { this.playerList.get(playerWsId)!.date_connection = `${new Date().getHours().toString().padStart(2, "0")}:${new Date().getMinutes().toString().padStart(2, "0")}`; - if ( - this.controller.gama_connector !== undefined && - !["NONE", "NOTREADY"].includes(this.controller.gama_connector.jsonGamaState.experiment_state) - ) { + if (this.controller.gama_connector?.canTalkToExperiment()) { logger.debug(`Adding player ${this.playerList.get(playerWsId)?.id} to GAMA simulation...`); this.controller.addInGamePlayer(playerWsId); this.togglePlayerInGame(playerWsId, true); @@ -417,7 +414,7 @@ class PlayerManager { logger.debug("Add every player at once"); for (const [playerWsId, player] of this.playerList) { - if (this.controller.gama_connector !== undefined && !player.in_game) { + if (this.controller.gama_connector?.canTalkToExperiment() && !player.in_game) { this.controller.gama_connector.addInGamePlayer(playerWsId); this.togglePlayerInGame(playerWsId, true); } @@ -497,6 +494,17 @@ class PlayerManager { } } + /** + * Sends an explicit error message to a player's client + * (e.g. GAMA rejected its create_player, see issue #156) + * @param {string} playerId - The in-game id of the player + * @param {string} error - Error identifier sent to the client + */ + notifyPlayerError(playerId: string, error: string) { + const playerIP = this.getIndexByPlayerId(playerId); + if (playerIP !== undefined) this.sendMessageByWs(playerIP, { type: "error", content: error }); + } + /** * * @param playerWsId diff --git a/src/api/simulation/GamaConnector.ts b/src/api/simulation/GamaConnector.ts index b886d7c..bbc2b2a 100644 --- a/src/api/simulation/GamaConnector.ts +++ b/src/api/simulation/GamaConnector.ts @@ -1,7 +1,14 @@ import { getLogger, type Logger } from "@logtape/logtape"; import WebSocket from "ws"; import type { GamaState } from "../../common/types.ts"; -import { GAMA_ERROR_MESSAGES, type GAMA_JSON_LOAD_EXPERIMENT, type JsonPlayerAsk } from "../core/Constants.ts"; +import { + extractCreatePlayerId, + GAMA_ERROR_MESSAGES, + type GAMA_JSON_LOAD_EXPERIMENT, + GAMA_PROBE_EXPR, + GAMA_PROBE_TIMEOUT_MS, + type JsonPlayerAsk, +} from "../core/Constants.ts"; import type Controller from "../core/Controller.ts"; import { ENV_EXTRA_VERBOSE, ENV_VERBOSE } from "../index.ts"; import type Model from "./Model.ts"; @@ -20,6 +27,19 @@ class GamaConnector { listMessages: unknown[] = []; + // Experiment ownership tracking (issue #156) ------------------------ + // True only once the platform controls the experiment: after our own + // `load` ack, or after a foreign experiment passed the probe. + private experimentOwned = false; + // Set when our `load` command is in flight, cleared on ack/error + private pendingLoad = false; + // Pending probe of a foreign experiment (one at a time) + private probe: { expId: string; showBanner: boolean; timeout: NodeJS.Timeout } | null = null; + // Foreign exp_ids that failed the probe: their statuses are ignored + private rejectedForeignExpIds = new Set(); + // exp_id -> last SimulationStatus content, cached while probing / loading + private lastForeignStatus = new Map(); + /** * Constructor of the websocket client * @param {Controller} controller - The controller of the project @@ -34,6 +54,7 @@ class GamaConnector { content_error: "", experiment_id: "", experiment_name: "", + foreign_experiment_detected: false, }; this.connectGama(); @@ -68,6 +89,131 @@ class GamaConnector { this.jsonGamaState.experiment_name = experimentName; this.controller.notifyMonitor(); } + setGamaForeignExperimentDetected(detected: boolean) { + this.jsonGamaState.foreign_experiment_detected = detected; + this.controller.notifyMonitor(); + } + + // ------------------- + + /* Foreign experiment handling (issue #156) */ + + /** + * Whether the platform currently controls a live experiment in GAMA + * that commands (create_player, expressions, asks) can be sent to. + */ + canTalkToExperiment(): boolean { + return ( + this.jsonGamaState.connected && + this.experimentOwned && + this.jsonGamaState.experiment_id !== "" && + !["NONE", "NOTREADY"].includes(this.jsonGamaState.experiment_state) + ); + } + + private clearProbe() { + if (this.probe !== null) { + clearTimeout(this.probe.timeout); + this.probe = null; + } + } + + /** + * Whether a message echoed back by Gama Server is the reply to the pending probe + */ + private isProbeReply(command?: { type?: string; exp_id?: string; expr?: string }): boolean { + return ( + this.probe !== null && + command?.type === "expression" && + command?.expr === GAMA_PROBE_EXPR && + command?.exp_id === this.probe.expId + ); + } + + /** + * Evaluates a probe expression against an experiment the platform didn't launch to check + * whether it is actually usable (live simulation + queryable state). Resolution happens in + * the onmessage handler (adopt on success, reject on error) or on timeout (reject). + * Triggered on connection (GAMA started first, issue #156) and by foreign SimulationStatus. + * @param {string} expId - The foreign experiment id + * @param {boolean} showBanner - Whether a failed probe should raise the monitor warning + * (true only when a SimulationStatus proved an experiment actually exists in GAMA) + */ + private startForeignExperimentProbe(expId: string, showBanner: boolean) { + logger.info(`Checking for a GAMA experiment (id ${expId}) not launched by the platform...`); + + this.probe = { + expId, + showBanner, + timeout: setTimeout(() => { + logger.warn(`Probe of foreign experiment ${expId} timed out after ${GAMA_PROBE_TIMEOUT_MS}ms`); + this.rejectForeignExperiment(expId); + }, GAMA_PROBE_TIMEOUT_MS), + }; + + this.listMessages = [{ type: "expression", exp_id: expId, expr: GAMA_PROBE_EXPR }]; + this.sendMessages(); + } + + /** + * The foreign experiment answered the probe: take control of it as if the platform launched it + * @param {string} expId - The foreign experiment id + * @param {string} probeReply - The probe reply content, "|" (e.g. "false|3565") + */ + private adoptForeignExperiment(expId: string, probeReply?: string) { + this.clearProbe(); + this.experimentOwned = true; + this.setGamaExperimentId(expId); + + // Prefer the live paused state carried by the probe reply, then the last status heard + const parsedState = + typeof probeReply === "string" && probeReply.includes("|") + ? probeReply.startsWith("true") + ? "PAUSED" + : "RUNNING" + : undefined; + this.setGamaExperimentState(parsedState ?? this.lastForeignStatus.get(expId) ?? "RUNNING"); + + this.lastForeignStatus.clear(); + this.setGamaForeignExperimentDetected(false); + + logger.info(`Adopted externally-launched GAMA experiment ${expId} (${this.jsonGamaState.experiment_state})`); + this.controller.player_manager.addEveryPlayer(); + } + + /** + * The foreign experiment failed the probe: ignore its statuses so the platform state + * stays NONE (players are kept out and the admin can still launch an experiment) + * @param {string} expId - The foreign experiment id + */ + private rejectForeignExperiment(expId: string) { + const showBanner = this.probe?.showBanner ?? false; + this.clearProbe(); + this.rejectedForeignExpIds.add(expId); + + if (showBanner) { + this.setGamaForeignExperimentDetected(true); + logger.warn( + `GAMA is running an experiment (id ${expId}) that the platform cannot control (e.g. opened from GAMA's interface without a live simulation). Players won't be added to it. Start/close it in GAMA, or launch an experiment from the platform.`, + ); + } else { + logger.debug(`No usable pre-existing experiment found in GAMA (probed id ${expId})`); + } + } + + /** + * Forget everything known about GAMA-side experiments + * (called when the connection with Gama Server opens, closes or breaks) + */ + private resetExperimentTracking() { + this.experimentOwned = false; + this.pendingLoad = false; + this.clearProbe(); + this.rejectedForeignExpIds.clear(); + this.lastForeignStatus.clear(); + this.setGamaExperimentId(""); + this.setGamaForeignExperimentDetected(false); + } // ------------------- @@ -158,6 +304,7 @@ class GamaConnector { this.gama_socket.onopen = () => { logger.debug(`Opening connection with GAMA Server`); + this.resetExperimentTracking(); this.setGamaConnection(true); this.setGamaExperimentState("NONE"); }; @@ -175,17 +322,51 @@ class GamaConnector { case "SimulationStatus": logger.trace(`Message received from Gama Server: SimulationStatus = ${message.content}`); - this.setGamaExperimentId(message.exp_id); + // The platform controls (or is loading) its experiment: trust the state, but never + // overwrite experiment_id from statuses — GAMA's GUI-server mode stamps arbitrary + // ids on them (e.g. "0"), regardless of the id returned by the load ack. + if (this.experimentOwned || this.pendingLoad) { + if ( + ["NONE", "NOTREADY"].includes(message.content) && + ["RUNNING", "PAUSED", "NOTREADY"].includes(this.jsonGamaState.experiment_state) + ) { + this.controller.cancelLaunchInterval(); + this.controller.player_manager.disableAllPlayerInGame(); + this.controller.notifyMonitor(); + } + if (message.content === "NONE" && this.experimentOwned) { + // Experiment is gone; future exp_ids are fresh candidates + this.experimentOwned = false; + this.setGamaExperimentId(""); + this.rejectedForeignExpIds.clear(); + } + + this.setGamaExperimentState(message.content); + break; + } + + // Status about an experiment the platform didn't launch (issue #156) + if (message.content === "NONE") { + // Foreign experiment closed: it may be probed again if it comes back + this.rejectedForeignExpIds.delete(message.exp_id); + this.lastForeignStatus.delete(message.exp_id); + if (this.jsonGamaState.foreign_experiment_detected) this.setGamaForeignExperimentDetected(false); + break; + } + + this.lastForeignStatus.set(message.exp_id, message.content); + + // A transition to RUNNING makes a previously rejected experiment worth re-probing + // (e.g. the user pressed play in GAMA's GUI, its simulation is now live) + if (message.content === "RUNNING") this.rejectedForeignExpIds.delete(message.exp_id); + if ( - ["NONE", "NOTREADY"].includes(message.content) && - ["RUNNING", "PAUSED", "NOTREADY"].includes(this.jsonGamaState.experiment_state) + message.content !== "NOTREADY" && // wait for the experiment to be stable before probing + !this.rejectedForeignExpIds.has(message.exp_id) && + this.probe === null ) { - this.controller.cancelLaunchInterval(); - this.controller.player_manager.disableAllPlayerInGame(); - this.controller.notifyMonitor(); + this.startForeignExperimentProbe(message.exp_id, true); } - - this.setGamaExperimentState(message.content); break; case "SimulationOutput": @@ -200,7 +381,21 @@ class GamaConnector { logger.trace("Message received from Gama Server: CommandExecutedSuccessfully\n{message}", { message }); this.setGamaContentError(""); - if (message.command.type === "load") this.setGamaExperimentName(message.content); + + // Reply to a foreign-experiment probe: the experiment is usable, adopt it + if (this.isProbeReply(message.command)) { + this.adoptForeignExperiment(message.command.exp_id, String(message.content)); + break; + } + + if (message.command.type === "load") { + // The load ack's content carries the authoritative experiment id + this.pendingLoad = false; + this.experimentOwned = true; + this.setGamaExperimentId(message.content); + this.setGamaExperimentName(this.model?.getExperimentName() ?? ""); + this.lastForeignStatus.clear(); + } try { this.controller.broadcastSimulationOutput(message); @@ -213,18 +408,49 @@ class GamaConnector { logger.info( `Connected to Gama Server on ws://${process.env.GAMA_IP_ADDRESS}:${process.env.GAMA_WS_PORT}`, ); + + // GAMA may have been started before the platform with an experiment already open + // (issue #156): it never re-broadcasts SimulationStatus to new clients, so probe + // proactively. GAMA's GUI-server mode routes commands to the GUI experiment + // whatever the exp_id, "0" is only a placeholder. + if (!this.experimentOwned && !this.pendingLoad && this.probe === null) { + this.startForeignExperimentProbe("0", false); + } break; - default: + default: { // If a known GAMA error if (GAMA_ERROR_MESSAGES.includes(type)) { + // Failed probe: the foreign experiment is not controllable, ignore it + if (this.isProbeReply(message.command)) { + this.rejectForeignExperiment(message.command.exp_id); + break; + } + logger.error("Error message received from Gama Server: {message}", { message }); + if (message.command?.type === "load") { + // Our own load failed: drop any state a stray status may have set meanwhile + this.pendingLoad = false; + this.setGamaLoading(false); + if (!this.experimentOwned) this.setGamaExperimentState("NONE"); + } + + // A failed `create_player`: roll back the player state so it can retry later (issue #156) + const failedPlayerId = extractCreatePlayerId(message.command?.expr); + if (failedPlayerId !== null) { + logger.warn(`GAMA could not create the player ${failedPlayerId}, rolling back its in-game status`); + this.controller.player_manager.togglePlayerInGame(failedPlayerId, false); + this.controller.player_manager.notifyPlayerError(failedPlayerId, "experiment_not_available"); + this.controller.notifyMonitor(); + } + this.setGamaContentError(message); //this.setGamaLoading(false); } else { logger.error("Unknown message received from Gama Server: {message}", { message }); } + } } } catch (error) { logger.fatal("Error with the WebSocket with Gama Server:\n{error}", { error }); @@ -234,6 +460,7 @@ class GamaConnector { }; this.gama_socket.onclose = (event) => { + this.resetExperimentTracking(); this.setGamaConnection(false); this.setGamaExperimentState("NONE"); @@ -267,6 +494,7 @@ class GamaConnector { logger.fatal("An error broke the WebSocket:\n{error}", { error }); this.gama_socket = null; // Set to null if there was an error, so a reconnection may be triggered + this.resetExperimentTracking(); this.setGamaConnection(false); this.setGamaExperimentState("NONE"); this.controller.player_manager.disableAllPlayerInGame(); @@ -318,14 +546,19 @@ class GamaConnector { launchExperiment() { logger.debug("[GAMA CONNECTOR]Called launch experiment"); if (this.jsonGamaState.connected && this.jsonGamaState.experiment_state === "NONE") { + // An admin-triggered launch takes priority over any pending foreign-experiment probe + this.clearProbe(); + + // Set before sending: the load ack needs the model to fill experiment_name + this.model = this.controller.model_manager?.getActiveModel(); + this.pendingLoad = true; + this.listMessages = [this.jsonLoadExperiment()]; this.setGamaLoading(true); logger.debug("[GAMA CONNECTOR] called LaunchExperiment"); this.sendMessages(() => { this.setGamaLoading(false); }); - - this.model = this.controller.model_manager?.getActiveModel(); } else { logger.warn("GAMA is not connected or an experiment is already running..."); } @@ -391,7 +624,10 @@ class GamaConnector { * @param {string} playerWsId - The id of the player to be added */ addInGamePlayer(playerWsId: string) { - if (["NONE", "NOTREADY"].includes(this.jsonGamaState.experiment_state)) return; + if (!this.canTalkToExperiment()) { + logger.warn(`Blocked create_player for ${playerWsId}: no platform-controlled experiment running in GAMA`); + return; + } if (this.controller.player_manager.getPlayerState(playerWsId)?.in_game) return; @@ -409,8 +645,8 @@ class GamaConnector { removeInGamePlayer(idPlayer: string) { logger.debug(`Removing player from game: ${idPlayer}`); - if (["NONE", "NOTREADY"].includes(this.jsonGamaState.experiment_state)) { - logger.debug("Gama Simulation is not running, cannot remove player"); + if (!this.canTalkToExperiment()) { + logger.debug("No platform-controlled Gama Simulation is running, cannot remove player"); return; } @@ -434,7 +670,10 @@ class GamaConnector { * @param {string} expr - The expression. If this expression contains $id, it will be replaced by the id of the player which asked the method */ sendExpression(idPlayer: string, expr: string) { - if (["NONE", "NOTREADY"].includes(this.jsonGamaState.experiment_state)) return; + if (!this.canTalkToExperiment()) { + logger.warn(`Blocked expression from player ${idPlayer}: no platform-controlled experiment running in GAMA`); + return; + } expr = expr.replace("$id", `"${idPlayer}"`); this.listMessages = [this.jsonSendExpression(expr)]; @@ -449,7 +688,10 @@ class GamaConnector { * @param {object} json - The JSON containing the information of the ask */ sendAsk(json: JsonPlayerAsk) { - if (["NONE", "NOTREADY"].includes(this.jsonGamaState.experiment_state)) return; + if (!this.canTalkToExperiment()) { + logger.warn(`Blocked ask '${json.action}': no platform-controlled experiment running in GAMA`); + return; + } this.listMessages = [json]; diff --git a/src/common/types.ts b/src/common/types.ts index 884c3da..38de949 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -62,6 +62,8 @@ export interface GamaState { content_error: string; experiment_id: string; experiment_name: string; + /** True when GAMA runs an experiment the platform didn't launch and cannot control (e.g. opened in GAMA's GUI) */ + foreign_experiment_detected?: boolean; } /** Record of players keyed by their identifier */ diff --git a/src/components/SelectorSimulations/SelectorSimulations.tsx b/src/components/SelectorSimulations/SelectorSimulations.tsx index 1026a96..2b3c30d 100644 --- a/src/components/SelectorSimulations/SelectorSimulations.tsx +++ b/src/components/SelectorSimulations/SelectorSimulations.tsx @@ -88,9 +88,9 @@ const SelectorSimulations = () => { {gamaless ? ( <>
-

GAMALESS Mode

-

Simulation features are disabled. No GAMA server is connected.

-

Headset management is still operational.

+

{t("gamaless_mode_title")}

+

{t("gamaless_mode_description")}

+

{t("gamaless_mode_headsets")}

display button diff --git a/src/components/SimulationManager/SimulationManager.tsx b/src/components/SimulationManager/SimulationManager.tsx index 8b2f1cd..97cd5d8 100644 --- a/src/components/SimulationManager/SimulationManager.tsx +++ b/src/components/SimulationManager/SimulationManager.tsx @@ -39,6 +39,7 @@ const SimulationManager = () => { playerCount={playerCount} minPlayers={minPlayers} maxPlayers={maxPlayers} + foreignExperimentDetected={gama.foreign_experiment_detected} /> { +const StatusBanner = ({ + gamaless, + experimentState, + playerCount, + minPlayers, + maxPlayers, + foreignExperimentDetected, +}: StatusBannerProps) => { const { t } = useTranslation(); if (gamaless) { return (
- GAMALESS — simulation controls disabled + {t("gamaless_banner")} +
+ ); + } + + // GAMA runs an experiment the platform didn't launch and cannot control (issue #156) + if (foreignExperimentDetected) { + return ( +
+ {t("foreign_experiment_banner")}
); } diff --git a/src/components/WebSocketManager/WebSocketManager.tsx b/src/components/WebSocketManager/WebSocketManager.tsx index 4315a6a..fcc94a8 100644 --- a/src/components/WebSocketManager/WebSocketManager.tsx +++ b/src/components/WebSocketManager/WebSocketManager.tsx @@ -42,6 +42,7 @@ const WebSocketManager = ({ children }: WebSocketManagerProps) => { experiment_id: "", experiment_name: "", content_error: "", + foreign_experiment_detected: false, }); const [playerList, setPlayerList] = useState({}); const [simulationList, setSimulationList] = useState<(VU_CATALOG_SETTING_JSON | VU_MODEL_SETTING_JSON)[]>([]); diff --git a/src/languages/english.json b/src/languages/english.json index bd85e20..730118d 100644 --- a/src/languages/english.json +++ b/src/languages/english.json @@ -27,6 +27,11 @@ "in_game": "In game", "connected": "Connected", "error": "Error", - "simulation_loading": "Simulation loading" + "simulation_loading": "Simulation loading", + "gamaless_banner": "GAMALESS — simulation controls disabled", + "foreign_experiment_banner": "GAMA is running an experiment opened in its own interface — the platform cannot control it. Close it in GAMA before launching an experiment here.", + "gamaless_mode_title": "GAMALESS Mode", + "gamaless_mode_description": "Simulation features are disabled. No GAMA server is connected.", + "gamaless_mode_headsets": "Headset management is still operational." } \ No newline at end of file diff --git a/src/languages/french.json b/src/languages/french.json index d521a02..710e022 100644 --- a/src/languages/french.json +++ b/src/languages/french.json @@ -27,6 +27,11 @@ "in_game": "En jeu", "connected" : "Connecté", "error" : "Erreur", - "simulation_loading": "Chargement de la simulation" + "simulation_loading": "Chargement de la simulation", + "gamaless_banner": "GAMALESS — contrôles de simulation désactivés", + "foreign_experiment_banner": "GAMA exécute une expérience ouverte depuis sa propre interface — la plateforme ne peut pas la contrôler. Fermez-la dans GAMA avant de lancer une expérience ici.", + "gamaless_mode_title": "Mode GAMALESS", + "gamaless_mode_description": "Les fonctionnalités de simulation sont désactivées. Aucun serveur GAMA n'est connecté.", + "gamaless_mode_headsets": "La gestion des casques reste opérationnelle." } diff --git a/src/languages/khmer.json b/src/languages/khmer.json index b75649d..0a08005 100644 --- a/src/languages/khmer.json +++ b/src/languages/khmer.json @@ -27,5 +27,10 @@ "in_game": "នៅក្នុងហ្គេម", "connected": "បានភ្ជាប់", "error": "កំហុស", - "simulation_loading": "ការផ្ទុកការក្លែងធ្វើ" + "simulation_loading": "ការផ្ទុកការក្លែងធ្វើ", + "gamaless_banner": "GAMALESS — ការគ្រប់គ្រងការក្លែងធ្វើត្រូវបានបិទ", + "foreign_experiment_banner": "GAMA កំពុងដំណើរការពិសោធន៍ដែលបានបើកពីផ្ទាំងរបស់ GAMA ផ្ទាល់ — វេទិកាមិនអាចគ្រប់គ្រងវាបានទេ។ សូមបិទវានៅក្នុង GAMA មុនពេលចាប់ផ្តើមពិសោធន៍នៅទីនេះ។", + "gamaless_mode_title": "របៀប GAMALESS", + "gamaless_mode_description": "មុខងារក្លែងធ្វើត្រូវបានបិទ។ គ្មានម៉ាស៊ីនមេ GAMA ត្រូវបានភ្ជាប់ទេ។", + "gamaless_mode_headsets": "ការគ្រប់គ្រងកាសនៅតែដំណើរការ។" } \ No newline at end of file diff --git a/src/languages/lao.json b/src/languages/lao.json index fe82760..9d9d0a0 100644 --- a/src/languages/lao.json +++ b/src/languages/lao.json @@ -27,5 +27,10 @@ "in_game": "ໃນເກມ", "connected": "ເຊື່ອມຕໍ່ແລ້ວ", "error": "ຂໍ້ຜິດພາດ", - "simulation_loading": "ກຳລັງໂຫຼດການຈຳລອງ" + "simulation_loading": "ກຳລັງໂຫຼດການຈຳລອງ", + "gamaless_banner": "GAMALESS — ປິດການຄວບຄຸມການຈຳລອງ", + "foreign_experiment_banner": "GAMA ກຳລັງແລ່ນການທົດລອງທີ່ເປີດຈາກໜ້າຈໍຂອງ GAMA ເອງ — ແພລດຟອມບໍ່ສາມາດຄວບຄຸມໄດ້ ກະລຸນາປິດການທົດລອງໃນ GAMA ກ່ອນເລີ່ມການທົດລອງຢູ່ບ່ອນນີ້", + "gamaless_mode_title": "ໂໝດ GAMALESS", + "gamaless_mode_description": "ຄຸນສົມບັດການຈຳລອງຖືກປິດໃຊ້ງານ. ບໍ່ມີເຊີບເວີ GAMA ເຊື່ອມຕໍ່.", + "gamaless_mode_headsets": "ການຈັດການຊຸດຫູຟັງຍັງໃຊ້ງານໄດ້." } \ No newline at end of file diff --git a/src/languages/thai.json b/src/languages/thai.json index 07c0b2e..da435b6 100644 --- a/src/languages/thai.json +++ b/src/languages/thai.json @@ -25,5 +25,10 @@ "ask_launch_gama": "กรุณาเรียกใช้กามา..", "in_game": "ในเกม", "connected": "เชื่อมต่อ", - "error": "ข้อผิดพลาด" + "error": "ข้อผิดพลาด", + "gamaless_banner": "GAMALESS — ปิดใช้งานการควบคุมแบบจำลอง", + "foreign_experiment_banner": "GAMA กำลังรันการทดลองที่เปิดจากหน้าต่างของ GAMA เอง — แพลตฟอร์มไม่สามารถควบคุมได้ กรุณาปิดการทดลองใน GAMA ก่อนเริ่มการทดลองจากที่นี่", + "gamaless_mode_title": "โหมด GAMALESS", + "gamaless_mode_description": "ฟีเจอร์แบบจำลองถูกปิดใช้งาน ไม่มีเซิร์ฟเวอร์ GAMA เชื่อมต่ออยู่", + "gamaless_mode_headsets": "การจัดการเฮดเซ็ตยังคงใช้งานได้" } diff --git a/src/languages/vietnamese.json b/src/languages/vietnamese.json index 250d271..0b63507 100644 --- a/src/languages/vietnamese.json +++ b/src/languages/vietnamese.json @@ -26,6 +26,11 @@ "select_subproject" : "Chọn dự án phụ để khởi chạy", "in_game": "Trong trò chơi", "connected": "Đã kết nối", - "error": "Lỗi" + "error": "Lỗi", + "gamaless_banner": "GAMALESS — điều khiển mô phỏng bị vô hiệu hóa", + "foreign_experiment_banner": "GAMA đang chạy một thí nghiệm được mở từ giao diện riêng của nó — nền tảng không thể điều khiển thí nghiệm này. Hãy đóng nó trong GAMA trước khi khởi chạy thí nghiệm tại đây.", + "gamaless_mode_title": "Chế độ GAMALESS", + "gamaless_mode_description": "Các tính năng mô phỏng bị vô hiệu hóa. Không có máy chủ GAMA nào được kết nối.", + "gamaless_mode_headsets": "Việc quản lý kính thực tế ảo vẫn hoạt động." }