Skip to content

Codebase Reference

High-Level Overview

This codebase is a full-stack simulation platform centered on a Python backend package, with a React frontend and Mongo-backed persistence. Reporting, analysis, and admin tooling lives in dcs_utils, and run/game/experiment configuration is primarily YAML-driven.

The main runtime entry point is the Typer CLI and the FastAPI app factory in dcs_simulation_engine/api/app.py. The React frontend is optional; the engine runtime is the API server.

Design Considerations

This repository is organized as a monorepo so the engine, frontend, docs, examples, tests, and operational tooling can evolve together. Keeping these pieces in one place reduces version skew between the API, UI, run configurations, and evaluation workflows, which matters because DCS use cases (research workflows for AI and CogSci, etc.) depend on all of them staying aligned.

The backend is asynchronous and WebSocket-driven because gameplay shouldn't be limited to turn-based synchronous interactions. It is event-based, latency-sensitive, and often concurrent across human and AI players. Async I/O keeps the API responsive when it is handling persistence, model calls, or other external work, while WebSockets let the server stream state updates during a session instead of forcing a request/response loop.

The React frontend is optional by design. The engine exposes an API endpoint so the same runtime can support the built-in UI, custom clients, automated agents, and non-visual integrations. The default frontend and Fly.io deployment path make the project easy to start quickly, but the containerized runtime keeps it portable for other deployment targets.

The HITL pipeline exists because characters are treated as artifacts that need iterative evaluation, not static content. It gives developers a repeatable way to generate scenarios, inspect behavior, and refine character sheets before promotion.

The character release policy enforces quality thresholds before characters are promoted to production. Evaluations are fingerprinted to the character + model + prompt configuration, so material changes require re-evaluation of role-playing simulation quality. This keeps low-quality or under-evaluated characters out of the default database and makes the promotion process explicit and reviewable.

Run harnessing is provided to support repeatable AI evaluation workflows. Like other benchmarking frameworks, it standardizes orchestration across common providers. We support models from OpenRouter and Hugging Face by default so model runs can be executed consistently and compared with less setup overhead.

Run configurations are intentionally only as configurable as needed to support internal use cases. This keeps the configuration surface manageable and testable; exposing every possible toggle would create a combinatorial space that is difficult to reason about and validate, making engine quality and robustness harder to maintain. The goal is minimal configuration for common workflows, with extension points for cases that genuinely need customization. The examples/ folder contains the DCS group's internal configurations and serves as the primary functional test surface for run configuration behavior.

The CLI design intentionally departs from strict noun-verb command conventions to support a mixed user base of AI researchers and less technical operators. Common engine operations are exposed as simple commands for local and remote runs (start, status, save, stop) plus basic report generation, while advanced workflows are grouped under admin subcommands (for example, server and database management, HITL pipelines, and related tooling). This keeps advanced capabilities available without overwhelming users who only need core run operations.

The architecture also leaves room for future interaction patterns and client modalities. Because the engine is built around session state, event streaming, and a clean client/server boundary, it can support richer multi-turn behavior and additional frontends without restructuring the core runtime.

As a design trade-off, we prioritize Python for research velocity, readability, and ease of extension, accepting that peak throughput may be lower than in lower-level implementations and may require scaling or optimization for high-load deployments.

Repo Structure

├── database_seeds      # seed data (characters and related collections)
├── dcs_simulation_engine
│   ├── core            # core engine components (e.g. session manager)
│   ├── dal             # data access layer for MongoDB
│   ├── deployments     # fly deployment assets (toml files)
│   ├── games           # core games
│   ├── infra           #...
├── dcs_utils          # reporting/analysis/admin tooling
├── ui              # React + Bun frontend
├── docs
├── docker
├── examples
├── tests
├── character-release-policy.yml

Main Components

⚠️ This section needs completion.

The backend API layer dcs_simulation_engine/api/app.py ...hands off work between ...

SessionManager in dcs_simulation_engine/core manages the gameplay sessions.

SessionRegistry

EngineRunManager ...

Data Flow

⚠️ This section needs completion.

At a high level, the system lets a player start a text-based “game” against a simulated character, routes each turn through a game engine implementation, optionally calls an LLM-backed AI client to generate or validate responses, persists the transcript and session metadata to MongoDB, and streams events back to the browser over WebSockets.

For running the engine, the flow is:

  1. Startup begins in the CLI calls the create_app function in dcs_simulation_engine/api/app.py

  2. When a session starts, the API enforces auth and returns allowed games, characters and run state.

  3. On WebSocket connection (), the server calls the SessionManager.step_async() to create the opening scene of a game.

  4. Each message by the player and/or simulator is recorded as an event by SessionEventRecorder...

  5. On termination, the session is finalized in Mongo with the exit reason, turn count, and last sequence number.

For normal gameplay, the flow is: 1. The browser loads the frontend and fetches ...

  1. The player registers/authenticates through ...

  2. The player opens a game setup page /api/play/setup/{game} and the backend loads the game config, queries the data layer for valid characters and returns allowed PC/NPC choices.

  3. The UI POSTs /api/play/game and calls SessionManager which constructs a session object around that game.

  4. The new session is inserted into the in-memory SessionRegistry


DCS-SE Codebase Reference

dcs_simulation_engine

DCS Simulation Engine package.

api

FastAPI server surface for programmatic DCS access.

create_app(*, provider=None, mongo_uri=None, shutdown_dump_dir=None, server_mode='standard', default_experiment_name=None, remote_management_enabled=False, bootstrap_token=None, session_ttl_seconds=DEFAULT_SESSION_TTL_SECONDS, sweep_interval_seconds=DEFAULT_SWEEP_INTERVAL_SECONDS, cors_origins=None)

Create and configure the FastAPI server application.

Source code in dcs_simulation_engine/api/app.py
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def create_app(
    *,
    provider: DataProvider | object | None = None,
    mongo_uri: str | None = None,
    shutdown_dump_dir: Path | None = None,
    server_mode: ServerMode = "standard",
    default_experiment_name: str | None = None,
    remote_management_enabled: bool = False,
    bootstrap_token: str | None = None,
    session_ttl_seconds: int = DEFAULT_SESSION_TTL_SECONDS,
    sweep_interval_seconds: int = DEFAULT_SWEEP_INTERVAL_SECONDS,
    cors_origins: list[str] | None = None,
) -> FastAPI:
    """Create and configure the FastAPI server application."""
    registry = SessionRegistry(ttl_seconds=session_ttl_seconds, sweep_interval_seconds=sweep_interval_seconds)
    app = FastAPI(title="DCS Server")

    @asynccontextmanager
    async def lifespan(_app: FastAPI):
        if app.state.provider is None:
            app.state.provider = await create_async_provider(mongo_uri=mongo_uri)
        app.state.started_at = utc_now()
        SessionManager.preload_game_configs()
        ExperimentManager.preload_experiment_configs()
        await registry.start()
        try:
            yield
        finally:
            await registry.stop()
            if shutdown_dump_dir is not None:
                try:
                    db = app.state.provider.get_db()
                    dump_root = await dump_all_collections_to_json_async(db, shutdown_dump_dir)
                    logger.info("Wrote shutdown Mongo dump to {}", dump_root)
                except Exception:
                    logger.exception("Failed to write shutdown Mongo dump to {}", shutdown_dump_dir)

    app.router.lifespan_context = lifespan
    app.add_middleware(
        CORSMiddleware,
        allow_origins=list(dict.fromkeys((cors_origins or []) + CORS_ORIGINS)),
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )
    app.state.provider = provider
    app.state.registry = registry
    app.state.server_mode = server_mode
    app.state.mongo_uri = mongo_uri
    app.state.default_experiment_name = default_experiment_name
    app.state.remote_management_enabled = remote_management_enabled
    app.state.bootstrap_token = bootstrap_token

    app.include_router(users_router)
    app.include_router(sessions_router)
    app.include_router(play_router)
    app.include_router(experiments_router)
    app.include_router(catalog_router)
    app.include_router(remote_router)

    @app.get("/api/server/config", response_model=ServerConfigResponse)
    def server_config() -> ServerConfigResponse:
        """Expose server capabilities so clients can adapt to the active mode."""
        return build_server_config(
            server_mode=server_mode,
            default_experiment_name=default_experiment_name,
        )

    @app.get("/api/status", response_model=StatusResponse)
    def status() -> StatusResponse:
        """Expose basic process liveness metadata for monitoring."""
        started_at = app.state.started_at
        uptime = int((utc_now() - started_at).total_seconds())
        return StatusResponse(started_at=started_at, uptime=max(uptime, 0))

    @app.get("/healthz")
    def health() -> dict[str, str]:
        """Simple liveness endpoint."""
        # TODO: Include
        #  - uptime
        #  - total sessions since start
        #  - active sessions
        #  - assignment status
        #  - last db writ
        #  - last request time
        return {"status": "ok"}

    return app

app

FastAPI application factory for the DCS server.

create_app(*, provider=None, mongo_uri=None, shutdown_dump_dir=None, server_mode='standard', default_experiment_name=None, remote_management_enabled=False, bootstrap_token=None, session_ttl_seconds=DEFAULT_SESSION_TTL_SECONDS, sweep_interval_seconds=DEFAULT_SWEEP_INTERVAL_SECONDS, cors_origins=None)

Create and configure the FastAPI server application.

Source code in dcs_simulation_engine/api/app.py
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def create_app(
    *,
    provider: DataProvider | object | None = None,
    mongo_uri: str | None = None,
    shutdown_dump_dir: Path | None = None,
    server_mode: ServerMode = "standard",
    default_experiment_name: str | None = None,
    remote_management_enabled: bool = False,
    bootstrap_token: str | None = None,
    session_ttl_seconds: int = DEFAULT_SESSION_TTL_SECONDS,
    sweep_interval_seconds: int = DEFAULT_SWEEP_INTERVAL_SECONDS,
    cors_origins: list[str] | None = None,
) -> FastAPI:
    """Create and configure the FastAPI server application."""
    registry = SessionRegistry(ttl_seconds=session_ttl_seconds, sweep_interval_seconds=sweep_interval_seconds)
    app = FastAPI(title="DCS Server")

    @asynccontextmanager
    async def lifespan(_app: FastAPI):
        if app.state.provider is None:
            app.state.provider = await create_async_provider(mongo_uri=mongo_uri)
        app.state.started_at = utc_now()
        SessionManager.preload_game_configs()
        ExperimentManager.preload_experiment_configs()
        await registry.start()
        try:
            yield
        finally:
            await registry.stop()
            if shutdown_dump_dir is not None:
                try:
                    db = app.state.provider.get_db()
                    dump_root = await dump_all_collections_to_json_async(db, shutdown_dump_dir)
                    logger.info("Wrote shutdown Mongo dump to {}", dump_root)
                except Exception:
                    logger.exception("Failed to write shutdown Mongo dump to {}", shutdown_dump_dir)

    app.router.lifespan_context = lifespan
    app.add_middleware(
        CORSMiddleware,
        allow_origins=list(dict.fromkeys((cors_origins or []) + CORS_ORIGINS)),
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )
    app.state.provider = provider
    app.state.registry = registry
    app.state.server_mode = server_mode
    app.state.mongo_uri = mongo_uri
    app.state.default_experiment_name = default_experiment_name
    app.state.remote_management_enabled = remote_management_enabled
    app.state.bootstrap_token = bootstrap_token

    app.include_router(users_router)
    app.include_router(sessions_router)
    app.include_router(play_router)
    app.include_router(experiments_router)
    app.include_router(catalog_router)
    app.include_router(remote_router)

    @app.get("/api/server/config", response_model=ServerConfigResponse)
    def server_config() -> ServerConfigResponse:
        """Expose server capabilities so clients can adapt to the active mode."""
        return build_server_config(
            server_mode=server_mode,
            default_experiment_name=default_experiment_name,
        )

    @app.get("/api/status", response_model=StatusResponse)
    def status() -> StatusResponse:
        """Expose basic process liveness metadata for monitoring."""
        started_at = app.state.started_at
        uptime = int((utc_now() - started_at).total_seconds())
        return StatusResponse(started_at=started_at, uptime=max(uptime, 0))

    @app.get("/healthz")
    def health() -> dict[str, str]:
        """Simple liveness endpoint."""
        # TODO: Include
        #  - uptime
        #  - total sessions since start
        #  - active sessions
        #  - assignment status
        #  - last db writ
        #  - last request time
        return {"status": "ok"}

    return app

auth

Authentication and app-state access helpers for the FastAPI API layer.

api_key_from_request(request)

Extract api_key from Authorization: Bearer header.

Source code in dcs_simulation_engine/api/auth.py
137
138
139
def api_key_from_request(request: Request) -> str | None:
    """Extract api_key from Authorization: Bearer header."""
    return _extract_bearer(request.headers.get("authorization"))
api_key_from_websocket(websocket)

Extract api_key from Authorization: Bearer header on a WebSocket.

Source code in dcs_simulation_engine/api/auth.py
142
143
144
def api_key_from_websocket(websocket: WebSocket) -> str | None:
    """Extract api_key from Authorization: Bearer header on a WebSocket."""
    return _extract_bearer(websocket.headers.get("authorization"))
authenticate_player(*, provider, api_key)

Return the player for a raw API key, or None if invalid.

Source code in dcs_simulation_engine/api/auth.py
147
148
149
150
151
152
153
154
155
156
157
158
159
def authenticate_player(*, provider: DataProvider, api_key: str | None) -> PlayerRecord | None:
    """Return the player for a raw API key, or None if invalid."""
    if api_key is None:
        return None

    key = api_key.strip()
    if not key:
        return None

    record = provider.get_players(access_key=key)
    if isinstance(record, PlayerRecord):
        return record
    return None
authenticate_player_async(*, provider, api_key) async

Async variant of authenticate_player that supports sync/async providers.

Source code in dcs_simulation_engine/api/auth.py
170
171
172
173
174
175
176
177
178
179
180
181
182
async def authenticate_player_async(*, provider: Any, api_key: str | None) -> PlayerRecord | None:
    """Async variant of authenticate_player that supports sync/async providers."""
    if api_key is None:
        return None

    key = api_key.strip()
    if not key:
        return None

    record = await maybe_await(provider.get_players(access_key=key))
    if isinstance(record, PlayerRecord):
        return record
    return None
build_server_config(*, server_mode, default_experiment_name=None)

Translate the active mode into frontend-readable capability flags.

Source code in dcs_simulation_engine/api/auth.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def build_server_config(
    *,
    server_mode: ServerMode,
    default_experiment_name: str | None = None,
) -> ServerConfigResponse:
    """Translate the active mode into frontend-readable capability flags."""
    is_standard = server_mode == "standard"
    return ServerConfigResponse(
        mode=server_mode,
        authentication_required=is_standard,
        registration_enabled=is_standard,
        experiments_enabled=is_standard,
        default_experiment_name=default_experiment_name,
    )
get_default_experiment_name_from_request(request)

Fetch the configured default experiment name from app state for an HTTP request.

Source code in dcs_simulation_engine/api/auth.py
48
49
50
def get_default_experiment_name_from_request(request: Request) -> str | None:
    """Fetch the configured default experiment name from app state for an HTTP request."""
    return cast(str | None, getattr(request.app.state, "default_experiment_name", None))
get_default_experiment_name_from_websocket(websocket)

Fetch the configured default experiment name from app state for a websocket.

Source code in dcs_simulation_engine/api/auth.py
53
54
55
def get_default_experiment_name_from_websocket(websocket: WebSocket) -> str | None:
    """Fetch the configured default experiment name from app state for a websocket."""
    return cast(str | None, getattr(websocket.app.state, "default_experiment_name", None))
get_provider_from_request(request)

Fetch the data provider stored on app state for an HTTP request.

Source code in dcs_simulation_engine/api/auth.py
18
19
20
def get_provider_from_request(request: Request) -> DataProvider:
    """Fetch the data provider stored on app state for an HTTP request."""
    return cast(DataProvider, request.app.state.provider)
get_provider_from_websocket(websocket)

Fetch the data provider stored on app state for a WebSocket connection.

Source code in dcs_simulation_engine/api/auth.py
28
29
30
def get_provider_from_websocket(websocket: WebSocket) -> DataProvider:
    """Fetch the data provider stored on app state for a WebSocket connection."""
    return cast(DataProvider, websocket.app.state.provider)
get_registry_from_request(request)

Fetch the session registry stored on app state for an HTTP request.

Source code in dcs_simulation_engine/api/auth.py
23
24
25
def get_registry_from_request(request: Request) -> SessionRegistry:
    """Fetch the session registry stored on app state for an HTTP request."""
    return cast(SessionRegistry, request.app.state.registry)
get_registry_from_websocket(websocket)

Fetch the session registry stored on app state for a WebSocket connection.

Source code in dcs_simulation_engine/api/auth.py
33
34
35
def get_registry_from_websocket(websocket: WebSocket) -> SessionRegistry:
    """Fetch the session registry stored on app state for a WebSocket connection."""
    return cast(SessionRegistry, websocket.app.state.registry)
get_server_mode_from_request(request)

Fetch the configured server mode from app state for an HTTP request.

Source code in dcs_simulation_engine/api/auth.py
38
39
40
def get_server_mode_from_request(request: Request) -> ServerMode:
    """Fetch the configured server mode from app state for an HTTP request."""
    return cast(ServerMode, getattr(request.app.state, "server_mode", "standard"))
get_server_mode_from_websocket(websocket)

Fetch the configured server mode from app state for a WebSocket connection.

Source code in dcs_simulation_engine/api/auth.py
43
44
45
def get_server_mode_from_websocket(websocket: WebSocket) -> ServerMode:
    """Fetch the configured server mode from app state for a WebSocket connection."""
    return cast(ServerMode, getattr(websocket.app.state, "server_mode", "standard"))
has_remote_admin_async(*, provider) async

Return True when any player currently holds the remote admin role.

Source code in dcs_simulation_engine/api/auth.py
119
120
121
122
123
124
async def has_remote_admin_async(*, provider: Any) -> bool:
    """Return True when any player currently holds the remote admin role."""
    records = await maybe_await(provider.get_players())
    if not isinstance(records, list):
        return False
    return any(is_remote_admin(record) for record in records)
is_remote_admin(player)

Return True when the player carries the remote admin role.

Source code in dcs_simulation_engine/api/auth.py
114
115
116
def is_remote_admin(player: PlayerRecord) -> bool:
    """Return True when the player carries the remote admin role."""
    return str(player.data.get("role") or "") == REMOTE_ADMIN_ROLE
is_remote_management_enabled_from_request(request)

Return whether the app is running in remote-managed mode for this request.

Source code in dcs_simulation_engine/api/auth.py
58
59
60
def is_remote_management_enabled_from_request(request: Request) -> bool:
    """Return whether the app is running in remote-managed mode for this request."""
    return bool(getattr(request.app.state, "remote_management_enabled", False))
is_remote_management_enabled_from_websocket(websocket)

Return whether the app is running in remote-managed mode for this websocket.

Source code in dcs_simulation_engine/api/auth.py
63
64
65
def is_remote_management_enabled_from_websocket(websocket: WebSocket) -> bool:
    """Return whether the app is running in remote-managed mode for this websocket."""
    return bool(getattr(websocket.app.state, "remote_management_enabled", False))
require_player(*, provider, api_key)

Return authenticated player or raise a 401 HTTPException.

Source code in dcs_simulation_engine/api/auth.py
162
163
164
165
166
167
def require_player(*, provider: DataProvider, api_key: str | None) -> PlayerRecord:
    """Return authenticated player or raise a 401 HTTPException."""
    player = authenticate_player(provider=provider, api_key=api_key)
    if player is None:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid access key")
    return player
require_player_async(*, provider, api_key) async

Return authenticated player or raise 401 for sync/async providers.

Source code in dcs_simulation_engine/api/auth.py
185
186
187
188
189
190
async def require_player_async(*, provider: Any, api_key: str | None) -> PlayerRecord:
    """Return authenticated player or raise 401 for sync/async providers."""
    player = await authenticate_player_async(provider=provider, api_key=api_key)
    if player is None:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid access key")
    return player
require_remote_admin_async(*, provider, api_key) async

Return the authenticated remote admin player or raise 403.

Source code in dcs_simulation_engine/api/auth.py
193
194
195
196
197
198
async def require_remote_admin_async(*, provider: Any, api_key: str | None) -> PlayerRecord:
    """Return the authenticated remote admin player or raise 403."""
    player = await require_player_async(provider=provider, api_key=api_key)
    if not is_remote_admin(player):
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access key required")
    return player
require_remote_management_from_request(request, *, detail)

Raise a 409 when remote-management-only endpoints are used outside remote mode.

Source code in dcs_simulation_engine/api/auth.py
108
109
110
111
def require_remote_management_from_request(request: Request, *, detail: str) -> None:
    """Raise a 409 when remote-management-only endpoints are used outside remote mode."""
    if not is_remote_management_enabled_from_request(request):
        raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
require_standard_mode(*, server_mode, detail)

Raise a 409 when an endpoint is disabled in free-play mode.

Source code in dcs_simulation_engine/api/auth.py
 97
 98
 99
100
def require_standard_mode(*, server_mode: ServerMode, detail: str) -> None:
    """Raise a 409 when an endpoint is disabled in free-play mode."""
    if server_mode != "standard":
        raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
require_standard_mode_from_request(request, *, detail)

Ensure an HTTP endpoint is only used while the server runs in standard mode.

Source code in dcs_simulation_engine/api/auth.py
103
104
105
def require_standard_mode_from_request(request: Request, *, detail: str) -> None:
    """Ensure an HTTP endpoint is only used while the server runs in standard mode."""
    require_standard_mode(server_mode=get_server_mode_from_request(request), detail=detail)
resolve_remote_deployment_mode(*, server_mode, default_experiment_name)

Collapse app state into a public deployment mode for remote status.

Source code in dcs_simulation_engine/api/auth.py
84
85
86
87
88
89
90
91
92
93
94
def resolve_remote_deployment_mode(
    *,
    server_mode: ServerMode,
    default_experiment_name: str | None,
) -> RemoteDeploymentMode:
    """Collapse app state into a public deployment mode for remote status."""
    if server_mode == "free_play":
        return "free_play"
    if default_experiment_name:
        return "experiment"
    return "standard"

client

Python client wrapper for the DCS FastAPI server.

Provides APIClient and SimulationRun for ergonomic use in research scripts.

APIClient

Client for the DCS FastAPI server.

Source code in dcs_simulation_engine/api/client.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
class APIClient:
    """Client for the DCS FastAPI server."""

    def __init__(self, url: str = "http://localhost:8080", api_key: str = "", timeout: float = 30.0) -> None:
        """Initialize the API client with a base URL, default API key, and request timeout."""
        self._base_url = url.rstrip("/")
        self._default_api_key = api_key
        self._http = httpx.Client(base_url=self._base_url, timeout=timeout)

    def close(self) -> None:
        """Close the underlying HTTP client transport."""
        self._http.close()

    def __enter__(self) -> Self:
        """Enter the context manager."""
        return self

    def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
        """Exit the context manager, closing the HTTP client."""
        self.close()

    def register_player(self, body: RegistrationRequest) -> RegistrationResponse:
        """Register a new player and return player_id + api_key."""
        return self._request("POST", "/api/player/registration", body, RegistrationResponse)

    def auth(self, *, api_key: Optional[str] = None) -> AuthResponse:
        """Validate an API key and return player_id + authenticated."""
        key = self._resolve_api_key(api_key)
        assert key is not None
        return self._request("POST", "/api/player/auth", AuthRequest(api_key=key), AuthResponse)

    def server_config(self) -> ServerConfigResponse:
        """Fetch server capability flags for the active runtime mode."""
        return self._request("GET", "/api/server/config", None, ServerConfigResponse)

    def list_sessions(self, *, api_key: Optional[str] = None) -> SessionsListResponse:
        """List active in-memory sessions for the authenticated player."""
        key = self._resolve_api_key(api_key)
        assert key is not None
        return self._request(
            "GET",
            "/api/sessions/list",
            None,
            SessionsListResponse,
            headers={"Authorization": f"Bearer {key}"},
        )

    def request_infer_intent_evaluation(
        self,
        session_id: str,
        *,
        api_key: Optional[str] = None,
    ) -> InferIntentEvaluationResponse:
        """Generate or fetch the cached Infer Intent evaluation for one completed session."""
        key = self._resolve_api_key(api_key, required=False)
        headers: dict[str, str] = {}
        if key:
            headers["Authorization"] = f"Bearer {key}"
        return self._request(
            "POST",
            f"/api/sessions/{session_id}/infer-intent/evaluation",
            None,
            InferIntentEvaluationResponse,
            headers=headers,
        )

    def list_games(self) -> GamesListResponse:
        """List available games."""
        return self._request("GET", "/api/games/list", None, GamesListResponse)

    def list_characters(self) -> CharactersListResponse:
        """List available characters."""
        return self._request("GET", "/api/characters/list", None, CharactersListResponse)

    def create_character(self, body: UpsertCharacterRequest) -> UpsertCharacterResponse:
        """Create a new character. Returns character_id."""
        return self._request("POST", "/api/characters", body, UpsertCharacterResponse)

    def update_character(self, character_id: str, body: UpsertCharacterRequest) -> UpsertCharacterResponse:
        """Update an existing character by id. Returns character_id."""
        return self._request("PUT", f"/api/characters/{character_id}", body, UpsertCharacterResponse)

    def delete_character(self, character_id: str) -> DeleteCharacterResponse:
        """Delete a character by id. Returns character_id."""
        return self._request("DELETE", f"/api/characters/{character_id}", None, DeleteCharacterResponse)

    def start_game(self, body: CreateGameRequest) -> SimulationRun:
        """Create a new simulation session and return a SimulationRun."""
        key = self._resolve_api_key(body.api_key, required=False)
        response = self._request("POST", "/api/play/game", body, CreateGameResponse)
        return SimulationRun(client=self, session_id=response.session_id, game_name=body.game, api_key=key)

    def setup_options(self, *, game_name: str, api_key: Optional[str] = None) -> GameSetupOptionsResponse:
        """Fetch setup authorization and valid character choices for a game."""
        key = self._resolve_api_key(api_key, required=False)
        headers: dict[str, str] = {}
        if key:
            headers["Authorization"] = f"Bearer {key}"
        return self._request(
            "GET",
            f"/api/play/setup/{game_name}",
            None,
            GameSetupOptionsResponse,
            headers=headers,
        )

    def health(self) -> dict:
        """Check server liveness."""
        response = self._http.get("/healthz")
        response.raise_for_status()
        return response.json()

    def _resolve_api_key(self, api_key: Optional[str], *, required: bool = True) -> str | None:
        key = (api_key or self._default_api_key).strip()
        if not key and required:
            raise APIRequestError("API key is required.")
        return key or None

    def _request(
        self,
        method: str,
        path: str,
        body: BaseModel | None,
        response_model: type[BaseModel],
        **kwargs: Any,
    ) -> Any:
        """Send an HTTP request with an optional Pydantic body and parse the response into a model."""
        try:
            if body is not None:
                kwargs["content"] = body.model_dump_json()
                kwargs.setdefault("headers", {})["Content-Type"] = "application/json"
            response = self._http.request(method, path, **kwargs)
            response.raise_for_status()
            return response_model.model_validate_json(response.text)
        except httpx.HTTPStatusError as exc:
            detail = exc.response.text
            try:
                payload = exc.response.json()
                if isinstance(payload, dict):
                    detail = str(payload.get("detail") or payload.get("error") or detail)
            except Exception:
                pass
            raise APIRequestError(detail) from exc
        except httpx.HTTPError as exc:
            raise APIRequestError(str(exc)) from exc

    def _build_ws_url(self, *, session_id: str) -> str:
        parsed = urlparse(self._base_url)
        scheme = "wss" if parsed.scheme == "https" else "ws"
        return f"{scheme}://{parsed.netloc}/api/play/game/{session_id}/ws"

    def _recv_frame(self, ws: Any) -> WSEventFrame | WSTurnEndFrame | WSStatusFrame | WSClosedFrame:
        """Receive one WebSocket frame and parse it into a typed model.

        Raises APIRequestError on non-text frames, non-object JSON, or
        server-side error frames (type="error").
        """
        raw = ws.recv()
        if not isinstance(raw, str):
            raise APIRequestError("Expected text websocket frame")

        data = json.loads(raw)
        if not isinstance(data, dict):
            raise APIRequestError("Expected JSON object websocket frame")

        # Server signals protocol/auth errors with a dedicated error frame type.
        if data.get("type") == "error":
            raise APIRequestError(str(data.get("detail") or data.get("message") or "Unknown websocket error"))

        frame_type = data.get("type")
        if frame_type == "event":
            return WSEventFrame.model_validate(data)
        if frame_type == "turn_end":
            return WSTurnEndFrame.model_validate(data)
        if frame_type == "status":
            return WSStatusFrame.model_validate(data)
        if frame_type == "closed":
            return WSClosedFrame.model_validate(data)

        raise APIRequestError(f"Unexpected websocket frame type: {frame_type!r}")

    def _recv_until_turn_end(self, ws: Any) -> tuple[list[WSEventFrame], WSTurnEndFrame]:
        """Drain frames until the server signals the end of a turn.

        The server streams zero or more WSEventFrame (AI output, info, etc.)
        followed by exactly one terminal frame:
          - WSTurnEndFrame — normal turn completion; may or may not be the final turn
          - WSClosedFrame  — session was closed mid-stream (e.g. game exited)

        Returns (events, turn_end).
        """
        events: list[WSEventFrame] = []
        while True:
            frame = self._recv_frame(ws)
            if isinstance(frame, WSEventFrame):
                # Accumulate content frames; event_type distinguishes ai/info/warning/error.
                events.append(frame)
                continue
            if isinstance(frame, WSTurnEndFrame):
                return events, frame
            if isinstance(frame, WSClosedFrame):
                # Session was already closed before a full turn completed; synthesize a turn_end.
                return events, WSTurnEndFrame(session_id=frame.session_id, turns=0, exited=True)

    def _ws_open_and_advance(
        self,
        *,
        session_id: str,
        api_key: str | None,
        text: Optional[str],
        include_opening: bool,
    ) -> tuple[list[WSEventFrame], WSTurnEndFrame]:
        """Open a WebSocket connection, optionally consume the opening turn, then advance.

        The server always sends an unsolicited opening turn the first time a
        session is connected to. include_opening=True consumes that opening turn
        before optionally sending a user advance. Subsequent calls should pass
        include_opening=False to skip straight to sending the advance message.

        text=None sends no advance message (used for opening-only fetches).
        """
        events: list[WSEventFrame] = []
        # Default turn_end in case no frames are received (e.g. text=None, include_opening=False).
        turn_end = WSTurnEndFrame(session_id=session_id, turns=0, exited=False)

        ws_url = self._build_ws_url(session_id=session_id)
        connect_kwargs: dict[str, Any] = {}
        if api_key:
            connect_kwargs["additional_headers"] = {"Authorization": f"Bearer {api_key}"}
        with connect(ws_url, **connect_kwargs) as ws:
            if include_opening:
                # Consume the server-initiated opening turn before sending anything.
                opening_events, opening_turn_end = self._recv_until_turn_end(ws)
                events.extend(opening_events)
                turn_end = opening_turn_end

            if text is not None:
                # Send the player's input and drain the resulting turn.
                ws.send(WSAdvanceRequest(type="advance", text=text).model_dump_json())
                step_events, step_turn_end = self._recv_until_turn_end(ws)
                events.extend(step_events)
                turn_end = step_turn_end

        return events, turn_end

    def _ws_status(self, *, session_id: str, api_key: str | None, include_opening: bool) -> WSStatusFrame:
        """Fetch session status via WebSocket without advancing a turn.

        If include_opening=True, the unsolicited opening turn is consumed first
        (required on first connect before any other message can be sent).
        Sends a WSStatusRequest and returns the WSStatusFrame payload.
        """
        ws_url = self._build_ws_url(session_id=session_id)
        connect_kwargs: dict[str, Any] = {}
        if api_key:
            connect_kwargs["additional_headers"] = {"Authorization": f"Bearer {api_key}"}
        with connect(ws_url, **connect_kwargs) as ws:
            if include_opening:
                # Must drain the opening turn before the server will accept requests.
                self._recv_until_turn_end(ws)
            ws.send(WSStatusRequest(type="status").model_dump_json())
            frame = self._recv_frame(ws)

        if not isinstance(frame, WSStatusFrame):
            raise APIRequestError("Expected status websocket frame")

        return frame

    def _ws_close(self, *, session_id: str, api_key: str | None, include_opening: bool) -> None:
        """Close a session via WebSocket.

        Sends a WSCloseRequest and waits for the server's WSClosedFrame confirmation.
        If include_opening=True, the opening turn is consumed first so the server
        is ready to accept the close message.
        """
        ws_url = self._build_ws_url(session_id=session_id)
        connect_kwargs: dict[str, Any] = {}
        if api_key:
            connect_kwargs["additional_headers"] = {"Authorization": f"Bearer {api_key}"}
        with connect(ws_url, **connect_kwargs) as ws:
            if include_opening:
                self._recv_until_turn_end(ws)
            ws.send(WSCloseRequest(type="close").model_dump_json())
            frame = self._recv_frame(ws)
            if not isinstance(frame, WSClosedFrame):
                raise APIRequestError("Expected closed websocket frame")
__enter__()

Enter the context manager.

Source code in dcs_simulation_engine/api/client.py
155
156
157
def __enter__(self) -> Self:
    """Enter the context manager."""
    return self
__exit__(exc_type, exc_val, exc_tb)

Exit the context manager, closing the HTTP client.

Source code in dcs_simulation_engine/api/client.py
159
160
161
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
    """Exit the context manager, closing the HTTP client."""
    self.close()
__init__(url='http://localhost:8080', api_key='', timeout=30.0)

Initialize the API client with a base URL, default API key, and request timeout.

Source code in dcs_simulation_engine/api/client.py
145
146
147
148
149
def __init__(self, url: str = "http://localhost:8080", api_key: str = "", timeout: float = 30.0) -> None:
    """Initialize the API client with a base URL, default API key, and request timeout."""
    self._base_url = url.rstrip("/")
    self._default_api_key = api_key
    self._http = httpx.Client(base_url=self._base_url, timeout=timeout)
auth(*, api_key=None)

Validate an API key and return player_id + authenticated.

Source code in dcs_simulation_engine/api/client.py
167
168
169
170
171
def auth(self, *, api_key: Optional[str] = None) -> AuthResponse:
    """Validate an API key and return player_id + authenticated."""
    key = self._resolve_api_key(api_key)
    assert key is not None
    return self._request("POST", "/api/player/auth", AuthRequest(api_key=key), AuthResponse)
close()

Close the underlying HTTP client transport.

Source code in dcs_simulation_engine/api/client.py
151
152
153
def close(self) -> None:
    """Close the underlying HTTP client transport."""
    self._http.close()
create_character(body)

Create a new character. Returns character_id.

Source code in dcs_simulation_engine/api/client.py
216
217
218
def create_character(self, body: UpsertCharacterRequest) -> UpsertCharacterResponse:
    """Create a new character. Returns character_id."""
    return self._request("POST", "/api/characters", body, UpsertCharacterResponse)
delete_character(character_id)

Delete a character by id. Returns character_id.

Source code in dcs_simulation_engine/api/client.py
224
225
226
def delete_character(self, character_id: str) -> DeleteCharacterResponse:
    """Delete a character by id. Returns character_id."""
    return self._request("DELETE", f"/api/characters/{character_id}", None, DeleteCharacterResponse)
health()

Check server liveness.

Source code in dcs_simulation_engine/api/client.py
248
249
250
251
252
def health(self) -> dict:
    """Check server liveness."""
    response = self._http.get("/healthz")
    response.raise_for_status()
    return response.json()
list_characters()

List available characters.

Source code in dcs_simulation_engine/api/client.py
212
213
214
def list_characters(self) -> CharactersListResponse:
    """List available characters."""
    return self._request("GET", "/api/characters/list", None, CharactersListResponse)
list_games()

List available games.

Source code in dcs_simulation_engine/api/client.py
208
209
210
def list_games(self) -> GamesListResponse:
    """List available games."""
    return self._request("GET", "/api/games/list", None, GamesListResponse)
list_sessions(*, api_key=None)

List active in-memory sessions for the authenticated player.

Source code in dcs_simulation_engine/api/client.py
177
178
179
180
181
182
183
184
185
186
187
def list_sessions(self, *, api_key: Optional[str] = None) -> SessionsListResponse:
    """List active in-memory sessions for the authenticated player."""
    key = self._resolve_api_key(api_key)
    assert key is not None
    return self._request(
        "GET",
        "/api/sessions/list",
        None,
        SessionsListResponse,
        headers={"Authorization": f"Bearer {key}"},
    )
register_player(body)

Register a new player and return player_id + api_key.

Source code in dcs_simulation_engine/api/client.py
163
164
165
def register_player(self, body: RegistrationRequest) -> RegistrationResponse:
    """Register a new player and return player_id + api_key."""
    return self._request("POST", "/api/player/registration", body, RegistrationResponse)
request_infer_intent_evaluation(session_id, *, api_key=None)

Generate or fetch the cached Infer Intent evaluation for one completed session.

Source code in dcs_simulation_engine/api/client.py
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
def request_infer_intent_evaluation(
    self,
    session_id: str,
    *,
    api_key: Optional[str] = None,
) -> InferIntentEvaluationResponse:
    """Generate or fetch the cached Infer Intent evaluation for one completed session."""
    key = self._resolve_api_key(api_key, required=False)
    headers: dict[str, str] = {}
    if key:
        headers["Authorization"] = f"Bearer {key}"
    return self._request(
        "POST",
        f"/api/sessions/{session_id}/infer-intent/evaluation",
        None,
        InferIntentEvaluationResponse,
        headers=headers,
    )
server_config()

Fetch server capability flags for the active runtime mode.

Source code in dcs_simulation_engine/api/client.py
173
174
175
def server_config(self) -> ServerConfigResponse:
    """Fetch server capability flags for the active runtime mode."""
    return self._request("GET", "/api/server/config", None, ServerConfigResponse)
setup_options(*, game_name, api_key=None)

Fetch setup authorization and valid character choices for a game.

Source code in dcs_simulation_engine/api/client.py
234
235
236
237
238
239
240
241
242
243
244
245
246
def setup_options(self, *, game_name: str, api_key: Optional[str] = None) -> GameSetupOptionsResponse:
    """Fetch setup authorization and valid character choices for a game."""
    key = self._resolve_api_key(api_key, required=False)
    headers: dict[str, str] = {}
    if key:
        headers["Authorization"] = f"Bearer {key}"
    return self._request(
        "GET",
        f"/api/play/setup/{game_name}",
        None,
        GameSetupOptionsResponse,
        headers=headers,
    )
start_game(body)

Create a new simulation session and return a SimulationRun.

Source code in dcs_simulation_engine/api/client.py
228
229
230
231
232
def start_game(self, body: CreateGameRequest) -> SimulationRun:
    """Create a new simulation session and return a SimulationRun."""
    key = self._resolve_api_key(body.api_key, required=False)
    response = self._request("POST", "/api/play/game", body, CreateGameResponse)
    return SimulationRun(client=self, session_id=response.session_id, game_name=body.game, api_key=key)
update_character(character_id, body)

Update an existing character by id. Returns character_id.

Source code in dcs_simulation_engine/api/client.py
220
221
222
def update_character(self, character_id: str, body: UpsertCharacterRequest) -> UpsertCharacterResponse:
    """Update an existing character by id. Returns character_id."""
    return self._request("PUT", f"/api/characters/{character_id}", body, UpsertCharacterResponse)
SimulationRun

Lightweight wrapper around an active server-side simulation session.

Source code in dcs_simulation_engine/api/client.py
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
class SimulationRun:
    """Lightweight wrapper around an active server-side simulation session."""

    def __init__(
        self,
        client: "APIClient",
        session_id: str,
        game_name: str,
        api_key: str | None,
    ) -> None:
        """Initialize a SimulationRun bound to an existing server-side session."""
        self._client = client
        self.session_id = session_id
        self.game_name = game_name
        self._api_key = api_key
        self._opened = False
        self._events: list[WSEventFrame] = []
        self._turn_end: WSTurnEndFrame | None = None

    def __enter__(self) -> Self:
        """Enter the context manager."""
        return self

    def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
        """Exit the context manager, closing the session silently on error."""
        try:
            self.close()
        except Exception:
            pass

    def step(self, user_input: str = "") -> Self:
        """Advance the simulation by one step."""
        if not self._opened:
            first_text: str | None = user_input if user_input != "" else None
            events, turn_end = self._client._ws_open_and_advance(
                session_id=self.session_id,
                api_key=self._api_key,
                text=first_text,
                include_opening=True,
            )
            self._opened = True
        else:
            events, turn_end = self._client._ws_open_and_advance(
                session_id=self.session_id,
                api_key=self._api_key,
                text=user_input,
                include_opening=False,
            )

        self._events.extend(events)
        self._turn_end = turn_end
        return self

    def get_state(self) -> Self:
        """Fetch current session status without advancing a turn."""
        status_frame = self._client._ws_status(
            session_id=self.session_id,
            api_key=self._api_key,
            include_opening=not self._opened,
        )
        self._opened = True
        # Mirror turn_end shape from status frame for consistent meta access.
        self._turn_end = WSTurnEndFrame(
            session_id=status_frame.session_id,
            turns=status_frame.turns,
            exited=status_frame.exited,
        )
        return self

    def close(self) -> None:
        """Close the server-side session."""
        self._client._ws_close(
            session_id=self.session_id,
            api_key=self._api_key,
            include_opening=not self._opened,
        )
        self._opened = True

    @property
    def is_complete(self) -> bool:
        """True if the simulation has reached an exit condition."""
        return self._turn_end.exited if self._turn_end else False

    @property
    def simulator_output(self) -> Optional[str]:
        """Content of the latest AI event, if any."""
        for event in reversed(self._events):
            if event.event_type == "ai":
                return event.content
        return None

    @property
    def history(self) -> list[WSEventFrame]:
        """All WSEventFrames received so far, in order."""
        return list(self._events)

    @property
    def turns(self) -> int:
        """Number of completed turns, or 0 if no turn has ended yet."""
        return self._turn_end.turns if self._turn_end else 0
history property

All WSEventFrames received so far, in order.

is_complete property

True if the simulation has reached an exit condition.

simulator_output property

Content of the latest AI event, if any.

turns property

Number of completed turns, or 0 if no turn has ended yet.

__enter__()

Enter the context manager.

Source code in dcs_simulation_engine/api/client.py
59
60
61
def __enter__(self) -> Self:
    """Enter the context manager."""
    return self
__exit__(exc_type, exc_val, exc_tb)

Exit the context manager, closing the session silently on error.

Source code in dcs_simulation_engine/api/client.py
63
64
65
66
67
68
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
    """Exit the context manager, closing the session silently on error."""
    try:
        self.close()
    except Exception:
        pass
__init__(client, session_id, game_name, api_key)

Initialize a SimulationRun bound to an existing server-side session.

Source code in dcs_simulation_engine/api/client.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def __init__(
    self,
    client: "APIClient",
    session_id: str,
    game_name: str,
    api_key: str | None,
) -> None:
    """Initialize a SimulationRun bound to an existing server-side session."""
    self._client = client
    self.session_id = session_id
    self.game_name = game_name
    self._api_key = api_key
    self._opened = False
    self._events: list[WSEventFrame] = []
    self._turn_end: WSTurnEndFrame | None = None
close()

Close the server-side session.

Source code in dcs_simulation_engine/api/client.py
109
110
111
112
113
114
115
116
def close(self) -> None:
    """Close the server-side session."""
    self._client._ws_close(
        session_id=self.session_id,
        api_key=self._api_key,
        include_opening=not self._opened,
    )
    self._opened = True
get_state()

Fetch current session status without advancing a turn.

Source code in dcs_simulation_engine/api/client.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
def get_state(self) -> Self:
    """Fetch current session status without advancing a turn."""
    status_frame = self._client._ws_status(
        session_id=self.session_id,
        api_key=self._api_key,
        include_opening=not self._opened,
    )
    self._opened = True
    # Mirror turn_end shape from status frame for consistent meta access.
    self._turn_end = WSTurnEndFrame(
        session_id=status_frame.session_id,
        turns=status_frame.turns,
        exited=status_frame.exited,
    )
    return self
step(user_input='')

Advance the simulation by one step.

Source code in dcs_simulation_engine/api/client.py
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
def step(self, user_input: str = "") -> Self:
    """Advance the simulation by one step."""
    if not self._opened:
        first_text: str | None = user_input if user_input != "" else None
        events, turn_end = self._client._ws_open_and_advance(
            session_id=self.session_id,
            api_key=self._api_key,
            text=first_text,
            include_opening=True,
        )
        self._opened = True
    else:
        events, turn_end = self._client._ws_open_and_advance(
            session_id=self.session_id,
            api_key=self._api_key,
            text=user_input,
            include_opening=False,
        )

    self._events.extend(events)
    self._turn_end = turn_end
    return self

infer_intent_evaluation

Helpers for generating or loading Infer Intent evaluations from persisted sessions.

InferIntentEvaluationUnavailableError

Bases: ValueError

Raised when a requested Infer Intent evaluation cannot be produced.

Source code in dcs_simulation_engine/api/infer_intent_evaluation.py
24
25
class InferIntentEvaluationUnavailableError(ValueError):
    """Raised when a requested Infer Intent evaluation cannot be produced."""
extract_infer_intent_scoring_inputs(events)

Rebuild the transcript and saved player intent prediction from persisted session events.

Source code in dcs_simulation_engine/api/infer_intent_evaluation.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def extract_infer_intent_scoring_inputs(events: list[SessionEventRecord]) -> tuple[str, str]:
    """Rebuild the transcript and saved player intent prediction from persisted session events."""
    transcript_lines: list[str] = []
    found_prediction_command = False
    prediction = ""

    for event in sorted(events, key=lambda item: item.seq):
        if event.data.get(MongoColumns.VISIBLE_TO_USER) is False:
            continue

        if not found_prediction_command:
            if _is_prediction_command(event):
                found_prediction_command = True
                continue

            if event.direction == "inbound" and event.event_source == "user" and event.event_type == "message":
                transcript_lines.append(f"Player: {event.content}")
                continue

            if event.direction == "outbound" and event.event_source == "npc" and event.event_type == "message":
                transcript_lines.append(f"NPC: {event.content}")
                continue

            continue

        if event.direction == "inbound" and event.event_source == "user" and event.event_type == "message":
            prediction = event.content.strip()
            break

    if not found_prediction_command:
        raise InferIntentEvaluationUnavailableError("Infer Intent evaluation is unavailable because no prediction was saved.")
    if not prediction:
        raise InferIntentEvaluationUnavailableError(
            "Infer Intent evaluation is unavailable because the saved prediction could not be found."
        )

    return ("\n".join(transcript_lines), prediction)
generate_or_get_infer_intent_evaluation(*, provider, session_id, player_id, condition=None) async

Return a cached Infer Intent evaluation or generate and persist it once.

Source code in dcs_simulation_engine/api/infer_intent_evaluation.py
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
async def generate_or_get_infer_intent_evaluation(
    *,
    provider: Any,
    session_id: str,
    player_id: str,
    condition: str | None = None,
) -> InferIntentEvaluationResponse | None:
    """Return a cached Infer Intent evaluation or generate and persist it once."""
    session = await maybe_await(provider.get_session(session_id=session_id, player_id=player_id))
    if session is None:
        return None

    _validate_infer_intent_session(session)

    events = await maybe_await(provider.list_session_events(session_id=session_id))
    cached_event = _find_cached_evaluation_event(events)
    if cached_event is not None:
        return InferIntentEvaluationResponse(
            session_id=session_id,
            event_id=cached_event.event_id,
            cached=True,
            evaluation=InferIntentEvaluation.model_validate_json(cached_event.content),
        )

    transcript, prediction = extract_infer_intent_scoring_inputs(events)
    npc = await _load_session_npc(provider=provider, session=session)
    scorer_result = await ScorerClient(npc=npc).score(transcript, prediction)
    evaluation = InferIntentEvaluation.model_validate(scorer_result.evaluation)

    append_event = getattr(provider, "append_session_event", None)
    if append_event is None:
        raise NotImplementedError("Infer Intent evaluation persistence is unavailable for this provider.")

    stored = await maybe_await(
        append_event(
            session_id=session_id,
            player_id=player_id,
            direction="internal",
            event_type=LLM_EVAL_EVENT_TYPE,
            event_source="system",
            content=scorer_result.raw_json,
            content_format="json",
            turn_index=int(session.data.get(MongoColumns.TURNS_COMPLETED, 0) or 0),
            visible_to_user=(condition == "learning"),
        )
    )
    if stored is None:
        return None

    return InferIntentEvaluationResponse(
        session_id=session_id,
        event_id=stored.event_id,
        cached=False,
        evaluation=evaluation,
    )

models

Pydantic models and payload parsers for the API layer.

AuthRequest

Bases: BaseModel

Payload for API-key authentication checks.

Source code in dcs_simulation_engine/api/models.py
36
37
38
39
class AuthRequest(BaseModel):
    """Payload for API-key authentication checks."""

    api_key: str = Field(min_length=1)
AuthResponse

Bases: BaseModel

Response payload for successful API-key auth.

Source code in dcs_simulation_engine/api/models.py
42
43
44
45
46
47
class AuthResponse(BaseModel):
    """Response payload for successful API-key auth."""

    player_id: str
    full_name: str = ""
    authenticated: bool = True
CharacterChoice

Bases: BaseModel

A selectable character option for setup screens.

Source code in dcs_simulation_engine/api/models.py
106
107
108
109
110
class CharacterChoice(BaseModel):
    """A selectable character option for setup screens."""

    hid: str
    label: str
CharacterSummary

Bases: BaseModel

A single character entry.

Source code in dcs_simulation_engine/api/models.py
413
414
415
416
417
class CharacterSummary(BaseModel):
    """A single character entry."""

    hid: str
    short_description: str
CharactersListResponse

Bases: BaseModel

Response payload for characters list endpoint.

Source code in dcs_simulation_engine/api/models.py
420
421
422
423
class CharactersListResponse(BaseModel):
    """Response payload for characters list endpoint."""

    characters: list[CharacterSummary]
ClearSessionEventFeedbackResponse

Bases: BaseModel

Response payload after feedback is removed from a session event.

Source code in dcs_simulation_engine/api/models.py
283
284
285
286
287
288
class ClearSessionEventFeedbackResponse(BaseModel):
    """Response payload after feedback is removed from a session event."""

    session_id: str
    event_id: str
    cleared: bool = True
CreateGameRequest

Bases: BaseModel

Payload for creating a new gameplay session.

Source code in dcs_simulation_engine/api/models.py
88
89
90
91
92
93
94
95
class CreateGameRequest(BaseModel):
    """Payload for creating a new gameplay session."""

    api_key: str | None = None
    game: str = Field(min_length=1)
    pc_choice: str | None = None
    npc_choice: str | None = None
    source: str = Field(default="api", min_length=1)
CreateGameResponse

Bases: BaseModel

Response payload for newly created sessions.

Source code in dcs_simulation_engine/api/models.py
 98
 99
100
101
102
103
class CreateGameResponse(BaseModel):
    """Response payload for newly created sessions."""

    session_id: str
    status: SessionStatus
    ws_path: str
DeleteCharacterResponse

Bases: BaseModel

Response payload after deletion.

Source code in dcs_simulation_engine/api/models.py
439
440
441
442
class DeleteCharacterResponse(BaseModel):
    """Response payload after deletion."""

    character_id: str
EligibleAssignmentOption

Bases: BaseModel

One eligible game+character option returned in player_choice mode.

Source code in dcs_simulation_engine/api/models.py
175
176
177
178
179
class EligibleAssignmentOption(BaseModel):
    """One eligible game+character option returned in player_choice mode."""

    game_name: str
    character_hid: str
EligibleAssignmentOptionsResponse

Bases: BaseModel

List of eligible assignment options for a player in player_choice mode.

Source code in dcs_simulation_engine/api/models.py
182
183
184
185
class EligibleAssignmentOptionsResponse(BaseModel):
    """List of eligible assignment options for a player in player_choice mode."""

    options: list[EligibleAssignmentOption]
ExperimentAssignmentSummary

Bases: BaseModel

Assignment summary returned by experiment endpoints.

Source code in dcs_simulation_engine/api/models.py
125
126
127
128
129
130
131
class ExperimentAssignmentSummary(BaseModel):
    """Assignment summary returned by experiment endpoints."""

    assignment_id: str
    game_name: str
    character_hid: str
    status: AssignmentStatus
ExperimentGameStatusResponse

Bases: BaseModel

Per-game status counts for an experiment.

Source code in dcs_simulation_engine/api/models.py
142
143
144
145
146
147
class ExperimentGameStatusResponse(BaseModel):
    """Per-game status counts for an experiment."""

    total: int
    completed: int
    in_progress: int
ExperimentPlayerRequest

Bases: BaseModel

Entry-form payload for experiment registration.

Source code in dcs_simulation_engine/api/models.py
195
196
197
198
class ExperimentPlayerRequest(BaseModel):
    """Entry-form payload for experiment registration."""

    responses: dict[str, dict]
ExperimentPlayerResponse

Bases: BaseModel

Assignment response after an authenticated player submits before-play forms.

Source code in dcs_simulation_engine/api/models.py
201
202
203
204
class ExperimentPlayerResponse(BaseModel):
    """Assignment response after an authenticated player submits before-play forms."""

    assignment: ExperimentAssignmentSummary | None = None
ExperimentPostPlayRequest

Bases: BaseModel

Payload for storing experiment post-play form answers.

Source code in dcs_simulation_engine/api/models.py
213
214
215
216
class ExperimentPostPlayRequest(BaseModel):
    """Payload for storing experiment post-play form answers."""

    responses: dict[str, dict]
ExperimentProgressResponse

Bases: BaseModel

Finite progress payload for the usability experiment.

Source code in dcs_simulation_engine/api/models.py
134
135
136
137
138
139
class ExperimentProgressResponse(BaseModel):
    """Finite progress payload for the usability experiment."""

    total: int
    completed: int
    is_complete: bool
ExperimentSessionRequest

Bases: BaseModel

Payload for creating a session from the current assignment.

Source code in dcs_simulation_engine/api/models.py
207
208
209
210
class ExperimentSessionRequest(BaseModel):
    """Payload for creating a session from the current assignment."""

    source: str = Field(default="experiment", min_length=1)
ExperimentSetupResponse

Bases: BaseModel

Setup payload for the experiment landing page.

Source code in dcs_simulation_engine/api/models.py
159
160
161
162
163
164
165
166
167
168
169
170
171
172
class ExperimentSetupResponse(BaseModel):
    """Setup payload for the experiment landing page."""

    experiment_name: str
    description: str
    is_open: bool
    forms: list[dict] = Field(default_factory=list)
    progress: ExperimentProgressResponse
    current_assignment: ExperimentAssignmentSummary | None = None
    pending_post_play: bool = False
    # True only when the participant has exhausted all assignments available to them.
    assignment_completed: bool = False
    assignment_mode: str = "auto"
    assignments: list[ExperimentAssignmentSummary] = Field(default_factory=list)
ExperimentStatusResponse

Bases: BaseModel

Aggregate status payload for an experiment.

Source code in dcs_simulation_engine/api/models.py
150
151
152
153
154
155
156
class ExperimentStatusResponse(BaseModel):
    """Aggregate status payload for an experiment."""

    is_open: bool
    total: int
    completed: int
    per_game: dict[str, ExperimentGameStatusResponse]
GameSetupOptionsResponse

Bases: BaseModel

Preflight setup data for a specific game + authenticated player.

Source code in dcs_simulation_engine/api/models.py
113
114
115
116
117
118
119
120
121
122
class GameSetupOptionsResponse(BaseModel):
    """Preflight setup data for a specific game + authenticated player."""

    game: str
    allowed: bool
    can_start: bool
    denial_reason: SetupDenialReason | None = None
    message: str | None = None
    pcs: list[CharacterChoice]
    npcs: list[CharacterChoice]
GameSummary

Bases: BaseModel

A single game entry.

Source code in dcs_simulation_engine/api/models.py
399
400
401
402
403
404
class GameSummary(BaseModel):
    """A single game entry."""

    name: str
    author: str
    description: str | None
GamesListResponse

Bases: BaseModel

Response payload for games list endpoint.

Source code in dcs_simulation_engine/api/models.py
407
408
409
410
class GamesListResponse(BaseModel):
    """Response payload for games list endpoint."""

    games: list[GameSummary]
InferIntentEvaluation

Bases: BaseModel

Parsed Infer Intent evaluation payload returned by the scorer.

Source code in dcs_simulation_engine/api/models.py
237
238
239
240
241
242
class InferIntentEvaluation(BaseModel):
    """Parsed Infer Intent evaluation payload returned by the scorer."""

    tier: int = Field(ge=0, le=3)
    score: int = Field(ge=0, le=100)
    reasoning: str = Field(min_length=1)
InferIntentEvaluationResponse

Bases: BaseModel

Response payload for the cached-or-generated Infer Intent evaluation.

Source code in dcs_simulation_engine/api/models.py
245
246
247
248
249
250
251
class InferIntentEvaluationResponse(BaseModel):
    """Response payload for the cached-or-generated Infer Intent evaluation."""

    session_id: str
    event_id: str
    cached: bool
    evaluation: InferIntentEvaluation
RegistrationRequest

Bases: BaseModel

Payload for creating a new player and issuing an API key.

Source code in dcs_simulation_engine/api/models.py
19
20
21
22
23
24
25
26
class RegistrationRequest(BaseModel):
    """Payload for creating a new player and issuing an API key."""

    full_name: str = Field(min_length=1)
    email: str = Field(min_length=1)
    phone_number: str = Field(min_length=1)
    consent_to_followup: bool
    consent_signature: str = Field(min_length=1)
RegistrationResponse

Bases: BaseModel

Response payload for registration.

Source code in dcs_simulation_engine/api/models.py
29
30
31
32
33
class RegistrationResponse(BaseModel):
    """Response payload for registration."""

    player_id: str
    api_key: str
RemoteBootstrapResponse

Bases: BaseModel

Bootstrap response containing the newly issued remote admin key.

Source code in dcs_simulation_engine/api/models.py
68
69
70
71
72
73
class RemoteBootstrapResponse(BaseModel):
    """Bootstrap response containing the newly issued remote admin key."""

    player_id: str
    admin_api_key: str
    experiment_name: str | None = None
RemoteStatusResponse

Bases: BaseModel

Public status payload for remote-managed or generic deployments.

Source code in dcs_simulation_engine/api/models.py
76
77
78
79
80
81
82
83
84
85
class RemoteStatusResponse(BaseModel):
    """Public status payload for remote-managed or generic deployments."""

    status: Literal["ok"] = "ok"
    mode: RemoteDeploymentMode
    started_at: datetime
    uptime: int
    experiment_name: str | None = None
    progress: ExperimentProgressResponse | None = None
    experiment_status: ExperimentStatusResponse | None = None
SelectAssignmentRequest

Bases: BaseModel

Payload for player-directed assignment selection.

Source code in dcs_simulation_engine/api/models.py
188
189
190
191
192
class SelectAssignmentRequest(BaseModel):
    """Payload for player-directed assignment selection."""

    game_name: str
    character_hid: str
ServerConfigResponse

Bases: BaseModel

Response payload describing server capabilities for the active mode.

Source code in dcs_simulation_engine/api/models.py
50
51
52
53
54
55
56
57
class ServerConfigResponse(BaseModel):
    """Response payload describing server capabilities for the active mode."""

    mode: ServerMode
    authentication_required: bool
    registration_enabled: bool
    experiments_enabled: bool
    default_experiment_name: str | None = None
SessionEventFeedback

Bases: BaseModel

Stored reaction, comment, and issue flags attached to one assistant message.

Source code in dcs_simulation_engine/api/models.py
254
255
256
257
258
259
260
261
262
class SessionEventFeedback(BaseModel):
    """Stored reaction, comment, and issue flags attached to one assistant message."""

    liked: bool
    comment: str = ""
    doesnt_make_sense: bool
    out_of_character: bool
    other: bool = False
    submitted_at: datetime
SessionSummary

Bases: BaseModel

A single in-memory session summary for list responses.

Source code in dcs_simulation_engine/api/models.py
219
220
221
222
223
224
225
226
227
228
class SessionSummary(BaseModel):
    """A single in-memory session summary for list responses."""

    session_id: str
    game: str
    status: SessionStatus
    created_at: datetime
    last_active: datetime
    turns: int
    exited: bool
SessionsListResponse

Bases: BaseModel

Response payload for session list endpoint.

Source code in dcs_simulation_engine/api/models.py
231
232
233
234
class SessionsListResponse(BaseModel):
    """Response payload for session list endpoint."""

    sessions: list[SessionSummary]
StatusResponse

Bases: BaseModel

Response payload describing process liveness and uptime.

Source code in dcs_simulation_engine/api/models.py
60
61
62
63
64
65
class StatusResponse(BaseModel):
    """Response payload describing process liveness and uptime."""

    status: Literal["ok"] = "ok"
    started_at: datetime
    uptime: int
SubmitSessionEventFeedbackRequest

Bases: BaseModel

Payload for storing feedback on a single assistant session event.

Source code in dcs_simulation_engine/api/models.py
265
266
267
268
269
270
271
272
class SubmitSessionEventFeedbackRequest(BaseModel):
    """Payload for storing feedback on a single assistant session event."""

    liked: bool
    comment: str = ""
    doesnt_make_sense: bool
    out_of_character: bool
    other: bool = False
SubmitSessionEventFeedbackResponse

Bases: BaseModel

Response payload after feedback is stored on a session event.

Source code in dcs_simulation_engine/api/models.py
275
276
277
278
279
280
class SubmitSessionEventFeedbackResponse(BaseModel):
    """Response payload after feedback is stored on a session event."""

    session_id: str
    event_id: str
    feedback: SessionEventFeedback
UpsertCharacterRequest

Bases: BaseModel

Payload for creating or updating a character.

Source code in dcs_simulation_engine/api/models.py
426
427
428
429
430
class UpsertCharacterRequest(BaseModel):
    """Payload for creating or updating a character."""

    character_id: str | None = None
    data: dict
UpsertCharacterResponse

Bases: BaseModel

Response payload after upsert.

Source code in dcs_simulation_engine/api/models.py
433
434
435
436
class UpsertCharacterResponse(BaseModel):
    """Response payload after upsert."""

    character_id: str
WSAdvanceRequest

Bases: BaseModel

WebSocket frame for advancing the game.

Source code in dcs_simulation_engine/api/models.py
298
299
300
301
302
class WSAdvanceRequest(BaseModel):
    """WebSocket frame for advancing the game."""

    type: Literal["advance"]
    text: str = ""
WSAuthRequest

Bases: BaseModel

WebSocket first-message auth frame (browser clients only).

Source code in dcs_simulation_engine/api/models.py
291
292
293
294
295
class WSAuthRequest(BaseModel):
    """WebSocket first-message auth frame (browser clients only)."""

    type: Literal["auth"]
    api_key: str = Field(min_length=1)
WSCloseRequest

Bases: BaseModel

WebSocket frame for closing a session.

Source code in dcs_simulation_engine/api/models.py
311
312
313
314
class WSCloseRequest(BaseModel):
    """WebSocket frame for closing a session."""

    type: Literal["close"]
WSClosedFrame

Bases: BaseModel

WebSocket frame indicating session closure.

Source code in dcs_simulation_engine/api/models.py
359
360
361
362
363
class WSClosedFrame(BaseModel):
    """WebSocket frame indicating session closure."""

    type: Literal["closed"] = "closed"
    session_id: str
WSErrorFrame

Bases: BaseModel

WebSocket frame describing a protocol or auth error.

Source code in dcs_simulation_engine/api/models.py
366
367
368
369
370
class WSErrorFrame(BaseModel):
    """WebSocket frame describing a protocol or auth error."""

    type: Literal["error"] = "error"
    detail: str
WSEventFrame

Bases: BaseModel

WebSocket frame representing a single game event.

Source code in dcs_simulation_engine/api/models.py
330
331
332
333
334
335
336
337
class WSEventFrame(BaseModel):
    """WebSocket frame representing a single game event."""

    type: Literal["event"] = "event"
    session_id: str
    event_type: EventType
    content: str
    event_id: str | None = None
WSReplayEndFrame

Bases: BaseModel

WebSocket frame signaling the end of a historical event replay burst.

Source code in dcs_simulation_engine/api/models.py
391
392
393
394
395
396
class WSReplayEndFrame(BaseModel):
    """WebSocket frame signaling the end of a historical event replay burst."""

    type: Literal["replay_end"] = "replay_end"
    session_id: str
    turns: int
WSReplayEventFrame

Bases: BaseModel

WebSocket frame carrying one historical event during replay.

Source code in dcs_simulation_engine/api/models.py
380
381
382
383
384
385
386
387
388
class WSReplayEventFrame(BaseModel):
    """WebSocket frame carrying one historical event during replay."""

    type: Literal["replay_event"] = "replay_event"
    session_id: str
    event_type: EventType
    content: str
    event_id: str | None = None
    role: Literal["user", "ai"] = "ai"
WSReplayStartFrame

Bases: BaseModel

WebSocket frame signaling the start of a historical event replay burst.

Source code in dcs_simulation_engine/api/models.py
373
374
375
376
377
class WSReplayStartFrame(BaseModel):
    """WebSocket frame signaling the start of a historical event replay burst."""

    type: Literal["replay_start"] = "replay_start"
    session_id: str
WSSessionMetaFrame

Bases: BaseModel

WebSocket frame sent once after auth, carrying session metadata.

Source code in dcs_simulation_engine/api/models.py
320
321
322
323
324
325
326
327
class WSSessionMetaFrame(BaseModel):
    """WebSocket frame sent once after auth, carrying session metadata."""

    type: Literal["session_meta"] = "session_meta"
    session_id: str
    pc_hid: str | None = None
    npc_hid: str | None = None
    has_game_feedback: bool = False
WSStatusFrame

Bases: BaseModel

WebSocket frame reporting current session status.

Source code in dcs_simulation_engine/api/models.py
349
350
351
352
353
354
355
356
class WSStatusFrame(BaseModel):
    """WebSocket frame reporting current session status."""

    type: Literal["status"] = "status"
    session_id: str
    status: SessionStatus
    turns: int
    exited: bool
WSStatusRequest

Bases: BaseModel

WebSocket frame for requesting session status.

Source code in dcs_simulation_engine/api/models.py
305
306
307
308
class WSStatusRequest(BaseModel):
    """WebSocket frame for requesting session status."""

    type: Literal["status"]
WSTurnEndFrame

Bases: BaseModel

WebSocket frame emitted at the end of each completed turn.

Source code in dcs_simulation_engine/api/models.py
340
341
342
343
344
345
346
class WSTurnEndFrame(BaseModel):
    """WebSocket frame emitted at the end of each completed turn."""

    type: Literal["turn_end"] = "turn_end"
    session_id: str
    turns: int
    exited: bool
parse_ws_auth(raw)

Parse a first-message auth frame. Returns None if not an auth message.

Source code in dcs_simulation_engine/api/models.py
445
446
447
448
449
450
451
452
453
454
455
456
def parse_ws_auth(raw: str) -> WSAuthRequest | None:
    """Parse a first-message auth frame. Returns None if not an auth message."""
    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        return None
    if not isinstance(data, dict) or data.get("type") != "auth":
        return None
    try:
        return WSAuthRequest.model_validate(data)
    except ValidationError:
        return None
parse_ws_request(raw)

Parse and validate a raw JSON websocket request payload.

Source code in dcs_simulation_engine/api/models.py
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
def parse_ws_request(raw: str) -> WSRequest:
    """Parse and validate a raw JSON websocket request payload."""
    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        raise ValueError("Malformed JSON payload") from exc

    if not isinstance(data, dict):
        raise ValueError("Payload must be a JSON object")

    kind = data.get("type")
    try:
        if kind == "advance":
            return WSAdvanceRequest.model_validate(data)
        if kind == "status":
            return WSStatusRequest.model_validate(data)
        if kind == "close":
            return WSCloseRequest.model_validate(data)
    except ValidationError as exc:
        raise ValueError(str(exc)) from exc

    raise ValueError(f"Unknown request type: {kind!r}")

registry

In-memory session registry with TTL cleanup for FastAPI server sessions.

SessionEntry dataclass

Represents one in-memory API session record.

Source code in dcs_simulation_engine/api/registry.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@dataclass
class SessionEntry:
    """Represents one in-memory API session record."""

    session_id: str
    player_id: str | None
    game_name: str
    manager: SessionManager
    experiment_name: str | None = None
    assignment_id: str | None = None
    status: SessionStatus = "active"
    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
    last_active: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
    opening_sent: bool = False
    ws_connected: bool = False

    def touch(self) -> None:
        """Refresh the last-activity timestamp."""
        self.last_active = datetime.now(timezone.utc)
touch()

Refresh the last-activity timestamp.

Source code in dcs_simulation_engine/api/registry.py
33
34
35
def touch(self) -> None:
    """Refresh the last-activity timestamp."""
    self.last_active = datetime.now(timezone.utc)
SessionRegistry

Thread-safe in-memory session store with async TTL sweeping.

Source code in dcs_simulation_engine/api/registry.py
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
class SessionRegistry:
    """Thread-safe in-memory session store with async TTL sweeping."""

    def __init__(self, *, ttl_seconds: int = 3600, sweep_interval_seconds: int = 60) -> None:
        """Initialize registry settings and empty storage."""
        if ttl_seconds <= 0:
            raise ValueError("ttl_seconds must be > 0")
        if sweep_interval_seconds <= 0:
            raise ValueError("sweep_interval_seconds must be > 0")

        self._ttl = timedelta(seconds=ttl_seconds)
        self._sweep_interval_seconds = sweep_interval_seconds
        self._store: dict[str, SessionEntry] = {}
        self._lock = RLock()
        self._sweep_task: asyncio.Task[None] | None = None

    def add(
        self,
        *,
        player_id: str | None,
        game_name: str,
        manager: SessionManager,
        experiment_name: str | None = None,
        assignment_id: str | None = None,
    ) -> SessionEntry:
        """Create and store a new session entry."""
        session_id = str(uuid4())
        entry = SessionEntry(
            session_id=session_id,
            player_id=player_id,
            game_name=game_name,
            manager=manager,
            experiment_name=experiment_name,
            assignment_id=assignment_id,
        )
        with self._lock:
            self._store[session_id] = entry
        logger.info("Session %s created (%d active)", session_id, self.size)
        return entry

    def get(self, session_id: str) -> SessionEntry | None:
        """Get a session entry by id, or None if it does not exist."""
        with self._lock:
            return self._store.get(session_id)

    def list_for_player(self, player_id: str) -> list[SessionEntry]:
        """List sessions owned by a specific player, newest first."""
        with self._lock:
            entries = [entry for entry in self._store.values() if entry.player_id == player_id]
        return sorted(entries, key=lambda item: item.created_at, reverse=True)

    def touch(self, session_id: str) -> None:
        """Refresh a session's idle timer if present."""
        with self._lock:
            entry = self._store.get(session_id)
            if entry is not None:
                entry.touch()

    def mark_opening_sent(self, session_id: str) -> None:
        """Mark that the opening turn has already been sent."""
        with self._lock:
            entry = self._store.get(session_id)
            if entry is not None:
                entry.opening_sent = True

    def pause(self, session_id: str) -> None:
        """Mark a session as paused; keep it and its manager alive for resume."""
        with self._lock:
            entry = self._store.get(session_id)
            if entry is not None:
                entry.status = "paused"
                entry.ws_connected = False
                entry.touch()

    def set_active(self, session_id: str) -> None:
        """Mark a paused session as active again after a successful reconnect."""
        with self._lock:
            entry = self._store.get(session_id)
            if entry is not None:
                entry.status = "active"
                entry.ws_connected = True
                entry.touch()

    def set_ws_connected(self, session_id: str, connected: bool) -> None:
        """Update the WebSocket connection flag for a session."""
        with self._lock:
            entry = self._store.get(session_id)
            if entry is not None:
                entry.ws_connected = connected

    def close(self, session_id: str) -> None:
        """Mark a session as closed but keep it until explicit removal/TTL expiry."""
        with self._lock:
            entry = self._store.get(session_id)
            if entry is not None:
                entry.status = "closed"
                entry.ws_connected = False
                entry.touch()

    def remove(self, session_id: str) -> SessionEntry | None:
        """Remove and return a session entry if it exists."""
        with self._lock:
            entry = self._store.pop(session_id, None)
        if entry is not None:
            logger.info("Session %s removed (%d remaining)", session_id, self.size)
        return entry

    @property
    def size(self) -> int:
        """Current number of live session entries."""
        with self._lock:
            return len(self._store)

    async def sweep_async(self) -> list[str]:
        """Async sweep variant that awaits async session finalization when available."""
        cutoff = datetime.now(timezone.utc) - self._ttl
        with self._lock:
            stale_ids = [sid for sid, entry in self._store.items() if entry.last_active < cutoff]
            stale_entries = [(sid, self._store.pop(sid)) for sid in stale_ids]

        for session_id, entry in stale_entries:
            try:
                if not entry.manager.exited:
                    await entry.manager.exit_async("session ttl expired")
            except Exception:
                logger.exception("Failed to exit stale session cleanly: %s", session_id)

        if stale_ids:
            logger.warning("Swept %d stale session(s)", len(stale_ids))
        return stale_ids

    async def start(self) -> None:
        """Start background TTL sweeping if it is not already running."""
        if self._sweep_task is not None:
            return
        self._sweep_task = asyncio.create_task(self._sweep_loop())

    async def stop(self) -> None:
        """Stop the background TTL sweeper task."""
        if self._sweep_task is None:
            return
        self._sweep_task.cancel()
        with suppress(asyncio.CancelledError):
            await self._sweep_task
        self._sweep_task = None

    async def _sweep_loop(self) -> None:
        """Run periodic sweep ticks until cancelled."""
        while True:
            await asyncio.sleep(self._sweep_interval_seconds)
            await self.sweep_async()
size property

Current number of live session entries.

__init__(*, ttl_seconds=3600, sweep_interval_seconds=60)

Initialize registry settings and empty storage.

Source code in dcs_simulation_engine/api/registry.py
41
42
43
44
45
46
47
48
49
50
51
52
def __init__(self, *, ttl_seconds: int = 3600, sweep_interval_seconds: int = 60) -> None:
    """Initialize registry settings and empty storage."""
    if ttl_seconds <= 0:
        raise ValueError("ttl_seconds must be > 0")
    if sweep_interval_seconds <= 0:
        raise ValueError("sweep_interval_seconds must be > 0")

    self._ttl = timedelta(seconds=ttl_seconds)
    self._sweep_interval_seconds = sweep_interval_seconds
    self._store: dict[str, SessionEntry] = {}
    self._lock = RLock()
    self._sweep_task: asyncio.Task[None] | None = None
add(*, player_id, game_name, manager, experiment_name=None, assignment_id=None)

Create and store a new session entry.

Source code in dcs_simulation_engine/api/registry.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
def add(
    self,
    *,
    player_id: str | None,
    game_name: str,
    manager: SessionManager,
    experiment_name: str | None = None,
    assignment_id: str | None = None,
) -> SessionEntry:
    """Create and store a new session entry."""
    session_id = str(uuid4())
    entry = SessionEntry(
        session_id=session_id,
        player_id=player_id,
        game_name=game_name,
        manager=manager,
        experiment_name=experiment_name,
        assignment_id=assignment_id,
    )
    with self._lock:
        self._store[session_id] = entry
    logger.info("Session %s created (%d active)", session_id, self.size)
    return entry
close(session_id)

Mark a session as closed but keep it until explicit removal/TTL expiry.

Source code in dcs_simulation_engine/api/registry.py
128
129
130
131
132
133
134
135
def close(self, session_id: str) -> None:
    """Mark a session as closed but keep it until explicit removal/TTL expiry."""
    with self._lock:
        entry = self._store.get(session_id)
        if entry is not None:
            entry.status = "closed"
            entry.ws_connected = False
            entry.touch()
get(session_id)

Get a session entry by id, or None if it does not exist.

Source code in dcs_simulation_engine/api/registry.py
78
79
80
81
def get(self, session_id: str) -> SessionEntry | None:
    """Get a session entry by id, or None if it does not exist."""
    with self._lock:
        return self._store.get(session_id)
list_for_player(player_id)

List sessions owned by a specific player, newest first.

Source code in dcs_simulation_engine/api/registry.py
83
84
85
86
87
def list_for_player(self, player_id: str) -> list[SessionEntry]:
    """List sessions owned by a specific player, newest first."""
    with self._lock:
        entries = [entry for entry in self._store.values() if entry.player_id == player_id]
    return sorted(entries, key=lambda item: item.created_at, reverse=True)
mark_opening_sent(session_id)

Mark that the opening turn has already been sent.

Source code in dcs_simulation_engine/api/registry.py
 96
 97
 98
 99
100
101
def mark_opening_sent(self, session_id: str) -> None:
    """Mark that the opening turn has already been sent."""
    with self._lock:
        entry = self._store.get(session_id)
        if entry is not None:
            entry.opening_sent = True
pause(session_id)

Mark a session as paused; keep it and its manager alive for resume.

Source code in dcs_simulation_engine/api/registry.py
103
104
105
106
107
108
109
110
def pause(self, session_id: str) -> None:
    """Mark a session as paused; keep it and its manager alive for resume."""
    with self._lock:
        entry = self._store.get(session_id)
        if entry is not None:
            entry.status = "paused"
            entry.ws_connected = False
            entry.touch()
remove(session_id)

Remove and return a session entry if it exists.

Source code in dcs_simulation_engine/api/registry.py
137
138
139
140
141
142
143
def remove(self, session_id: str) -> SessionEntry | None:
    """Remove and return a session entry if it exists."""
    with self._lock:
        entry = self._store.pop(session_id, None)
    if entry is not None:
        logger.info("Session %s removed (%d remaining)", session_id, self.size)
    return entry
set_active(session_id)

Mark a paused session as active again after a successful reconnect.

Source code in dcs_simulation_engine/api/registry.py
112
113
114
115
116
117
118
119
def set_active(self, session_id: str) -> None:
    """Mark a paused session as active again after a successful reconnect."""
    with self._lock:
        entry = self._store.get(session_id)
        if entry is not None:
            entry.status = "active"
            entry.ws_connected = True
            entry.touch()
set_ws_connected(session_id, connected)

Update the WebSocket connection flag for a session.

Source code in dcs_simulation_engine/api/registry.py
121
122
123
124
125
126
def set_ws_connected(self, session_id: str, connected: bool) -> None:
    """Update the WebSocket connection flag for a session."""
    with self._lock:
        entry = self._store.get(session_id)
        if entry is not None:
            entry.ws_connected = connected
start() async

Start background TTL sweeping if it is not already running.

Source code in dcs_simulation_engine/api/registry.py
169
170
171
172
173
async def start(self) -> None:
    """Start background TTL sweeping if it is not already running."""
    if self._sweep_task is not None:
        return
    self._sweep_task = asyncio.create_task(self._sweep_loop())
stop() async

Stop the background TTL sweeper task.

Source code in dcs_simulation_engine/api/registry.py
175
176
177
178
179
180
181
182
async def stop(self) -> None:
    """Stop the background TTL sweeper task."""
    if self._sweep_task is None:
        return
    self._sweep_task.cancel()
    with suppress(asyncio.CancelledError):
        await self._sweep_task
    self._sweep_task = None
sweep_async() async

Async sweep variant that awaits async session finalization when available.

Source code in dcs_simulation_engine/api/registry.py
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
async def sweep_async(self) -> list[str]:
    """Async sweep variant that awaits async session finalization when available."""
    cutoff = datetime.now(timezone.utc) - self._ttl
    with self._lock:
        stale_ids = [sid for sid, entry in self._store.items() if entry.last_active < cutoff]
        stale_entries = [(sid, self._store.pop(sid)) for sid in stale_ids]

    for session_id, entry in stale_entries:
        try:
            if not entry.manager.exited:
                await entry.manager.exit_async("session ttl expired")
        except Exception:
            logger.exception("Failed to exit stale session cleanly: %s", session_id)

    if stale_ids:
        logger.warning("Swept %d stale session(s)", len(stale_ids))
    return stale_ids
touch(session_id)

Refresh a session's idle timer if present.

Source code in dcs_simulation_engine/api/registry.py
89
90
91
92
93
94
def touch(self, session_id: str) -> None:
    """Refresh a session's idle timer if present."""
    with self._lock:
        entry = self._store.get(session_id)
        if entry is not None:
            entry.touch()

routers

API router exports.

catalog

HTTP endpoints for listing, creating, updating, and deleting games and characters.

create_character(body, request) async

Create a new character.

Source code in dcs_simulation_engine/api/routers/catalog.py
35
36
37
38
39
40
@router.post("/characters", response_model=UpsertCharacterResponse, status_code=status.HTTP_201_CREATED)
async def create_character(body: UpsertCharacterRequest, request: Request) -> UpsertCharacterResponse:
    """Create a new character."""
    provider = get_provider_from_request(request)
    character_id = await maybe_await(provider.upsert_character(body.data, character_id=body.character_id))
    return UpsertCharacterResponse(character_id=character_id)
delete_character(character_id, request) async

Delete a character by id.

Source code in dcs_simulation_engine/api/routers/catalog.py
55
56
57
58
59
60
61
62
63
@router.delete("/characters/{character_id}", response_model=DeleteCharacterResponse)
async def delete_character(character_id: str, request: Request) -> DeleteCharacterResponse:
    """Delete a character by id."""
    provider = get_provider_from_request(request)
    try:
        await maybe_await(provider.delete_character(character_id))
    except Exception as e:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) from e
    return DeleteCharacterResponse(character_id=character_id)
list_characters_endpoint(request) async

List available characters.

Source code in dcs_simulation_engine/api/routers/catalog.py
26
27
28
29
30
31
32
@router.get("/characters/list", response_model=CharactersListResponse)
async def list_characters_endpoint(request: Request) -> CharactersListResponse:
    """List available characters."""
    provider = get_provider_from_request(request)
    records = await maybe_await(provider.list_characters())
    characters = [CharacterSummary(hid=c.hid, short_description=c.short_description) for c in records]
    return CharactersListResponse(characters=characters)
list_games_endpoint()

List available games.

Source code in dcs_simulation_engine/api/routers/catalog.py
19
20
21
22
23
@router.get("/games/list", response_model=GamesListResponse)
def list_games_endpoint() -> GamesListResponse:
    """List available games."""
    games = [GameSummary(name=name, author=author, description=description) for name, author, _path, _version, description in list_games()]
    return GamesListResponse(games=games)
update_character(character_id, body, request) async

Update an existing character.

Source code in dcs_simulation_engine/api/routers/catalog.py
43
44
45
46
47
48
49
50
51
52
@router.put("/characters/{character_id}", response_model=UpsertCharacterResponse)
async def update_character(
    character_id: str,
    body: UpsertCharacterRequest,
    request: Request,
) -> UpsertCharacterResponse:
    """Update an existing character."""
    provider = get_provider_from_request(request)
    updated_id = await maybe_await(provider.upsert_character(body.data, character_id=character_id))
    return UpsertCharacterResponse(character_id=updated_id)
experiments

Experiment-scoped endpoints for assignment-driven study flows.

create_experiment_session(experiment_name, body, request) async

Create a session for the authenticated player's current experiment assignment.

Source code in dcs_simulation_engine/api/routers/experiments.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
@router.post("/{experiment_name}/sessions", response_model=CreateGameResponse)
async def create_experiment_session(
    experiment_name: str,
    body: ExperimentSessionRequest,
    request: Request,
) -> CreateGameResponse:
    """Create a session for the authenticated player's current experiment assignment."""
    require_standard_mode_from_request(
        request,
        detail="Experiment endpoints are disabled when the server is running in free play mode.",
    )
    _require_allowed_experiment(experiment_name=experiment_name, request=request)
    provider = get_provider_from_request(request)
    registry = get_registry_from_request(request)
    player = await require_player_async(provider=provider, api_key=api_key_from_request(request))

    try:
        entry, _assignment = await ExperimentManager.start_assignment_session_async(
            provider=provider,
            registry=registry,
            experiment_name=experiment_name,
            player=player,
            source=body.source,
        )
    except PermissionError as exc:
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
    except ValueError as exc:
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc

    return CreateGameResponse(
        session_id=entry.session_id,
        status="active",
        ws_path=f"/api/play/game/{entry.session_id}/ws",
    )
experiment_progress(experiment_name, request) async

Return the current finite progress for the usability experiment.

Source code in dcs_simulation_engine/api/routers/experiments.py
212
213
214
215
216
217
218
219
220
221
222
223
224
@router.get("/{experiment_name}/progress", response_model=ExperimentProgressResponse)
async def experiment_progress(experiment_name: str, request: Request) -> ExperimentProgressResponse:
    """Return the current finite progress for the usability experiment."""
    require_standard_mode_from_request(
        request,
        detail="Experiment endpoints are disabled when the server is running in free play mode.",
    )
    _require_allowed_experiment(experiment_name=experiment_name, request=request)
    provider = get_provider_from_request(request)
    await require_player_async(provider=provider, api_key=api_key_from_request(request))
    progress = await ExperimentManager.compute_progress_async(provider=provider, experiment_name=experiment_name)
    await ExperimentManager.ensure_experiment_async(provider=provider, experiment_name=experiment_name)
    return _progress_response(progress)
experiment_setup(experiment_name, request) async

Return experiment metadata, form schemas, and current player assignment state.

Source code in dcs_simulation_engine/api/routers/experiments.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
@router.get("/{experiment_name}/setup", response_model=ExperimentSetupResponse)
async def experiment_setup(experiment_name: str, request: Request) -> ExperimentSetupResponse:
    """Return experiment metadata, form schemas, and current player assignment state."""
    require_standard_mode_from_request(
        request,
        detail="Experiment endpoints are disabled when the server is running in free play mode.",
    )
    _require_allowed_experiment(experiment_name=experiment_name, request=request)
    provider = get_provider_from_request(request)
    config = ExperimentManager.get_experiment_config_cached(experiment_name)
    await ExperimentManager.ensure_experiment_async(provider=provider, experiment_name=config.name)
    player = await require_player_async(provider=provider, api_key=api_key_from_request(request))
    current_assignment = None
    pending_post_play = False
    assignment_completed = False
    player_state = await ExperimentManager.get_player_state_async(
        provider=provider,
        experiment_name=config.name,
        player_id=player.id,
    )
    current_assignment = player_state["active_assignment"]
    pending_post_play = player_state["pending_post_play"] is not None
    assignment_completed = bool(player_state["has_finished_experiment"])

    progress = await ExperimentManager.compute_progress_async(provider=provider, experiment_name=config.name)
    return ExperimentSetupResponse(
        experiment_name=config.name,
        description=config.description,
        is_open=not progress["is_complete"],
        forms=[form.model_dump(mode="json") for form in config.forms],
        progress=_progress_response(progress),
        current_assignment=_assignment_summary(current_assignment),
        pending_post_play=pending_post_play,
        assignment_completed=assignment_completed,
        assignment_mode=config.assignment_strategy.assignment_mode,
        assignments=[_assignment_summary(a) for a in player_state.get("assignments", [])],
    )
experiment_status(experiment_name, request) async

Return the current aggregate status for one experiment.

Source code in dcs_simulation_engine/api/routers/experiments.py
274
275
276
277
278
279
280
281
282
283
284
285
286
@router.get("/{experiment_name}/status", response_model=ExperimentStatusResponse)
async def experiment_status(experiment_name: str, request: Request) -> ExperimentStatusResponse:
    """Return the current aggregate status for one experiment."""
    require_standard_mode_from_request(
        request,
        detail="Experiment endpoints are disabled when the server is running in free play mode.",
    )
    _require_allowed_experiment(experiment_name=experiment_name, request=request)
    provider = get_provider_from_request(request)
    await require_player_async(provider=provider, api_key=api_key_from_request(request))
    await ExperimentManager.ensure_experiment_async(provider=provider, experiment_name=experiment_name)
    status_payload = await ExperimentManager.compute_status_async(provider=provider, experiment_name=experiment_name)
    return _status_response(status_payload)
get_eligible_options(experiment_name, request) async

Return eligible game/character pairs for the authenticated player in player_choice mode.

Source code in dcs_simulation_engine/api/routers/experiments.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
@router.get("/{experiment_name}/eligible-options", response_model=EligibleAssignmentOptionsResponse)
async def get_eligible_options(experiment_name: str, request: Request) -> EligibleAssignmentOptionsResponse:
    """Return eligible game/character pairs for the authenticated player in player_choice mode."""
    require_standard_mode_from_request(
        request,
        detail="Experiment endpoints are disabled when the server is running in free play mode.",
    )
    _require_allowed_experiment(experiment_name=experiment_name, request=request)
    provider = get_provider_from_request(request)
    player = await require_player_async(provider=provider, api_key=api_key_from_request(request))
    options = await ExperimentManager.get_eligible_options_async(
        provider=provider,
        experiment_name=experiment_name,
        player=player,
    )
    return EligibleAssignmentOptionsResponse(
        options=[EligibleAssignmentOption(game_name=opt["game_name"], character_hid=opt["character_hid"]) for opt in options]
    )
register_experiment_player(experiment_name, body, request) async

Store before-play experiment forms for the authenticated participant and generate an assignment.

Source code in dcs_simulation_engine/api/routers/experiments.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
@router.post("/{experiment_name}/players", response_model=ExperimentPlayerResponse)
async def register_experiment_player(
    experiment_name: str,
    body: ExperimentPlayerRequest,
    request: Request,
) -> ExperimentPlayerResponse:
    """Store before-play experiment forms for the authenticated participant and generate an assignment."""
    require_standard_mode_from_request(
        request,
        detail="Experiment endpoints are disabled when the server is running in free play mode.",
    )
    _require_allowed_experiment(experiment_name=experiment_name, request=request)
    provider = get_provider_from_request(request)
    player = await require_player_async(provider=provider, api_key=api_key_from_request(request))
    try:
        assignment = await ExperimentManager.submit_before_play_async(
            provider=provider,
            experiment_name=experiment_name,
            player_id=player.id,
            responses=body.responses,
        )
    except ValueError as exc:
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc

    return ExperimentPlayerResponse(assignment=_assignment_summary(assignment))
select_assignment(experiment_name, body, request) async

Create an assignment for the authenticated player based on their explicit game/character selection.

Source code in dcs_simulation_engine/api/routers/experiments.py
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
@router.post("/{experiment_name}/assignments/select", response_model=ExperimentAssignmentSummary)
async def select_assignment(
    experiment_name: str,
    body: SelectAssignmentRequest,
    request: Request,
) -> ExperimentAssignmentSummary:
    """Create an assignment for the authenticated player based on their explicit game/character selection."""
    require_standard_mode_from_request(
        request,
        detail="Experiment endpoints are disabled when the server is running in free play mode.",
    )
    _require_allowed_experiment(experiment_name=experiment_name, request=request)
    provider = get_provider_from_request(request)
    player = await require_player_async(provider=provider, api_key=api_key_from_request(request))
    try:
        assignment = await ExperimentManager.create_player_choice_assignment_async(
            provider=provider,
            experiment_name=experiment_name,
            player=player,
            game_name=body.game_name,
            character_hid=body.character_hid,
        )
    except ValueError as exc:
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
    return _assignment_summary(assignment)
submit_experiment_post_play(experiment_name, body, request) async

Store the experiment post-play form on the latest completed assignment.

Source code in dcs_simulation_engine/api/routers/experiments.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
@router.post("/{experiment_name}/post-play", response_model=ExperimentAssignmentSummary)
async def submit_experiment_post_play(
    experiment_name: str,
    body: ExperimentPostPlayRequest,
    request: Request,
) -> ExperimentAssignmentSummary:
    """Store the experiment post-play form on the latest completed assignment."""
    require_standard_mode_from_request(
        request,
        detail="Experiment endpoints are disabled when the server is running in free play mode.",
    )
    _require_allowed_experiment(experiment_name=experiment_name, request=request)
    provider = get_provider_from_request(request)
    player = await require_player_async(provider=provider, api_key=api_key_from_request(request))

    try:
        assignment = await ExperimentManager.store_post_play_async(
            provider=provider,
            experiment_name=experiment_name,
            player_id=player.id,
            responses=body.responses,
        )
    except ValueError as exc:
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc

    return _assignment_summary(assignment)
play

Gameplay session creation and WebSocket interaction endpoints.

create_game(body, request) async

Create a session-owned game instance and return websocket connect info.

Source code in dcs_simulation_engine/api/routers/play.py
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
@router.post("/game", response_model=CreateGameResponse)
async def create_game(body: CreateGameRequest, request: Request) -> CreateGameResponse:
    """Create a session-owned game instance and return websocket connect info."""
    _require_generic_play_enabled(request)
    provider = get_provider_from_request(request)
    registry = get_registry_from_request(request)
    server_mode = get_server_mode_from_request(request)
    player_id: str | None = None
    if server_mode == "standard":
        player = await require_player_async(provider=provider, api_key=body.api_key)
        player_id = player.id
        await _reject_if_experiment_gated(provider=provider, player_id=player.id)

    try:
        manager = await SessionManager.create_async(
            game=body.game,
            provider=provider,
            source=body.source,
            pc_choice=body.pc_choice,
            npc_choice=body.npc_choice,
            player_id=player_id,
        )
    except PermissionError as exc:
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
    except ValueError as exc:
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
    except Exception as exc:
        logger.exception("Failed to create session manager")
        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc

    entry = registry.add(player_id=player_id, game_name=manager.game_config.name, manager=manager)
    start_hook = getattr(manager, "start_persistence", None)
    if start_hook is not None:
        await maybe_await(start_hook(session_id=entry.session_id))

    return CreateGameResponse(
        session_id=entry.session_id,
        status="active",
        ws_path=f"/api/play/game/{entry.session_id}/ws",
    )
play_ws(websocket, session_id) async

WebSocket endpoint for game play requests and streamed turn events.

Source code in dcs_simulation_engine/api/routers/play.py
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
@router.websocket("/game/{session_id}/ws")
async def play_ws(websocket: WebSocket, session_id: str) -> None:
    """WebSocket endpoint for game play requests and streamed turn events."""
    await websocket.accept()

    provider = get_provider_from_websocket(websocket)
    registry = get_registry_from_websocket(websocket)
    server_mode = get_server_mode_from_websocket(websocket)

    try:
        entry = registry.get(session_id)
        if entry is None:
            await _send_error(websocket, f"Session {session_id} not found")
            await websocket.close()
            return

        # Reject a second WebSocket if the session already has an active connection.
        if entry.ws_connected:
            await _send_error(websocket, "Session already connected")
            await websocket.close()
            return

        if server_mode == "standard":
            # Try header-based auth first (Python client), then first-message auth (browser).
            api_key = api_key_from_websocket(websocket)
            if api_key is None:
                raw = await websocket.receive_text()
                auth_frame = parse_ws_auth(raw)
                if auth_frame is not None:
                    api_key = auth_frame.api_key

            player = await require_player_async(provider=provider, api_key=api_key)
            if entry.player_id != player.id:
                await _send_error(websocket, "Unauthorized for this session")
                await websocket.close()
                return

        is_resume = entry.status == "paused"
        if is_resume:
            registry.set_active(session_id)
            await maybe_await(provider.resume_session(session_id=session_id, resumed_at=datetime.now(timezone.utc)))

        # Send session metadata (pc/npc) immediately after auth, before any events.
        game = entry.manager.game
        pc = getattr(game, "_pc", None)
        npc = getattr(game, "_npc", None)
        game_config = SessionManager.get_game_config_cached(entry.game_name)
        has_game_feedback = any(f.before_or_after == "after" for f in game_config.forms)
        meta_frame = WSSessionMetaFrame(
            session_id=session_id,
            pc_hid=getattr(pc, "hid", None),
            npc_hid=getattr(npc, "hid", None),
            has_game_feedback=has_game_feedback,
        )
        await websocket.send_json(meta_frame.model_dump(mode="json"))
        registry.set_ws_connected(session_id, True)

        if is_resume:
            # Replay persisted history so the client can restore the chat view.
            await _send_replay(websocket, session_id, provider, turns=entry.manager.turns)

        if not entry.opening_sent and entry.status != "closed":
            opening_events = await entry.manager.step_async(None)
            registry.mark_opening_sent(session_id)
            registry.touch(session_id)
            if entry.manager.exited:
                registry.close(session_id)
                await _sync_experiment_assignment_if_needed(provider=provider, entry=entry)

            await _send_events(websocket, session_id, opening_events)
            await _send_turn_end(
                websocket,
                session_id,
                turns=entry.manager.turns,
                exited=entry.manager.exited,
            )

        while True:
            raw_message = await websocket.receive_text()
            if parse_ws_auth(raw_message) is not None:
                continue
            try:
                req = parse_ws_request(raw_message)
            except ValueError as exc:
                await _send_error(websocket, str(exc))
                continue

            if isinstance(req, WSAdvanceRequest):
                if _session_status(entry.status, entry.manager.exited) == "closed":
                    await _send_error(websocket, "Session is closed")
                    continue

                events = await entry.manager.step_async(req.text)
                registry.touch(session_id)
                if entry.manager.exited:
                    registry.close(session_id)
                    await _sync_experiment_assignment_if_needed(provider=provider, entry=entry)

                await _send_events(websocket, session_id, events)
                await _send_turn_end(
                    websocket,
                    session_id,
                    turns=entry.manager.turns,
                    exited=entry.manager.exited,
                )
                continue

            if isinstance(req, WSStatusRequest):
                status_value = _session_status(entry.status, entry.manager.exited)
                await _send_status(
                    websocket,
                    session_id,
                    status_value=status_value,
                    turns=entry.manager.turns,
                    exited=entry.manager.exited,
                )
                continue

            if isinstance(req, WSCloseRequest):
                if not entry.manager.exited:
                    if entry.assignment_id is not None:
                        await _finalize_exit_with_retry(
                            manager=entry.manager,
                            reason="received close request",
                            session_id=session_id,
                        )
                    else:
                        _spawn_background_finalize(
                            manager=entry.manager,
                            reason="received close request",
                            session_id=session_id,
                        )
                registry.set_ws_connected(session_id, False)
                registry.close(session_id)
                await _sync_experiment_assignment_if_needed(provider=provider, entry=entry)
                await websocket.send_json({"type": "closed", "session_id": session_id})
                await websocket.close()
                return

    except WebSocketDisconnect as exc:
        entry = registry.get(session_id)
        if entry is not None:
            registry.set_ws_connected(session_id, False)
            if entry.manager.exited:
                # Game finished naturally before the disconnect — finalize as usual.
                pass
            else:
                # Game still in progress — pause so the player can resume later.
                try:
                    registry.pause(session_id)
                    await maybe_await(provider.pause_session(session_id=session_id, paused_at=datetime.now(timezone.utc)))
                except Exception:
                    logger.exception("Failed to pause session after websocket disconnect: {}", session_id)
        logger.info(
            "WebSocket disconnected for session {} (code={}, reason={})",
            session_id,
            exc.code,
            exc.reason,
        )

    except Exception:
        entry = registry.get(session_id)
        if entry is not None:
            registry.set_ws_connected(session_id, False)
            if not entry.manager.exited:
                try:
                    await _finalize_exit_with_retry(
                        manager=entry.manager,
                        reason="server_error",
                        session_id=session_id,
                    )
                    registry.close(session_id)
                    await _sync_experiment_assignment_if_needed(provider=provider, entry=entry)
                except Exception:
                    logger.exception("Failed to finalize session after internal websocket error: {}", session_id)
        logger.exception("Unhandled websocket error for session {}", session_id)
        try:
            await _send_error(websocket, "Internal server error")
            await websocket.close()
        except Exception:
            logger.debug("WebSocket already closed while sending internal error frame")
setup_options(game_name, request) async

Return setup-ready authorization and valid character choices for a game.

Source code in dcs_simulation_engine/api/routers/play.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
@router.get("/setup/{game_name}", response_model=GameSetupOptionsResponse)
async def setup_options(game_name: str, request: Request) -> GameSetupOptionsResponse:
    """Return setup-ready authorization and valid character choices for a game."""
    _require_generic_play_enabled(request)
    provider = get_provider_from_request(request)
    server_mode = get_server_mode_from_request(request)
    player_id: str | None = None
    if server_mode == "standard":
        player = await require_player_async(provider=provider, api_key=api_key_from_request(request))
        player_id = player.id
        await _reject_if_experiment_gated(provider=provider, player_id=player.id)

    try:
        game_config = SessionManager.get_game_config_cached(game_name)
    except FileNotFoundError as exc:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
    except Exception as exc:
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc

    get_valid = getattr(game_config, "get_valid_characters_async", None)
    if get_valid is None:
        valid_pcs, valid_npcs = await maybe_await(game_config.get_valid_characters(player_id=player_id, provider=provider))
    else:
        valid_pcs, valid_npcs = await maybe_await(get_valid(player_id=player_id, provider=provider))
    pcs = [CharacterChoice(hid=hid, label=label) for label, hid in valid_pcs]
    npcs = [CharacterChoice(hid=hid, label=label) for label, hid in valid_npcs]

    if not pcs:
        return GameSetupOptionsResponse(
            game=game_config.name,
            allowed=True,
            can_start=False,
            denial_reason="no_valid_pc",
            message=("No valid player characters are available for your account for this game."),
            pcs=pcs,
            npcs=npcs,
        )
    if not npcs:
        return GameSetupOptionsResponse(
            game=game_config.name,
            allowed=True,
            can_start=False,
            denial_reason="no_valid_npc",
            message=("No valid non-player characters are available for your account for this game."),
            pcs=pcs,
            npcs=npcs,
        )

    return GameSetupOptionsResponse(
        game=game_config.name,
        allowed=True,
        can_start=True,
        denial_reason=None,
        message=None,
        pcs=pcs,
        npcs=npcs,
    )
remote

Remote deployment bootstrap, status, and export endpoints.

bootstrap_remote_deployment(request) async

Seed the uploaded database snapshot and provision the remote admin access key.

Source code in dcs_simulation_engine/api/routers/remote.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
@router.post("/bootstrap", response_model=RemoteBootstrapResponse)
async def bootstrap_remote_deployment(request: Request) -> RemoteBootstrapResponse:
    """Seed the uploaded database snapshot and provision the remote admin access key."""
    require_remote_management_from_request(
        request,
        detail="Remote bootstrap is unavailable when the server is not remote-managed.",
    )
    _require_bootstrap_token(request)
    provider = get_provider_from_request(request)
    requested_admin_key = _requested_admin_key(request)

    if await has_remote_admin_async(provider=provider):
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="Remote deployment has already been bootstrapped.",
        )

    mongo_uri = getattr(request.app.state, "mongo_uri", None)
    if not mongo_uri:
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail="Mongo URI is unavailable for remote bootstrap.",
        )

    filename = Path(request.headers.get("x-dcs-mongo-seed-filename") or "").name
    if not filename:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Missing X-DCS-Mongo-Seed-Filename header.",
        )

    temp_root = Path(tempfile.mkdtemp(prefix="dcs-remote-bootstrap-"))
    try:
        upload_path = temp_root / filename
        await _write_bootstrap_payload(request, upload_path)
        try:
            seed_dir = await asyncio.to_thread(_materialize_uploaded_seed, upload_path, temp_root)
        except ValueError as exc:
            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc

        await asyncio.to_thread(_seed_remote_database, mongo_uri=mongo_uri, seed_dir=seed_dir)
    finally:
        shutil.rmtree(temp_root, ignore_errors=True)

    record, api_key = await maybe_await(
        provider.create_player(
            player_data={
                "display_name": "Remote Admin",
                "role": REMOTE_ADMIN_ROLE,
            },
            issue_access_key=requested_admin_key is None,
            access_key=requested_admin_key,
        )
    )
    if api_key is None:
        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to issue admin key")

    experiment_name = get_default_experiment_name_from_request(request)
    if experiment_name:
        await ExperimentManager.ensure_experiment_async(provider=provider, experiment_name=experiment_name)

    return RemoteBootstrapResponse(
        player_id=record.id,
        admin_api_key=api_key,
        experiment_name=experiment_name,
    )
export_remote_database(request, format='tar.gz') async

Stream an archive of the current database state to the remote admin.

Source code in dcs_simulation_engine/api/routers/remote.py
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
@router.get("/db-export")
async def export_remote_database(
    request: Request,
    format: Literal["tar.gz", "zip"] = "tar.gz",
) -> FileResponse:
    """Stream an archive of the current database state to the remote admin."""
    require_remote_management_from_request(
        request,
        detail="Remote database export is unavailable when the server is not remote-managed.",
    )
    provider = get_provider_from_request(request)
    await require_remote_admin_async(provider=provider, api_key=api_key_from_request(request))

    temp_root = Path(tempfile.mkdtemp(prefix="dcs-remote-export-"))
    dump_root = await dump_all_collections_to_json_async(provider.get_db(), temp_root)
    archive_suffix = ".zip" if format == "zip" else ".tar.gz"
    archive_path = temp_root / f"{dump_root.name}{archive_suffix}"
    await asyncio.to_thread(_archive_dump_dir, dump_root, archive_path, format)

    experiment_name = get_default_experiment_name_from_request(request) or "dcs-db"
    filename = f"{experiment_name}-{utc_now().strftime('%Y%m%d-%H%M%S')}{archive_suffix}"
    return FileResponse(
        archive_path,
        media_type="application/zip" if format == "zip" else "application/gzip",
        filename=filename,
        background=BackgroundTask(shutil.rmtree, temp_root, ignore_errors=True),
    )
remote_status(request) async

Return a public status summary for remote-managed experiment deployments.

Source code in dcs_simulation_engine/api/routers/remote.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
@router.get("/status", response_model=RemoteStatusResponse)
async def remote_status(request: Request) -> RemoteStatusResponse:
    """Return a public status summary for remote-managed experiment deployments."""
    started_at = request.app.state.started_at
    uptime = int((utc_now() - started_at).total_seconds())
    default_experiment_name = get_default_experiment_name_from_request(request)
    provider = get_provider_from_request(request)

    progress = None
    experiment_status = None
    if default_experiment_name:
        try:
            await ExperimentManager.ensure_experiment_async(provider=provider, experiment_name=default_experiment_name)
            progress = _progress_response(
                await ExperimentManager.compute_progress_async(
                    provider=provider,
                    experiment_name=default_experiment_name,
                )
            )
            experiment_status = _status_response(
                await ExperimentManager.compute_status_async(provider=provider, experiment_name=default_experiment_name)
            )
        except Exception as exc:
            logger.exception("Failed to compute remote status for {}", default_experiment_name)
            raise HTTPException(
                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                detail=f"Failed to compute remote status: {exc}",
            ) from exc

    return RemoteStatusResponse(
        mode=resolve_remote_deployment_mode(
            server_mode=get_server_mode_from_request(request),
            default_experiment_name=default_experiment_name,
        ),
        started_at=started_at,
        uptime=max(uptime, 0),
        experiment_name=default_experiment_name,
        progress=progress,
        experiment_status=experiment_status,
    )
sessions

HTTP endpoints for listing in-memory API sessions.

clear_session_event_feedback(session_id, event_id, request) async

Remove feedback from one persisted NPC-message event.

Source code in dcs_simulation_engine/api/routers/sessions.py
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
@router.delete(
    "/{session_id}/events/{event_id}/feedback",
    response_model=ClearSessionEventFeedbackResponse,
)
async def clear_session_event_feedback(
    session_id: str,
    event_id: str,
    request: Request,
) -> ClearSessionEventFeedbackResponse:
    """Remove feedback from one persisted NPC-message event."""
    provider = get_provider_from_request(request)
    player_id = await _resolve_session_player_id(request=request, session_id=session_id)

    await _flush_live_session_feedback_target(request=request, session_id=session_id, player_id=player_id)

    clearer = getattr(provider, "clear_session_event_feedback", None)
    if clearer is None:
        raise HTTPException(
            status_code=status.HTTP_501_NOT_IMPLEMENTED,
            detail="Session event feedback clearing is unavailable for this provider.",
        )

    cleared = await maybe_await(
        clearer(
            session_id=session_id,
            player_id=player_id,
            event_id=event_id,
        )
    )
    if not cleared:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NPC message not found")

    return ClearSessionEventFeedbackResponse(session_id=session_id, event_id=event_id, cleared=True)
get_session_reconstruction(session_id, request) async

Return complete persisted metadata + event stream for transcript replay.

Source code in dcs_simulation_engine/api/routers/sessions.py
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
@router.get("/{session_id}/reconstruction")
async def get_session_reconstruction(session_id: str, request: Request) -> dict:
    """Return complete persisted metadata + event stream for transcript replay."""
    require_standard_mode_from_request(
        request,
        detail="Session endpoints are disabled when the server is running in free play mode.",
    )
    provider = get_provider_from_request(request)
    player = await require_player_async(provider=provider, api_key=api_key_from_request(request))

    loader = getattr(provider, "get_session_reconstruction", None)
    if loader is None:
        raise HTTPException(
            status_code=status.HTTP_501_NOT_IMPLEMENTED,
            detail="Session reconstruction is unavailable for this provider.",
        )

    payload = await maybe_await(loader(session_id=session_id, player_id=player.id))
    if not payload:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
    return payload
get_session_status(session_id, request) async

Return the current status of a session. Works in both standard and free-play modes.

Used by the frontend to verify a stored session_id is still paused and resumable.

Source code in dcs_simulation_engine/api/routers/sessions.py
73
74
75
76
77
78
79
80
81
82
83
84
@router.get("/{session_id}/status")
async def get_session_status(session_id: str, request: Request) -> dict:
    """Return the current status of a session. Works in both standard and free-play modes.

    Used by the frontend to verify a stored session_id is still paused and resumable.
    """
    registry = get_registry_from_request(request)
    entry = registry.get(session_id)
    if entry is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
    computed = _session_status(entry.status, entry.manager.exited)
    return {"status": computed, "game_name": entry.game_name, "turns": entry.manager.turns}
list_sessions(request) async

List active in-memory sessions for the player tied to the provided API key.

Source code in dcs_simulation_engine/api/routers/sessions.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
@router.get("/list", response_model=SessionsListResponse)
async def list_sessions(request: Request) -> SessionsListResponse:
    """List active in-memory sessions for the player tied to the provided API key."""
    require_standard_mode_from_request(
        request,
        detail="Session endpoints are disabled when the server is running in free play mode.",
    )
    provider = get_provider_from_request(request)
    registry = get_registry_from_request(request)

    player = await require_player_async(provider=provider, api_key=api_key_from_request(request))
    sessions = []
    for entry in registry.list_for_player(player.id):
        status = _session_status(entry.status, entry.manager.exited)
        if status == "closed" and entry.status != "closed":
            registry.close(entry.session_id)

        sessions.append(
            SessionSummary(
                session_id=entry.session_id,
                game=entry.game_name,
                status=status,
                created_at=entry.created_at,
                last_active=entry.last_active,
                turns=entry.manager.turns,
                exited=entry.manager.exited,
            )
        )

    return SessionsListResponse(sessions=sessions)
request_infer_intent_evaluation(session_id, request) async

Return a cached Infer Intent evaluation or generate and persist it on first request.

Source code in dcs_simulation_engine/api/routers/sessions.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
@router.post(
    "/{session_id}/infer-intent/evaluation",
    response_model=InferIntentEvaluationResponse,
)
async def request_infer_intent_evaluation(
    session_id: str,
    request: Request,
) -> InferIntentEvaluationResponse:
    """Return a cached Infer Intent evaluation or generate and persist it on first request."""
    provider = get_provider_from_request(request)
    player_id = await _resolve_session_player_id(request=request, session_id=session_id)

    if getattr(provider, "append_session_event", None) is None:
        raise HTTPException(
            status_code=status.HTTP_501_NOT_IMPLEMENTED,
            detail="Infer Intent evaluation is unavailable for this provider.",
        )

    condition: str | None = None
    get_assignment_fn = getattr(provider, "get_assignment_for_session_id", None)
    if get_assignment_fn is not None:
        assignment = await maybe_await(get_assignment_fn(session_id=session_id))
        if assignment is not None and assignment.experiment_name:
            try:
                config = ExperimentManager.get_experiment_config_cached(assignment.experiment_name)
                condition = config.condition
            except Exception:
                pass

    try:
        response = await generate_or_get_infer_intent_evaluation(
            provider=provider,
            session_id=session_id,
            player_id=player_id,
            condition=condition,
        )
    except InferIntentEvaluationUnavailableError as exc:
        raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
    except NotImplementedError as exc:
        raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=str(exc)) from exc

    if response is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
    return response
submit_session_event_feedback(session_id, event_id, body, request) async

Store or overwrite feedback on one persisted NPC-message event.

Source code in dcs_simulation_engine/api/routers/sessions.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
@router.post(
    "/{session_id}/events/{event_id}/feedback",
    response_model=SubmitSessionEventFeedbackResponse,
)
async def submit_session_event_feedback(
    session_id: str,
    event_id: str,
    body: SubmitSessionEventFeedbackRequest,
    request: Request,
) -> SubmitSessionEventFeedbackResponse:
    """Store or overwrite feedback on one persisted NPC-message event."""
    provider = get_provider_from_request(request)
    player_id = await _resolve_session_player_id(request=request, session_id=session_id)

    await _flush_live_session_feedback_target(request=request, session_id=session_id, player_id=player_id)

    writer = getattr(provider, "set_session_event_feedback", None)
    if writer is None:
        raise HTTPException(
            status_code=status.HTTP_501_NOT_IMPLEMENTED,
            detail="Session event feedback is unavailable for this provider.",
        )

    now = utc_now()
    doesnt_make_sense = False if body.liked else body.doesnt_make_sense
    out_of_character = False if body.liked else body.out_of_character
    other = False if body.liked else body.other
    feedback = SessionEventFeedback(
        liked=body.liked,
        comment=body.comment.strip(),
        doesnt_make_sense=doesnt_make_sense,
        out_of_character=out_of_character,
        other=other,
        submitted_at=now,
    )
    stored = await maybe_await(
        writer(
            session_id=session_id,
            player_id=player_id,
            event_id=event_id,
            feedback=feedback.model_dump(),
        )
    )
    if not stored:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NPC message not found")

    return SubmitSessionEventFeedbackResponse(
        session_id=session_id,
        event_id=event_id,
        feedback=SessionEventFeedback.model_validate(stored),
    )
users

Player registration, auth, and management endpoints.

auth_user(body, request) async

Authenticate a user API key and return the associated player id.

Source code in dcs_simulation_engine/api/routers/users.py
110
111
112
113
114
115
116
117
118
119
120
@router.post("/auth", response_model=AuthResponse)
async def auth_user(body: AuthRequest, request: Request) -> AuthResponse:
    """Authenticate a user API key and return the associated player id."""
    require_standard_mode_from_request(
        request,
        detail="Player authentication is disabled when the server is running in free play mode.",
    )
    provider = get_provider_from_request(request)
    player = await require_player_async(provider=provider, api_key=body.api_key)
    full_name = player.data.get("full_name", {}).get("answer", "")
    return AuthResponse(player_id=player.id, full_name=full_name, authenticated=True)
register_user(body, request) async

Register a new player record and return a newly issued API key.

Source code in dcs_simulation_engine/api/routers/users.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
@router.post("/registration", response_model=RegistrationResponse)
async def register_user(body: RegistrationRequest, request: Request) -> RegistrationResponse:
    """Register a new player record and return a newly issued API key."""
    require_standard_mode_from_request(
        request,
        detail="Player registration is disabled when the server is running in free play mode.",
    )
    provider = get_provider_from_request(request)
    player_data = _registration_to_player_data(body)
    if is_remote_management_enabled_from_request(request) and not await has_remote_admin_async(provider=provider):
        player_data["role"] = REMOTE_ADMIN_ROLE

    record, api_key = await maybe_await(provider.create_player(player_data=player_data, issue_access_key=True))
    if api_key is None:
        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to issue access key")

    return RegistrationResponse(player_id=record.id, api_key=api_key)

cli

CLI package.

app

Root cli app wiring.

main(ctx, quiet=typer.Option(False, '--quiet', '-q', help='Suppress non-error output.'), verbose=typer.Option(0, '-v', '--verbose', count=True, help='Increase verbosity: -v for INFO, -vv for DEBUG.'), yes=typer.Option(False, '--yes', '-y', help='Assume "yes" for all prompts (non-interactive mode).'), config=typer.Option(None, '--config', help='Optional global config file.', exists=False, dir_okay=False, file_okay=True, readable=True), mongo_uri=typer.Option(None, '--mongo-uri', envvar='MONGO_URI', help='MongoDB connection URI. Overrides MONGO_URI environment value.'), server_url=typer.Option('http://localhost:8000', '--server-url', envvar='DCS_SERVER_URL', help='DCS API server URL.'))

Initialize global CLI options and context.

Source code in dcs_simulation_engine/cli/app.py
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
@app.callback(invoke_without_command=False)
def main(
    ctx: typer.Context,
    quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress non-error output."),
    verbose: int = typer.Option(
        0,
        "-v",
        "--verbose",
        count=True,
        help="Increase verbosity: -v for INFO, -vv for DEBUG.",
    ),
    yes: bool = typer.Option(
        False,
        "--yes",
        "-y",
        help='Assume "yes" for all prompts (non-interactive mode).',
    ),
    config: Optional[Path] = typer.Option(
        None,
        "--config",
        help="Optional global config file.",
        exists=False,
        dir_okay=False,
        file_okay=True,
        readable=True,
    ),
    mongo_uri: Optional[str] = typer.Option(
        None,
        "--mongo-uri",
        envvar="MONGO_URI",
        help="MongoDB connection URI. Overrides MONGO_URI environment value.",
    ),
    server_url: str = typer.Option(
        "http://localhost:8000",
        "--server-url",
        envvar="DCS_SERVER_URL",
        help="DCS API server URL.",
    ),
) -> None:
    """Initialize global CLI options and context."""
    ctx.obj = GlobalOptions(quiet=quiet, yes=yes, config=config, mongo_uri=mongo_uri, server_url=server_url)
    configure_logger(source="dcs-cli", quiet=quiet, verbose=verbose)

bootstrap

CLI bootstrap: single entrypoint for backend wiring and lifecycle.

create_async_provider(*, mongo_uri=None) async

Return an AsyncMongoProvider wired to a resolved MongoDB URI.

Source code in dcs_simulation_engine/cli/bootstrap.py
38
39
40
41
async def create_async_provider(*, mongo_uri: str | None = None) -> AsyncMongoProvider:
    """Return an AsyncMongoProvider wired to a resolved MongoDB URI."""
    uri = _resolve_mongo_uri(mongo_uri=mongo_uri)
    return AsyncMongoProvider(db=await connect_db_async(uri=uri))
create_provider_admin(*, mongo_uri=None)

Return a MongoAdmin wired to a resolved MongoDB URI.

Source code in dcs_simulation_engine/cli/bootstrap.py
50
51
52
53
def create_provider_admin(*, mongo_uri: str | None = None) -> MongoAdmin:
    """Return a MongoAdmin wired to a resolved MongoDB URI."""
    uri = _resolve_mongo_uri(mongo_uri=mongo_uri)
    return MongoAdmin(connect_db(uri=uri))
create_sync_db(*, mongo_uri=None)

Return a sync MongoDB database handle wired to a resolved MongoDB URI.

Source code in dcs_simulation_engine/cli/bootstrap.py
44
45
46
47
def create_sync_db(*, mongo_uri: str | None = None) -> Database[Any]:
    """Return a sync MongoDB database handle wired to a resolved MongoDB URI."""
    uri = _resolve_mongo_uri(mongo_uri=mongo_uri)
    return connect_db(uri=uri)

commands

Command groups for the CLI.

admin

CLI admin commands for database administration.

backup(ctx, outdir=typer.Argument(help='Directory to write the backup to. A timestamped subdirectory is created inside.'))

Backup the entire database to a directory.

Source code in dcs_simulation_engine/cli/commands/admin.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@admin_app.command("backup")
def backup(
    ctx: typer.Context,
    outdir: Path = typer.Argument(
        help="Directory to write the backup to. A timestamped subdirectory is created inside.",
    ),
) -> None:
    """Backup the entire database to a directory."""
    mongo_uri = getattr(getattr(ctx, "obj", None), "mongo_uri", None)
    try:
        admin = create_provider_admin(mongo_uri=mongo_uri)
        result = admin.backup_db(outdir)
    except Exception as e:
        echo(ctx, str(e), style="error")
        raise typer.Exit(code=1)
    echo(ctx, f"Backup written to: {result}")
keygen(ctx)

Generate a deployment-ready admin key without storing it anywhere.

Source code in dcs_simulation_engine/cli/commands/admin.py
42
43
44
45
46
47
48
@admin_app.command("keygen")
def keygen(ctx: typer.Context) -> None:
    """Generate a deployment-ready admin key without storing it anywhere."""
    key = generate_access_key()
    echo(ctx, key, style="success")
    echo(ctx, "This key has not been added to any app or database.", style="error")
    echo(ctx, "It is intended to be supplied during deployment, for example via `dcs remote deploy --admin-key`.")
seed(ctx, seeds_dir=typer.Argument(help='Directory of JSON/NDJSON seed files. Defaults to database_seeds/dev.'))

Seed the database from JSON files.

Source code in dcs_simulation_engine/cli/commands/admin.py
13
14
15
16
17
18
19
20
21
@admin_app.command("seed")
def seed(
    ctx: typer.Context,
    seeds_dir: Path = typer.Argument(
        help="Directory of JSON/NDJSON seed files. Defaults to database_seeds/dev.",
    ),
) -> None:
    """Seed the database from JSON files."""
    seed_database(ctx, seeds_dir)
dump

CLI command for dumping Mongo collections to JSON files.

dump(ctx, outdir=typer.Argument(..., help='Directory to write the dump to. A timestamped subdirectory is created inside.', file_okay=False, dir_okay=True, writable=True, readable=True, resolve_path=False))

Dump all Mongo collections to JSON files.

Source code in dcs_simulation_engine/cli/commands/dump.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
def dump(
    ctx: typer.Context,
    outdir: Path = typer.Argument(
        ...,
        help="Directory to write the dump to. A timestamped subdirectory is created inside.",
        file_okay=False,
        dir_okay=True,
        writable=True,
        readable=True,
        resolve_path=False,
    ),
) -> None:
    """Dump all Mongo collections to JSON files."""
    mongo_uri = getattr(getattr(ctx, "obj", None), "mongo_uri", None)
    try:
        db = create_sync_db(mongo_uri=mongo_uri)
        result = dump_all_collections_to_json(db, outdir)
    except Exception as e:
        echo(ctx, f"Failed to dump database: {e}", style="error")
        raise typer.Exit(code=1)

    echo(ctx, f"Dump written to: {result}", style="success")
remote

Remote Fly deployment and lifecycle commands.

deploy(ctx, config=typer.Option(None, '--config', exists=True, dir_okay=False, file_okay=True, readable=True, resolve_path=True, help='Experiment YAML config to deploy. Omit when using --free-play.'), free_play=typer.Option(False, '--free-play', help='Deploy the stack in anonymous free-play mode instead of experiment mode.'), openrouter_key=typer.Option(..., '--openrouter-key', envvar='OPENROUTER_API_KEY', help='OpenRouter API key forwarded to the remote API deployment.'), fly_io_key=typer.Option(None, '--fly-io-key', envvar='FLY_API_TOKEN', help='Fly API token used for deploy and destroy operations.'), mongo_seed_path=typer.Option(..., '--mongo-seed-path', exists=True, dir_okay=True, file_okay=True, readable=True, resolve_path=True, help='Local seed source for Mongo bootstrap: a .zip/.tar.gz archive, a .json/.ndjson dump, or a directory.'), admin_key=typer.Option(None, '--admin-key', envvar='DCS_ADMIN_KEY', help='Optional explicit remote admin key to install during bootstrap. Must match the dcs-ak- key format.'), region=typer.Option(None, '--regions', '--region', help='Fly region(s) to try in order. Example: --regions lax sjc dfw'), only_app=typer.Option(None, '--only-app', help='Redeploy only the selected app(s): api, ui, or db. Repeat the flag to deploy multiple apps.'), api_app=typer.Option(None, '--api-app', help='Optional explicit Fly app name for the API.'), ui_app=typer.Option(None, '--ui-app', help='Optional explicit Fly app name for the UI.'), db_app=typer.Option(None, '--db-app', help='Optional explicit Fly app name for MongoDB.'), json_output=typer.Option(False, '--json', help='Print the deployment result as JSON.'))

Deploy one remote-managed stack to Fly as API, UI, and Mongo apps.

Source code in dcs_simulation_engine/cli/commands/remote.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
@remote_app.command("deploy", context_settings={"allow_extra_args": True})
def deploy(
    ctx: typer.Context,
    config: Optional[Path] = typer.Option(
        None,
        "--config",
        exists=True,
        dir_okay=False,
        file_okay=True,
        readable=True,
        resolve_path=True,
        help="Experiment YAML config to deploy. Omit when using --free-play.",
    ),
    free_play: bool = typer.Option(
        False,
        "--free-play",
        help="Deploy the stack in anonymous free-play mode instead of experiment mode.",
    ),
    openrouter_key: str = typer.Option(
        ...,
        "--openrouter-key",
        envvar="OPENROUTER_API_KEY",
        help="OpenRouter API key forwarded to the remote API deployment.",
    ),
    fly_io_key: Optional[str] = typer.Option(
        None,
        "--fly-io-key",
        envvar="FLY_API_TOKEN",
        help="Fly API token used for deploy and destroy operations.",
    ),
    mongo_seed_path: Path = typer.Option(
        ...,
        "--mongo-seed-path",
        exists=True,
        dir_okay=True,
        file_okay=True,
        readable=True,
        resolve_path=True,
        help="Local seed source for Mongo bootstrap: a .zip/.tar.gz archive, a .json/.ndjson dump, or a directory.",
    ),
    admin_key: Optional[str] = typer.Option(
        None,
        "--admin-key",
        envvar="DCS_ADMIN_KEY",
        help="Optional explicit remote admin key to install during bootstrap. Must match the dcs-ak- key format.",
    ),
    region: Optional[str] = typer.Option(
        None,
        "--regions",
        "--region",
        help=("Fly region(s) to try in order. Example: --regions lax sjc dfw"),
    ),
    only_app: Optional[list[str]] = typer.Option(
        None,
        "--only-app",
        help="Redeploy only the selected app(s): api, ui, or db. Repeat the flag to deploy multiple apps.",
    ),
    api_app: Optional[str] = typer.Option(None, "--api-app", help="Optional explicit Fly app name for the API."),
    ui_app: Optional[str] = typer.Option(None, "--ui-app", help="Optional explicit Fly app name for the UI."),
    db_app: Optional[str] = typer.Option(None, "--db-app", help="Optional explicit Fly app name for MongoDB."),
    json_output: bool = typer.Option(False, "--json", help="Print the deployment result as JSON."),
) -> None:
    """Deploy one remote-managed stack to Fly as API, UI, and Mongo apps."""
    try:
        if free_play and config is not None:
            raise ValueError("Use either --config or --free-play, not both.")
        if not free_play and config is None:
            raise ValueError("--config is required unless --free-play is set.")
        region_candidates = _parse_region_candidates(ctx, region)
        if json_output:
            result = _deploy_with_region_fallback(
                ctx=ctx,
                config=config,
                free_play=free_play,
                openrouter_key=openrouter_key,
                mongo_seed_path=mongo_seed_path,
                admin_key=admin_key,
                fly_io_key=fly_io_key,
                region_candidates=region_candidates,
                api_app=api_app,
                ui_app=ui_app,
                db_app=db_app,
                only_app=only_app,
                announce_attempts=False,
            )
        else:
            with step("Deploying remote experiment to Fly"):
                result = _deploy_with_region_fallback(
                    ctx=ctx,
                    config=config,
                    free_play=free_play,
                    openrouter_key=openrouter_key,
                    mongo_seed_path=mongo_seed_path,
                    admin_key=admin_key,
                    fly_io_key=fly_io_key,
                    region_candidates=region_candidates,
                    api_app=api_app,
                    ui_app=ui_app,
                    db_app=db_app,
                    only_app=only_app,
                    announce_attempts=True,
                )
    except Exception as exc:
        echo(ctx, f"Remote deploy failed: {exc}", style="error")
        raise typer.Exit(code=1) from exc

    if json_output:
        _print_json(result)
        return

    echo(ctx, f"Deployment ready: {result.experiment_name}", style="success")
    echo(ctx, f"Deployed apps: {', '.join(result.deployed_apps)}")
    echo(ctx, f"API: {result.api_url}")
    echo(ctx, f"UI: {result.ui_url}")
    echo(ctx, f"Open the UI in your browser: {result.ui_url}")
    echo(ctx, f"Apps: api={result.api_app} ui={result.ui_app} db={result.db_app}")
    if result.admin_api_key:
        echo(ctx, f"Admin access key: {result.admin_api_key}", style="error")
        echo(ctx, "Save this admin key now. It will only be shown once.", style="error")
    else:
        echo(ctx, "Admin access key unchanged: targeted app deploys do not re-bootstrap the deployment.")
    echo(ctx, "Next commands:")
    echo(ctx, f"  {result.status_command}")
    if result.save_command:
        echo(ctx, f"  {result.save_command}")
    if result.stop_command:
        echo(ctx, f"  {result.stop_command}")
save(ctx, uri=typer.Option(..., '--uri', help='Remote API base URL.'), admin_key=typer.Option(..., '--admin-key', envvar='DCS_ADMIN_KEY', help='Admin access key returned by remote deploy.'), save_db_path=typer.Option(..., '--save-db-path', dir_okay=False, file_okay=True, writable=True, resolve_path=True, help='Local path for the downloaded database export archive (.tar.gz or .zip).'))

Download the remote database export archive to a local file.

Source code in dcs_simulation_engine/cli/commands/remote.py
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
@remote_app.command("save")
def save(
    ctx: typer.Context,
    uri: str = typer.Option(..., "--uri", help="Remote API base URL."),
    admin_key: str = typer.Option(..., "--admin-key", envvar="DCS_ADMIN_KEY", help="Admin access key returned by remote deploy."),
    save_db_path: Path = typer.Option(
        ...,
        "--save-db-path",
        dir_okay=False,
        file_okay=True,
        writable=True,
        resolve_path=True,
        help="Local path for the downloaded database export archive (.tar.gz or .zip).",
    ),
) -> None:
    """Download the remote database export archive to a local file."""
    try:
        with step("Downloading remote database export"):
            result_path = save_remote_database(uri=uri, admin_key=admin_key, save_db_path=save_db_path)
    except Exception as exc:
        echo(ctx, f"Remote save failed: {exc}", style="error")
        raise typer.Exit(code=1) from exc

    typer.echo(f"Database export written to: {result_path}")
status(ctx, uri=typer.Option(..., '--uri', help='Remote API base URL.'), admin_key=typer.Option(..., '--admin-key', envvar='DCS_ADMIN_KEY', help='Saved remote admin access key.'), json_output=typer.Option(False, '--json', help='Print the status result as JSON.'))

Return the authenticated status payload for one remote deployment.

Source code in dcs_simulation_engine/cli/commands/remote.py
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
@remote_app.command("status")
def status(
    ctx: typer.Context,
    uri: str = typer.Option(..., "--uri", help="Remote API base URL."),
    admin_key: str = typer.Option(..., "--admin-key", envvar="DCS_ADMIN_KEY", help="Saved remote admin access key."),
    json_output: bool = typer.Option(False, "--json", help="Print the status result as JSON."),
) -> None:
    """Return the authenticated status payload for one remote deployment."""
    try:
        result = fetch_remote_status(
            uri=uri,
            admin_key=admin_key,
        )
    except Exception as exc:
        echo(ctx, f"Remote status failed: {exc}", style="error")
        raise typer.Exit(code=1) from exc

    if json_output:
        _print_json(result.experiment_status or {})
        return

    typer.echo(json.dumps(result.experiment_status or {}, indent=2, sort_keys=True))
stop(ctx, uri=typer.Option(..., '--uri', help='Remote API base URL.'), admin_key=typer.Option(..., '--admin-key', envvar='DCS_ADMIN_KEY', help='Admin access key returned by remote deploy.'), save_db_path=typer.Option(..., '--save-db-path', dir_okay=False, file_okay=True, writable=True, resolve_path=True, help='Local path for the downloaded database export archive (.tar.gz or .zip).'), api_app=typer.Option(..., '--api-app', help='Fly API app name.'), ui_app=typer.Option(..., '--ui-app', help='Fly UI app name.'), db_app=typer.Option(..., '--db-app', help='Fly Mongo app name.'), fly_io_key=typer.Option(None, '--fly-io-key', envvar='FLY_API_TOKEN', help='Fly API token used to destroy the remote apps.'))

Save the remote DB archive, then destroy all Fly apps for the experiment.

Source code in dcs_simulation_engine/cli/commands/remote.py
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
@remote_app.command("stop")
def stop(
    ctx: typer.Context,
    uri: str = typer.Option(..., "--uri", help="Remote API base URL."),
    admin_key: str = typer.Option(..., "--admin-key", envvar="DCS_ADMIN_KEY", help="Admin access key returned by remote deploy."),
    save_db_path: Path = typer.Option(
        ...,
        "--save-db-path",
        dir_okay=False,
        file_okay=True,
        writable=True,
        resolve_path=True,
        help="Local path for the downloaded database export archive (.tar.gz or .zip).",
    ),
    api_app: str = typer.Option(..., "--api-app", help="Fly API app name."),
    ui_app: str = typer.Option(..., "--ui-app", help="Fly UI app name."),
    db_app: str = typer.Option(..., "--db-app", help="Fly Mongo app name."),
    fly_io_key: Optional[str] = typer.Option(
        None,
        "--fly-io-key",
        envvar="FLY_API_TOKEN",
        help="Fly API token used to destroy the remote apps.",
    ),
) -> None:
    """Save the remote DB archive, then destroy all Fly apps for the experiment."""
    try:
        with step("Saving remote database and destroying Fly apps"):
            result_path = stop_remote_experiment(
                uri=uri,
                admin_key=admin_key,
                save_db_path=save_db_path,
                api_app=api_app,
                ui_app=ui_app,
                db_app=db_app,
                fly_api_token=fly_io_key,
            )
    except Exception as exc:
        echo(ctx, f"Remote stop failed: {exc}", style="error")
        raise typer.Exit(code=1) from exc

    typer.echo(f"Database export written to: {result_path}")
    echo(ctx, "Remote deployment destroyed.", style="success")
server

CLI server command.

server(ctx, host=typer.Option(DEFAULT_HOST, '--host', envvar='DCS_SERVER_HOST', help='Host to bind the server to.'), port=typer.Option(DEFAULT_PORT, '--port', envvar='DCS_SERVER_PORT', help='Port to bind the server to.'), ttl_seconds=typer.Option(DEFAULT_SESSION_TTL_SECONDS, '--session-ttl', envvar='DCS_SESSION_TTL_SECONDS', help='Session TTL in seconds.'), sweep_interval_seconds=typer.Option(DEFAULT_SWEEP_INTERVAL_SECONDS, '--sweep-interval', envvar='DCS_SESSION_SWEEP_INTERVAL_SECONDS', help='Session sweep interval in seconds.'), mongo_seed_dir=typer.Option(None, '--mongo-seed-dir', envvar='DCS_MONGO_SEED_DIR', help='Seed MongoDB from this directory of JSON/NDJSON files on startup.'), dump_dir=typer.Option(None, '--dump', envvar='DCS_DUMP_DIR', help='Dump all Mongo collections to this directory when the server shuts down.'), fake_ai_response=typer.Option(None, '--fake-ai-response', help='Return this string for all AI responses instead of calling OpenRouter.'), free_play=typer.Option(False, '--free-play', help='Run the server in anonymous free play mode without registration or experiments.'), remote_managed=typer.Option(False, '--remote-managed', envvar='DCS_REMOTE_MANAGED', help='Run the server as a remote-managed deployment with bootstrap/export endpoints enabled.'), default_experiment=typer.Option(None, '--default-experiment', envvar='DCS_DEFAULT_EXPERIMENT_NAME', help='Default experiment name for experiment-centric deployments.'), bootstrap_token=typer.Option(None, '--bootstrap-token', envvar='DCS_REMOTE_BOOTSTRAP_TOKEN', help='One-time bootstrap token used to seed a remote-managed deployment.'), cors_origin=typer.Option(None, '--cors-origin', help='Additional allowed CORS origin. Repeat the flag to allow multiple origins.'))

Start the DCS API server.

Source code in dcs_simulation_engine/cli/commands/server.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def server(
    ctx: typer.Context,
    host: str = typer.Option(
        DEFAULT_HOST,
        "--host",
        envvar="DCS_SERVER_HOST",
        help="Host to bind the server to.",
    ),
    port: int = typer.Option(
        DEFAULT_PORT,
        "--port",
        envvar="DCS_SERVER_PORT",
        help="Port to bind the server to.",
    ),
    ttl_seconds: int = typer.Option(
        DEFAULT_SESSION_TTL_SECONDS,
        "--session-ttl",
        envvar="DCS_SESSION_TTL_SECONDS",
        help="Session TTL in seconds.",
    ),
    sweep_interval_seconds: int = typer.Option(
        DEFAULT_SWEEP_INTERVAL_SECONDS,
        "--sweep-interval",
        envvar="DCS_SESSION_SWEEP_INTERVAL_SECONDS",
        help="Session sweep interval in seconds.",
    ),
    mongo_seed_dir: Optional[Path] = typer.Option(
        None,
        "--mongo-seed-dir",
        envvar="DCS_MONGO_SEED_DIR",
        help="Seed MongoDB from this directory of JSON/NDJSON files on startup.",
    ),
    dump_dir: Optional[Path] = typer.Option(
        None,
        "--dump",
        envvar="DCS_DUMP_DIR",
        help="Dump all Mongo collections to this directory when the server shuts down.",
    ),
    fake_ai_response: Optional[str] = typer.Option(
        None,
        "--fake-ai-response",
        help="Return this string for all AI responses instead of calling OpenRouter.",
    ),
    free_play: bool = typer.Option(
        False,
        "--free-play",
        help="Run the server in anonymous free play mode without registration or experiments.",
    ),
    remote_managed: bool = typer.Option(
        False,
        "--remote-managed",
        envvar="DCS_REMOTE_MANAGED",
        help="Run the server as a remote-managed deployment with bootstrap/export endpoints enabled.",
    ),
    default_experiment: Optional[str] = typer.Option(
        None,
        "--default-experiment",
        envvar="DCS_DEFAULT_EXPERIMENT_NAME",
        help="Default experiment name for experiment-centric deployments.",
    ),
    bootstrap_token: Optional[str] = typer.Option(
        None,
        "--bootstrap-token",
        envvar="DCS_REMOTE_BOOTSTRAP_TOKEN",
        help="One-time bootstrap token used to seed a remote-managed deployment.",
    ),
    cors_origin: Optional[list[str]] = typer.Option(
        None,
        "--cors-origin",
        help="Additional allowed CORS origin. Repeat the flag to allow multiple origins.",
    ),
) -> None:
    """Start the DCS API server."""
    import uvicorn

    mongo_uri = getattr(getattr(ctx, "obj", None), "mongo_uri", None)
    ai_client.set_fake_ai_response(fake_ai_response)
    ai_client.validate_openrouter_configuration()

    if mongo_seed_dir is not None:
        seed_database(ctx, mongo_seed_dir)

    try:
        app = create_app(
            provider=None,
            mongo_uri=mongo_uri,
            shutdown_dump_dir=dump_dir,
            server_mode="free_play" if free_play else "standard",
            default_experiment_name=default_experiment,
            remote_management_enabled=remote_managed,
            bootstrap_token=bootstrap_token,
            session_ttl_seconds=ttl_seconds,
            sweep_interval_seconds=sweep_interval_seconds,
            cors_origins=cors_origin or [],
        )
    except Exception:
        console.print_exception()
        raise typer.Exit(code=1)

    console.print(f"DCS server running at http://{host}:{port}", style="success")
    uvicorn.run(app, host=host, port=port, loop="uvloop", workers=1)

common

Shared cli utilities.

GlobalOptions dataclass

Global options for the CLI.

Source code in dcs_simulation_engine/cli/common.py
26
27
28
29
30
31
32
33
34
@dataclass
class GlobalOptions:
    """Global options for the CLI."""

    quiet: bool = False
    yes: bool = False
    config: Optional[Path] = None
    mongo_uri: Optional[str] = None
    server_url: str = "http://localhost:8000"
echo(ctx, message, style='white')

Respect global quiet flag; print only if not quiet.

Source code in dcs_simulation_engine/cli/common.py
45
46
47
48
49
50
51
52
53
54
def echo(ctx: Optional[typer.Context], message: str, style: str = "white") -> None:
    """Respect global quiet flag; print only if not quiet."""
    quiet = False
    if ctx is not None and isinstance(getattr(ctx, "obj", None), GlobalOptions):
        quiet = ctx.obj.quiet

    if quiet:
        return

    console.print(message, style=style)
get_client(ctx)

Return an APIClient configured from the CLI context.

Source code in dcs_simulation_engine/cli/common.py
37
38
39
40
41
42
def get_client(ctx: Optional[typer.Context]) -> APIClient:
    """Return an APIClient configured from the CLI context."""
    url = "http://localhost:8000"
    if ctx is not None and isinstance(getattr(ctx, "obj", None), GlobalOptions):
        url = ctx.obj.server_url
    return APIClient(url=url)
seed_database(ctx, seed_dir)

Seed the database from JSON/NDJSON files.

Source code in dcs_simulation_engine/cli/common.py
72
73
74
75
76
77
78
79
80
81
def seed_database(ctx: typer.Context, seed_dir: Path) -> None:
    """Seed the database from JSON/NDJSON files."""
    mongo_uri = getattr(getattr(ctx, "obj", None), "mongo_uri", None)
    try:
        admin = create_provider_admin(mongo_uri=mongo_uri)
        result = admin.seed_database(seed_dir=seed_dir)
    except Exception as e:
        echo(ctx, str(e), style="error")
        raise typer.Exit(code=1)
    echo(ctx, f"Seeded: {result}")
step(msg)

Context manager for displaying a step with a spinner.

Source code in dcs_simulation_engine/cli/common.py
57
58
59
60
61
62
63
64
65
66
67
68
69
@contextmanager
def step(msg: str):
    """Context manager for displaying a step with a spinner."""
    try:
        with console.status(msg, spinner="dots") as status:
            yield
    except Exception:
        status.stop()
        console.print(f"[red]✖[/red] {msg}", style="dim")
        raise
    else:
        status.stop()
        console.print(f"[green]✔[/green] {msg}", style="dim")

core

Di Simulation Engine core components.

assignment_strategies

Assignment strategy registry.

get_assignment_strategy(strategy_name)

Resolve one registered assignment strategy by name.

Source code in dcs_simulation_engine/core/assignment_strategies/__init__.py
11
12
13
14
15
16
17
def get_assignment_strategy(strategy_name: str) -> AssignmentStrategy:
    """Resolve one registered assignment strategy by name."""
    normalized = strategy_name.strip().lower()
    try:
        return _STRATEGIES[normalized]
    except KeyError as exc:
        raise ValueError(f"Unknown assignment strategy: {strategy_name}") from exc
base

Assignment strategy protocol for experiment workflows.

AssignmentStrategy

Bases: Protocol

Behavior contract for experiment assignment strategies.

Source code in dcs_simulation_engine/core/assignment_strategies/base.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class AssignmentStrategy(Protocol):
    """Behavior contract for experiment assignment strategies."""

    name: str

    def validate_config(self, *, config: "ExperimentConfig") -> None:
        """Validate strategy-specific config constraints."""

    def max_assignments_per_player(self, *, config: "ExperimentConfig") -> int:
        """Return the maximum number of assignments one player may complete."""

    async def compute_progress_async(self, *, provider: Any, config: "ExperimentConfig") -> dict[str, Any]:
        """Return experiment progress payload for the public API."""

    async def compute_status_async(self, *, provider: Any, config: "ExperimentConfig") -> dict[str, Any]:
        """Return experiment status payload for the public API."""

    async def get_or_create_assignment_async(
        self,
        *,
        provider: Any,
        config: "ExperimentConfig",
        player: "PlayerRecord",
    ) -> "AssignmentRecord | None":
        """Return the current assignment for a player or create one."""
compute_progress_async(*, provider, config) async

Return experiment progress payload for the public API.

Source code in dcs_simulation_engine/core/assignment_strategies/base.py
23
24
async def compute_progress_async(self, *, provider: Any, config: "ExperimentConfig") -> dict[str, Any]:
    """Return experiment progress payload for the public API."""
compute_status_async(*, provider, config) async

Return experiment status payload for the public API.

Source code in dcs_simulation_engine/core/assignment_strategies/base.py
26
27
async def compute_status_async(self, *, provider: Any, config: "ExperimentConfig") -> dict[str, Any]:
    """Return experiment status payload for the public API."""
get_or_create_assignment_async(*, provider, config, player) async

Return the current assignment for a player or create one.

Source code in dcs_simulation_engine/core/assignment_strategies/base.py
29
30
31
32
33
34
35
36
async def get_or_create_assignment_async(
    self,
    *,
    provider: Any,
    config: "ExperimentConfig",
    player: "PlayerRecord",
) -> "AssignmentRecord | None":
    """Return the current assignment for a player or create one."""
max_assignments_per_player(*, config)

Return the maximum number of assignments one player may complete.

Source code in dcs_simulation_engine/core/assignment_strategies/base.py
20
21
def max_assignments_per_player(self, *, config: "ExperimentConfig") -> int:
    """Return the maximum number of assignments one player may complete."""
validate_config(*, config)

Validate strategy-specific config constraints.

Source code in dcs_simulation_engine/core/assignment_strategies/base.py
17
18
def validate_config(self, *, config: "ExperimentConfig") -> None:
    """Validate strategy-specific config constraints."""
random_unique

Random unique assignment strategy implementation.

RandomUniqueAssignmentStrategy

Assign each player a deterministic random game without repeats.

Source code in dcs_simulation_engine/core/assignment_strategies/random_unique.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
class RandomUniqueAssignmentStrategy:
    """Assign each player a deterministic random game without repeats."""

    name = "random_unique"

    def validate_config(self, *, config: "ExperimentConfig") -> None:
        """Validate the required knobs for the random-unique strategy."""
        if not config.assignment_strategy.games:
            raise ValueError("random_unique requires assignment_strategy.games")
        if config.assignment_strategy.quota_per_game is None or config.assignment_strategy.quota_per_game <= 0:
            raise ValueError("random_unique requires a positive quota_per_game")

        max_assignments = config.assignment_strategy.max_assignments_per_player
        if max_assignments is not None and max_assignments <= 0:
            raise ValueError("random_unique requires max_assignments_per_player to be positive")
        if max_assignments is not None and max_assignments > len(config.games):
            raise ValueError("random_unique cannot assign more games per player than are listed in assignment_strategy.games")

    def max_assignments_per_player(self, *, config: "ExperimentConfig") -> int:
        """Return the configured max assignments, capped by the game list."""
        configured = config.assignment_strategy.max_assignments_per_player
        if configured is None:
            return len(config.games)
        return min(int(configured), len(config.games))

    async def compute_progress_async(self, *, provider: Any, config: "ExperimentConfig") -> dict[str, Any]:
        """Compute progress while closing the study on counted quota saturation."""
        quota = int(config.assignment_strategy.quota_per_game or 0)
        completed_players_by_game = await self._players_by_game(
            provider=provider,
            experiment_name=config.name,
            statuses=["completed"],
        )
        counted_players_by_game = await self._players_by_game(
            provider=provider,
            experiment_name=config.name,
            statuses=["in_progress", "completed"],
        )

        completed_total = sum(len(players) for players in completed_players_by_game.values())
        return {
            "total": quota * len(config.games),
            "completed": completed_total,
            "is_complete": all(len(counted_players_by_game.get(game_name, set())) >= quota for game_name in config.games),
        }

    async def compute_status_async(self, *, provider: Any, config: "ExperimentConfig") -> dict[str, Any]:
        """Compute per-game counts with quota openness based on active plus finished players."""
        quota = int(config.assignment_strategy.quota_per_game or 0)
        completed_players_by_game = await self._players_by_game(
            provider=provider,
            experiment_name=config.name,
            statuses=["completed"],
        )
        in_progress_players_by_game = await self._players_by_game(
            provider=provider,
            experiment_name=config.name,
            statuses=["in_progress"],
        )
        counted_players_by_game = await self._players_by_game(
            provider=provider,
            experiment_name=config.name,
            statuses=["in_progress", "completed"],
        )

        per_game: dict[str, dict[str, int]] = {}
        for game_name in config.games:
            per_game[game_name] = {
                "total": quota,
                "completed": len(completed_players_by_game.get(game_name, set())),
                "in_progress": len(in_progress_players_by_game.get(game_name, set())),
            }

        completed_total = sum(item["completed"] for item in per_game.values())
        return {
            "is_open": any(len(counted_players_by_game.get(game_name, set())) < quota for game_name in config.games),
            "total": quota * len(config.games),
            "completed": completed_total,
            "per_game": per_game,
        }

    async def get_eligible_options_async(
        self,
        *,
        provider: Any,
        config: "ExperimentConfig",
        player: "PlayerRecord",
    ) -> list[dict[str, str]]:
        """Return all eligible {game_name, character_hid} options for the player to choose from."""
        active_assignment = await maybe_await(provider.get_active_assignment(experiment_name=config.name, player_id=player.id))
        if active_assignment is not None:
            return []

        player_assignments = await maybe_await(provider.list_assignments(experiment_name=config.name, player_id=player.id))
        completed_count = sum(1 for item in player_assignments if item.status == "completed")
        if completed_count >= self.max_assignments_per_player(config=config):
            return []

        counted_players_by_game = await self._players_by_game(
            provider=provider,
            experiment_name=config.name,
            statuses=["in_progress", "completed"],
        )
        quota = int(config.assignment_strategy.quota_per_game or 0)
        assigned_games = {item.game_name for item in player_assignments}
        pc_eligible_only = bool(config.assignment_strategy.pc_eligible_only)

        options: list[dict[str, str]] = []
        for game_name in config.games:
            if game_name in assigned_games:
                continue
            if len(counted_players_by_game.get(game_name, set())) >= quota:
                continue
            game_config = SessionManager.get_game_config_cached(game_name)
            get_valid = getattr(game_config, "get_valid_characters_async", None)
            if get_valid is None:
                valid_pcs, _ = await maybe_await(
                    game_config.get_valid_characters(player_id=player.id, provider=provider, pc_eligible_only=pc_eligible_only)
                )
            else:
                valid_pcs, _ = await maybe_await(get_valid(player_id=player.id, provider=provider, pc_eligible_only=pc_eligible_only))
            for _, hid in valid_pcs:
                options.append({"game_name": game_name, "character_hid": hid})
        return options

    async def get_or_create_assignment_async(
        self,
        *,
        provider: Any,
        config: "ExperimentConfig",
        player: "PlayerRecord",
    ) -> "AssignmentRecord | None":
        """Reuse active work or create a new random unique assignment for a player."""
        active_assignment = await maybe_await(provider.get_active_assignment(experiment_name=config.name, player_id=player.id))
        if active_assignment is not None:
            return active_assignment

        player_assignments = await maybe_await(provider.list_assignments(experiment_name=config.name, player_id=player.id))
        completed_count = sum(1 for item in player_assignments if item.status == "completed")
        if completed_count >= self.max_assignments_per_player(config=config):
            return None

        counted_players_by_game = await self._players_by_game(
            provider=provider,
            experiment_name=config.name,
            statuses=["in_progress", "completed"],
        )
        quota = int(config.assignment_strategy.quota_per_game or 0)
        assigned_games = {item.game_name for item in player_assignments}
        eligible_games = [
            game_name
            for game_name in config.games
            if game_name not in assigned_games and len(counted_players_by_game.get(game_name, set())) < quota
        ]
        if not eligible_games:
            return None

        game_rng = self._rng_for(config=config, player_id=player.id, salt="game")
        game_candidates = list(eligible_games)
        game_rng.shuffle(game_candidates)

        for game_name in game_candidates:
            game_config = SessionManager.get_game_config_cached(game_name)
            pc_eligible_only = bool(config.assignment_strategy.pc_eligible_only)
            get_valid = getattr(game_config, "get_valid_characters_async", None)
            if get_valid is None:
                valid_pcs, _ = await maybe_await(
                    game_config.get_valid_characters(player_id=player.id, provider=provider, pc_eligible_only=pc_eligible_only)
                )
            else:
                valid_pcs, _ = await maybe_await(get_valid(player_id=player.id, provider=provider, pc_eligible_only=pc_eligible_only))

            valid_pc_hids = [hid for _, hid in valid_pcs]
            if not valid_pc_hids:
                continue

            pc_rng = self._rng_for(config=config, player_id=player.id, salt=f"pc:{game_name}")
            character_hid = pc_rng.choice(valid_pc_hids)
            return await maybe_await(
                provider.create_assignment(
                    assignment_doc={
                        MongoColumns.EXPERIMENT_NAME: config.name,
                        MongoColumns.PLAYER_ID: player.id,
                        MongoColumns.GAME_NAME: game_name,
                        MongoColumns.CHARACTER_HID: character_hid,
                        MongoColumns.STATUS: "assigned",
                        MongoColumns.FORM_RESPONSES: {},
                    }
                )
            )

        return None

    async def generate_remaining_assignments_async(
        self,
        *,
        provider: Any,
        config: "ExperimentConfig",
        player: "PlayerRecord",
    ) -> "list[AssignmentRecord]":
        """Pre-generate all remaining assignments up to max_assignments_per_player."""
        max_n = self.max_assignments_per_player(config=config)
        player_assignments = await maybe_await(provider.list_assignments(experiment_name=config.name, player_id=player.id))
        if len(player_assignments) >= max_n:
            return []

        assigned_games = {item.game_name for item in player_assignments}
        counted_players_by_game = await self._players_by_game(
            provider=provider,
            experiment_name=config.name,
            statuses=["in_progress", "completed"],
        )
        quota = int(config.assignment_strategy.quota_per_game or 0)
        eligible_games = [
            game_name
            for game_name in config.games
            if game_name not in assigned_games and len(counted_players_by_game.get(game_name, set())) < quota
        ]
        if not eligible_games:
            return []

        game_rng = self._rng_for(config=config, player_id=player.id, salt="game")
        game_candidates = list(eligible_games)
        game_rng.shuffle(game_candidates)

        needed = max_n - len(player_assignments)
        pc_eligible_only = bool(config.assignment_strategy.pc_eligible_only)
        created: list[AssignmentRecord] = []
        for game_name in game_candidates:
            if len(created) >= needed:
                break
            game_config = SessionManager.get_game_config_cached(game_name)
            get_valid = getattr(game_config, "get_valid_characters_async", None)
            if get_valid is None:
                valid_pcs, _ = await maybe_await(
                    game_config.get_valid_characters(player_id=player.id, provider=provider, pc_eligible_only=pc_eligible_only)
                )
            else:
                valid_pcs, _ = await maybe_await(get_valid(player_id=player.id, provider=provider, pc_eligible_only=pc_eligible_only))
            valid_pc_hids = [hid for _, hid in valid_pcs]
            if not valid_pc_hids:
                continue
            pc_rng = self._rng_for(config=config, player_id=player.id, salt=f"pc:{game_name}")
            character_hid = pc_rng.choice(valid_pc_hids)
            assignment = await maybe_await(
                provider.create_assignment(
                    assignment_doc={
                        MongoColumns.EXPERIMENT_NAME: config.name,
                        MongoColumns.PLAYER_ID: player.id,
                        MongoColumns.GAME_NAME: game_name,
                        MongoColumns.CHARACTER_HID: character_hid,
                        MongoColumns.STATUS: "assigned",
                        MongoColumns.FORM_RESPONSES: {},
                    },
                    allow_concurrent=True,
                )
            )
            if assignment is not None:
                created.append(assignment)

        return created

    async def _players_by_game(
        self,
        *,
        provider: Any,
        experiment_name: str,
        statuses: list[str],
    ) -> dict[str, set[str]]:
        """Group unique player ids by game for the requested assignment states."""
        assignments = await maybe_await(provider.list_assignments(experiment_name=experiment_name, statuses=statuses))
        players_by_game: dict[str, set[str]] = defaultdict(set)
        for assignment in assignments:
            players_by_game[assignment.game_name].add(assignment.player_id)
        return players_by_game

    def _rng_for(self, *, config: "ExperimentConfig", player_id: str, salt: str) -> random.Random:
        """Derive a deterministic RNG for one player and selection phase."""
        seed_value = config.assignment_strategy.seed or config.name
        return random.Random(f"{seed_value}:{player_id}:{salt}")
compute_progress_async(*, provider, config) async

Compute progress while closing the study on counted quota saturation.

Source code in dcs_simulation_engine/core/assignment_strategies/random_unique.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
async def compute_progress_async(self, *, provider: Any, config: "ExperimentConfig") -> dict[str, Any]:
    """Compute progress while closing the study on counted quota saturation."""
    quota = int(config.assignment_strategy.quota_per_game or 0)
    completed_players_by_game = await self._players_by_game(
        provider=provider,
        experiment_name=config.name,
        statuses=["completed"],
    )
    counted_players_by_game = await self._players_by_game(
        provider=provider,
        experiment_name=config.name,
        statuses=["in_progress", "completed"],
    )

    completed_total = sum(len(players) for players in completed_players_by_game.values())
    return {
        "total": quota * len(config.games),
        "completed": completed_total,
        "is_complete": all(len(counted_players_by_game.get(game_name, set())) >= quota for game_name in config.games),
    }
compute_status_async(*, provider, config) async

Compute per-game counts with quota openness based on active plus finished players.

Source code in dcs_simulation_engine/core/assignment_strategies/random_unique.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
async def compute_status_async(self, *, provider: Any, config: "ExperimentConfig") -> dict[str, Any]:
    """Compute per-game counts with quota openness based on active plus finished players."""
    quota = int(config.assignment_strategy.quota_per_game or 0)
    completed_players_by_game = await self._players_by_game(
        provider=provider,
        experiment_name=config.name,
        statuses=["completed"],
    )
    in_progress_players_by_game = await self._players_by_game(
        provider=provider,
        experiment_name=config.name,
        statuses=["in_progress"],
    )
    counted_players_by_game = await self._players_by_game(
        provider=provider,
        experiment_name=config.name,
        statuses=["in_progress", "completed"],
    )

    per_game: dict[str, dict[str, int]] = {}
    for game_name in config.games:
        per_game[game_name] = {
            "total": quota,
            "completed": len(completed_players_by_game.get(game_name, set())),
            "in_progress": len(in_progress_players_by_game.get(game_name, set())),
        }

    completed_total = sum(item["completed"] for item in per_game.values())
    return {
        "is_open": any(len(counted_players_by_game.get(game_name, set())) < quota for game_name in config.games),
        "total": quota * len(config.games),
        "completed": completed_total,
        "per_game": per_game,
    }
generate_remaining_assignments_async(*, provider, config, player) async

Pre-generate all remaining assignments up to max_assignments_per_player.

Source code in dcs_simulation_engine/core/assignment_strategies/random_unique.py
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
async def generate_remaining_assignments_async(
    self,
    *,
    provider: Any,
    config: "ExperimentConfig",
    player: "PlayerRecord",
) -> "list[AssignmentRecord]":
    """Pre-generate all remaining assignments up to max_assignments_per_player."""
    max_n = self.max_assignments_per_player(config=config)
    player_assignments = await maybe_await(provider.list_assignments(experiment_name=config.name, player_id=player.id))
    if len(player_assignments) >= max_n:
        return []

    assigned_games = {item.game_name for item in player_assignments}
    counted_players_by_game = await self._players_by_game(
        provider=provider,
        experiment_name=config.name,
        statuses=["in_progress", "completed"],
    )
    quota = int(config.assignment_strategy.quota_per_game or 0)
    eligible_games = [
        game_name
        for game_name in config.games
        if game_name not in assigned_games and len(counted_players_by_game.get(game_name, set())) < quota
    ]
    if not eligible_games:
        return []

    game_rng = self._rng_for(config=config, player_id=player.id, salt="game")
    game_candidates = list(eligible_games)
    game_rng.shuffle(game_candidates)

    needed = max_n - len(player_assignments)
    pc_eligible_only = bool(config.assignment_strategy.pc_eligible_only)
    created: list[AssignmentRecord] = []
    for game_name in game_candidates:
        if len(created) >= needed:
            break
        game_config = SessionManager.get_game_config_cached(game_name)
        get_valid = getattr(game_config, "get_valid_characters_async", None)
        if get_valid is None:
            valid_pcs, _ = await maybe_await(
                game_config.get_valid_characters(player_id=player.id, provider=provider, pc_eligible_only=pc_eligible_only)
            )
        else:
            valid_pcs, _ = await maybe_await(get_valid(player_id=player.id, provider=provider, pc_eligible_only=pc_eligible_only))
        valid_pc_hids = [hid for _, hid in valid_pcs]
        if not valid_pc_hids:
            continue
        pc_rng = self._rng_for(config=config, player_id=player.id, salt=f"pc:{game_name}")
        character_hid = pc_rng.choice(valid_pc_hids)
        assignment = await maybe_await(
            provider.create_assignment(
                assignment_doc={
                    MongoColumns.EXPERIMENT_NAME: config.name,
                    MongoColumns.PLAYER_ID: player.id,
                    MongoColumns.GAME_NAME: game_name,
                    MongoColumns.CHARACTER_HID: character_hid,
                    MongoColumns.STATUS: "assigned",
                    MongoColumns.FORM_RESPONSES: {},
                },
                allow_concurrent=True,
            )
        )
        if assignment is not None:
            created.append(assignment)

    return created
get_eligible_options_async(*, provider, config, player) async

Return all eligible {game_name, character_hid} options for the player to choose from.

Source code in dcs_simulation_engine/core/assignment_strategies/random_unique.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
async def get_eligible_options_async(
    self,
    *,
    provider: Any,
    config: "ExperimentConfig",
    player: "PlayerRecord",
) -> list[dict[str, str]]:
    """Return all eligible {game_name, character_hid} options for the player to choose from."""
    active_assignment = await maybe_await(provider.get_active_assignment(experiment_name=config.name, player_id=player.id))
    if active_assignment is not None:
        return []

    player_assignments = await maybe_await(provider.list_assignments(experiment_name=config.name, player_id=player.id))
    completed_count = sum(1 for item in player_assignments if item.status == "completed")
    if completed_count >= self.max_assignments_per_player(config=config):
        return []

    counted_players_by_game = await self._players_by_game(
        provider=provider,
        experiment_name=config.name,
        statuses=["in_progress", "completed"],
    )
    quota = int(config.assignment_strategy.quota_per_game or 0)
    assigned_games = {item.game_name for item in player_assignments}
    pc_eligible_only = bool(config.assignment_strategy.pc_eligible_only)

    options: list[dict[str, str]] = []
    for game_name in config.games:
        if game_name in assigned_games:
            continue
        if len(counted_players_by_game.get(game_name, set())) >= quota:
            continue
        game_config = SessionManager.get_game_config_cached(game_name)
        get_valid = getattr(game_config, "get_valid_characters_async", None)
        if get_valid is None:
            valid_pcs, _ = await maybe_await(
                game_config.get_valid_characters(player_id=player.id, provider=provider, pc_eligible_only=pc_eligible_only)
            )
        else:
            valid_pcs, _ = await maybe_await(get_valid(player_id=player.id, provider=provider, pc_eligible_only=pc_eligible_only))
        for _, hid in valid_pcs:
            options.append({"game_name": game_name, "character_hid": hid})
    return options
get_or_create_assignment_async(*, provider, config, player) async

Reuse active work or create a new random unique assignment for a player.

Source code in dcs_simulation_engine/core/assignment_strategies/random_unique.py
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
async def get_or_create_assignment_async(
    self,
    *,
    provider: Any,
    config: "ExperimentConfig",
    player: "PlayerRecord",
) -> "AssignmentRecord | None":
    """Reuse active work or create a new random unique assignment for a player."""
    active_assignment = await maybe_await(provider.get_active_assignment(experiment_name=config.name, player_id=player.id))
    if active_assignment is not None:
        return active_assignment

    player_assignments = await maybe_await(provider.list_assignments(experiment_name=config.name, player_id=player.id))
    completed_count = sum(1 for item in player_assignments if item.status == "completed")
    if completed_count >= self.max_assignments_per_player(config=config):
        return None

    counted_players_by_game = await self._players_by_game(
        provider=provider,
        experiment_name=config.name,
        statuses=["in_progress", "completed"],
    )
    quota = int(config.assignment_strategy.quota_per_game or 0)
    assigned_games = {item.game_name for item in player_assignments}
    eligible_games = [
        game_name
        for game_name in config.games
        if game_name not in assigned_games and len(counted_players_by_game.get(game_name, set())) < quota
    ]
    if not eligible_games:
        return None

    game_rng = self._rng_for(config=config, player_id=player.id, salt="game")
    game_candidates = list(eligible_games)
    game_rng.shuffle(game_candidates)

    for game_name in game_candidates:
        game_config = SessionManager.get_game_config_cached(game_name)
        pc_eligible_only = bool(config.assignment_strategy.pc_eligible_only)
        get_valid = getattr(game_config, "get_valid_characters_async", None)
        if get_valid is None:
            valid_pcs, _ = await maybe_await(
                game_config.get_valid_characters(player_id=player.id, provider=provider, pc_eligible_only=pc_eligible_only)
            )
        else:
            valid_pcs, _ = await maybe_await(get_valid(player_id=player.id, provider=provider, pc_eligible_only=pc_eligible_only))

        valid_pc_hids = [hid for _, hid in valid_pcs]
        if not valid_pc_hids:
            continue

        pc_rng = self._rng_for(config=config, player_id=player.id, salt=f"pc:{game_name}")
        character_hid = pc_rng.choice(valid_pc_hids)
        return await maybe_await(
            provider.create_assignment(
                assignment_doc={
                    MongoColumns.EXPERIMENT_NAME: config.name,
                    MongoColumns.PLAYER_ID: player.id,
                    MongoColumns.GAME_NAME: game_name,
                    MongoColumns.CHARACTER_HID: character_hid,
                    MongoColumns.STATUS: "assigned",
                    MongoColumns.FORM_RESPONSES: {},
                }
            )
        )

    return None
max_assignments_per_player(*, config)

Return the configured max assignments, capped by the game list.

Source code in dcs_simulation_engine/core/assignment_strategies/random_unique.py
36
37
38
39
40
41
def max_assignments_per_player(self, *, config: "ExperimentConfig") -> int:
    """Return the configured max assignments, capped by the game list."""
    configured = config.assignment_strategy.max_assignments_per_player
    if configured is None:
        return len(config.games)
    return min(int(configured), len(config.games))
validate_config(*, config)

Validate the required knobs for the random-unique strategy.

Source code in dcs_simulation_engine/core/assignment_strategies/random_unique.py
23
24
25
26
27
28
29
30
31
32
33
34
def validate_config(self, *, config: "ExperimentConfig") -> None:
    """Validate the required knobs for the random-unique strategy."""
    if not config.assignment_strategy.games:
        raise ValueError("random_unique requires assignment_strategy.games")
    if config.assignment_strategy.quota_per_game is None or config.assignment_strategy.quota_per_game <= 0:
        raise ValueError("random_unique requires a positive quota_per_game")

    max_assignments = config.assignment_strategy.max_assignments_per_player
    if max_assignments is not None and max_assignments <= 0:
        raise ValueError("random_unique requires max_assignments_per_player to be positive")
    if max_assignments is not None and max_assignments > len(config.games):
        raise ValueError("random_unique cannot assign more games per player than are listed in assignment_strategy.games")

constants

Constants for core module.

experiment_config

Experiment-level configuration models for assignment-driven studies.

AssignmentStrategyConfig

Bases: BaseModel

Flexible assignment-strategy shape that can parse current and future strategies.

Source code in dcs_simulation_engine/core/experiment_config.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
class AssignmentStrategyConfig(BaseModel):
    """Flexible assignment-strategy shape that can parse current and future strategies."""

    model_config = ConfigDict(extra="allow")

    strategy: str
    games: list[str] | None = None
    player_characters: list[str] | None = None
    non_player_characters: list[str] | None = None
    quota_per_game: int | None = None
    max_assignments_per_player: int | None = None
    seed: str | int | None = None
    pc_eligible_only: bool = False
    assignment_mode: Literal["auto", "player_choice"] = "auto"

    @field_validator("games")
    @classmethod
    def validate_games(cls, values: list[str] | None) -> list[str] | None:
        """Normalize experiment game references when they are supplied."""
        if values is None:
            return None

        aliases = _available_game_names()
        normalized: list[str] = []
        seen: set[str] = set()
        for value in values:
            canonical = aliases.get(_normalize_game_ref(value))
            if canonical is None:
                raise ValueError(f"Unknown game reference in assignment strategy: {value!r}")
            lowered = canonical.lower()
            if lowered in seen:
                raise ValueError(f"Duplicate game listed in assignment strategy: {canonical}")
            seen.add(lowered)
            normalized.append(canonical)
        return normalized
validate_games(values) classmethod

Normalize experiment game references when they are supplied.

Source code in dcs_simulation_engine/core/experiment_config.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@field_validator("games")
@classmethod
def validate_games(cls, values: list[str] | None) -> list[str] | None:
    """Normalize experiment game references when they are supplied."""
    if values is None:
        return None

    aliases = _available_game_names()
    normalized: list[str] = []
    seen: set[str] = set()
    for value in values:
        canonical = aliases.get(_normalize_game_ref(value))
        if canonical is None:
            raise ValueError(f"Unknown game reference in assignment strategy: {value!r}")
        lowered = canonical.lower()
        if lowered in seen:
            raise ValueError(f"Duplicate game listed in assignment strategy: {canonical}")
        seen.add(lowered)
        normalized.append(canonical)
    return normalized
ExperimentConfig

Bases: SerdeMixin, BaseModel

Top-level experiment configuration for assignment-driven studies.

Source code in dcs_simulation_engine/core/experiment_config.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
class ExperimentConfig(SerdeMixin, BaseModel):
    """Top-level experiment configuration for assignment-driven studies."""

    model_config = ConfigDict(populate_by_name=True, extra="forbid")

    name: str
    description: str = ""
    condition: Optional[Literal["learning", "static"]] = None
    assignment_strategy: AssignmentStrategyConfig
    forms: list[ExperimentForm] = Field(default_factory=list)

    @field_validator("forms", mode="before")
    @classmethod
    def normalize_forms(cls, value: Any) -> Any:
        """Accept forms as either a list or a mapping keyed by form name."""
        if value is None:
            return []
        if isinstance(value, list):
            return value
        if isinstance(value, dict):
            return [{"name": name, **payload} for name, payload in value.items()]
        raise ValueError("forms must be a list or a mapping of form names to form definitions.")

    @model_validator(mode="after")
    def validate_config(self) -> "ExperimentConfig":
        """Validate strategy-specific constraints and form names."""
        form_names = [form.name for form in self.forms]
        if len(form_names) != len(set(form_names)):
            raise ValueError("Experiment form names must be unique.")
        from dcs_simulation_engine.core.assignment_strategies import get_assignment_strategy

        get_assignment_strategy(self.assignment_strategy.strategy).validate_config(config=self)
        return self

    @property
    def games(self) -> list[str]:
        """Canonical list of games included in the experiment assignment strategy."""
        return list(self.assignment_strategy.games or [])

    def forms_for_phase(self, *, before_or_after: str) -> list[ExperimentForm]:
        """Return forms matching one phase."""
        return [form for form in self.forms if form.before_or_after == before_or_after]

    @classmethod
    def load(cls, path: str | Path) -> "ExperimentConfig":
        """Load an experiment config from YAML."""
        return cls.from_yaml(path)
games property

Canonical list of games included in the experiment assignment strategy.

forms_for_phase(*, before_or_after)

Return forms matching one phase.

Source code in dcs_simulation_engine/core/experiment_config.py
121
122
123
def forms_for_phase(self, *, before_or_after: str) -> list[ExperimentForm]:
    """Return forms matching one phase."""
    return [form for form in self.forms if form.before_or_after == before_or_after]
load(path) classmethod

Load an experiment config from YAML.

Source code in dcs_simulation_engine/core/experiment_config.py
125
126
127
128
@classmethod
def load(cls, path: str | Path) -> "ExperimentConfig":
    """Load an experiment config from YAML."""
    return cls.from_yaml(path)
normalize_forms(value) classmethod

Accept forms as either a list or a mapping keyed by form name.

Source code in dcs_simulation_engine/core/experiment_config.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
@field_validator("forms", mode="before")
@classmethod
def normalize_forms(cls, value: Any) -> Any:
    """Accept forms as either a list or a mapping keyed by form name."""
    if value is None:
        return []
    if isinstance(value, list):
        return value
    if isinstance(value, dict):
        return [{"name": name, **payload} for name, payload in value.items()]
    raise ValueError("forms must be a list or a mapping of form names to form definitions.")
validate_config()

Validate strategy-specific constraints and form names.

Source code in dcs_simulation_engine/core/experiment_config.py
105
106
107
108
109
110
111
112
113
114
@model_validator(mode="after")
def validate_config(self) -> "ExperimentConfig":
    """Validate strategy-specific constraints and form names."""
    form_names = [form.name for form in self.forms]
    if len(form_names) != len(set(form_names)):
        raise ValueError("Experiment form names must be unique.")
    from dcs_simulation_engine.core.assignment_strategies import get_assignment_strategy

    get_assignment_strategy(self.assignment_strategy.strategy).validate_config(config=self)
    return self

experiment_manager

Experiment orchestration for assignment-driven study flows.

ExperimentManager

Loads experiment configs and resolves experiment assignment workflows.

Source code in dcs_simulation_engine/core/experiment_manager.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
class ExperimentManager:
    """Loads experiment configs and resolves experiment assignment workflows."""

    _experiment_config_cache: dict[str, ExperimentConfig] = {}

    @classmethod
    def _cache_key(cls, experiment: str) -> str:
        return experiment.strip().lower()

    @classmethod
    def _load_experiment_config_into_cache(cls, experiment: str) -> bool:
        cache_key = cls._cache_key(experiment)
        if cache_key in cls._experiment_config_cache:
            return False
        experiment_config_fpath = get_experiment_config(experiment)
        cls._experiment_config_cache[cache_key] = ExperimentConfig.load(experiment_config_fpath)
        return True

    @classmethod
    def get_experiment_config_cached(cls, experiment: str) -> ExperimentConfig:
        """Return a defensive copy of a cached experiment config."""
        cls._load_experiment_config_into_cache(experiment)
        return cls._experiment_config_cache[cls._cache_key(experiment)].model_copy(deep=True)

    @classmethod
    def preload_experiment_configs(cls) -> int:
        """Load all valid experiment configs from disk into the in-memory cache."""
        experiments_dir = Path(__file__).resolve().parents[2] / "experiments"
        if not experiments_dir.exists() or not experiments_dir.is_dir():
            return 0

        discovered_names: set[str] = set()
        for path in experiments_dir.glob("*.y*ml"):
            try:
                with path.open("r", encoding="utf-8") as handle:
                    doc = yaml.safe_load(handle) or {}
                raw_name = doc.get("name")
                if isinstance(raw_name, str) and raw_name.strip():
                    discovered_names.add(raw_name.strip())
            except Exception:
                logger.debug("Skipping unreadable experiment config while preloading: {}", path, exc_info=True)

        loaded = 0
        for name in discovered_names:
            try:
                if cls._load_experiment_config_into_cache(name):
                    loaded += 1
            except Exception:
                logger.debug("Skipping invalid experiment config '{}' during preload", name, exc_info=True)

        if loaded > 0:
            logger.info("Preloaded {} experiment config(s) into ExperimentManager cache.", loaded)
        return loaded

    @classmethod
    async def ensure_experiment_async(cls, *, provider: Any, experiment_name: str) -> ExperimentRecord:
        """Persist the experiment config snapshot if it is not already stored."""
        config = cls.get_experiment_config_cached(experiment_name)
        existing = await maybe_await(provider.get_experiment(experiment_name=config.name))
        progress = await cls.compute_progress_async(provider=provider, experiment_name=config.name)
        if existing is not None:
            updated = await maybe_await(
                provider.set_experiment_progress(
                    experiment_name=config.name,
                    progress=progress,
                )
            )
            return updated or existing
        return await maybe_await(
            provider.upsert_experiment(
                experiment_name=config.name,
                description=config.description,
                config_snapshot=config.model_dump(mode="json"),
                progress=progress,
            )
        )

    @classmethod
    async def compute_progress_async(cls, *, provider: Any, experiment_name: str) -> dict[str, Any]:
        """Compute finite usability progress from assignment state."""
        config = cls.get_experiment_config_cached(experiment_name)
        strategy = cls._strategy_for(config=config)
        return await maybe_await(strategy.compute_progress_async(provider=provider, config=config))

    @classmethod
    async def compute_status_async(cls, *, provider: Any, experiment_name: str) -> dict[str, Any]:
        """Compute quota-centric status counts for an experiment."""
        config = cls.get_experiment_config_cached(experiment_name)
        strategy = cls._strategy_for(config=config)
        return await maybe_await(strategy.compute_status_async(provider=provider, config=config))

    @classmethod
    async def get_player_state_async(
        cls,
        *,
        provider: Any,
        experiment_name: str,
        player_id: str,
    ) -> dict[str, Any]:
        """Return the assignment state visible to one authenticated player."""
        config = cls.get_experiment_config_cached(experiment_name)
        active_assignment = await maybe_await(provider.get_active_assignment(experiment_name=experiment_name, player_id=player_id))
        player_assignments = await maybe_await(provider.list_assignments(experiment_name=experiment_name, player_id=player_id))
        completed_assignments = [item for item in player_assignments if item.status == "completed"]
        before_form_names = {form.name for form in config.forms_for_phase(before_or_after="before")}
        after_form_names = {form.name for form in config.forms_for_phase(before_or_after="after")}
        player_forms = await maybe_await(provider.get_player_forms(player_id=player_id, experiment_name=experiment_name))
        submitted_before_keys = set((player_forms.data if player_forms else {}).keys())
        has_submitted_before_forms = not before_form_names or before_form_names.issubset(submitted_before_keys)
        pending_post_play = next(
            (
                item
                for item in reversed(completed_assignments)
                if not after_form_names.issubset(set(item.data.get(MongoColumns.FORM_RESPONSES, {}).keys()))
            ),
            None,
        )
        strategy = cls._strategy_for(config=config)
        has_finished_experiment = len(completed_assignments) >= strategy.max_assignments_per_player(config=config)

        is_player_choice = config.assignment_strategy.assignment_mode == "player_choice"
        if (
            active_assignment is None
            and pending_post_play is None
            and has_submitted_before_forms
            and not has_finished_experiment
            and not is_player_choice
        ):
            player_record = await maybe_await(provider.get_player(player_id=player_id))
            if player_record is not None:
                active_assignment = await cls.get_or_create_assignment_async(
                    provider=provider,
                    experiment_name=config.name,
                    player=player_record,
                )

        return {
            "active_assignment": active_assignment,
            "pending_post_play": pending_post_play,
            "has_finished_experiment": has_finished_experiment,
            "has_submitted_before_forms": has_submitted_before_forms,
            "assignments": player_assignments,
        }

    @classmethod
    async def submit_before_play_async(
        cls,
        *,
        provider: Any,
        experiment_name: str,
        player_id: str,
        responses: dict[str, Any],
    ) -> AssignmentRecord | None:
        """Store before-play form answers for an authenticated player and return their assignment."""
        config = cls.get_experiment_config_cached(experiment_name)

        player_record = await maybe_await(provider.get_player(player_id=player_id))
        if player_record is None:
            raise ValueError("Authenticated player could not be loaded.")

        progress = await cls.compute_progress_async(provider=provider, experiment_name=config.name)
        if progress["is_complete"]:
            raise ValueError("This experiment is no longer accepting new participants.")

        before_forms = config.forms_for_phase(before_or_after="before")
        normalized_before_forms = cls.normalize_form_submissions(forms=before_forms, responses=responses)
        await cls.ensure_experiment_async(provider=provider, experiment_name=config.name)
        assignment = await cls.get_or_create_assignment_async(
            provider=provider,
            experiment_name=config.name,
            player=player_record,
        )
        if assignment is not None and config.assignment_strategy.assignment_mode == "auto":
            strategy = cls._strategy_for(config=config)
            if hasattr(strategy, "generate_remaining_assignments_async"):
                await strategy.generate_remaining_assignments_async(
                    provider=provider,
                    config=config,
                    player=player_record,
                )
        await cls.store_player_form_payloads_async(
            provider=provider,
            player_id=player_id,
            experiment_name=config.name,
            forms_payload=normalized_before_forms,
        )
        return assignment

    @classmethod
    async def get_or_create_assignment_async(
        cls,
        *,
        provider: Any,
        experiment_name: str,
        player: PlayerRecord,
    ) -> AssignmentRecord | None:
        """Return the active assignment for a player or create one on demand."""
        config = cls.get_experiment_config_cached(experiment_name)
        strategy = cls._strategy_for(config=config)
        return await maybe_await(strategy.get_or_create_assignment_async(provider=provider, config=config, player=player))

    @classmethod
    async def start_assignment_session_async(
        cls,
        *,
        provider: Any,
        registry: "SessionRegistry",
        experiment_name: str,
        player: PlayerRecord,
        source: str = "experiment",
    ) -> tuple["SessionEntry", AssignmentRecord]:
        """Start a gameplay session for the current assignment."""
        assignment = await maybe_await(provider.get_active_assignment(experiment_name=experiment_name, player_id=player.id))
        if assignment is None:
            raise ValueError("No active assignment is available for this player.")
        if assignment.status == "in_progress":
            raise ValueError("This assignment is already in progress.")

        manager = await SessionManager.create_async(
            game=assignment.game_name,
            provider=provider,
            source=source,
            pc_choice=assignment.character_hid,
            npc_choice=None,
            player_id=player.id,
        )
        entry = registry.add(
            player_id=player.id,
            game_name=assignment.game_name,
            manager=manager,
            experiment_name=experiment_name,
            assignment_id=assignment.assignment_id,
        )
        start_hook = getattr(manager, "start_persistence", None)
        if start_hook is not None:
            await maybe_await(start_hook(session_id=entry.session_id))

        updated_assignment = await maybe_await(
            provider.update_assignment_status(
                assignment_id=assignment.assignment_id,
                status="in_progress",
                active_session_id=entry.session_id,
            )
        )
        if updated_assignment is None:
            raise ValueError("Failed to mark experiment assignment as in progress.")
        return entry, updated_assignment

    @classmethod
    async def handle_session_terminal_state_async(
        cls,
        *,
        provider: Any,
        experiment_name: str,
        assignment_id: str,
        exit_reason: str,
    ) -> AssignmentRecord | None:
        """Map a gameplay terminal reason onto an assignment lifecycle status."""
        status = "completed" if cls._is_completion_reason(exit_reason) else "interrupted"
        updated = await maybe_await(provider.update_assignment_status(assignment_id=assignment_id, status=status))
        if status == "completed":
            await cls.ensure_experiment_async(provider=provider, experiment_name=experiment_name)
        return updated

    @classmethod
    async def store_post_play_async(
        cls,
        *,
        provider: Any,
        experiment_name: str,
        player_id: str,
        responses: dict[str, Any],
    ) -> AssignmentRecord:
        """Store all after-play forms on the latest completed assignment."""
        config = cls.get_experiment_config_cached(experiment_name)
        after_forms = config.forms_for_phase(before_or_after="after")
        state = await cls.get_player_state_async(provider=provider, experiment_name=config.name, player_id=player_id)
        assignment = state["pending_post_play"]
        if assignment is None:
            raise ValueError("No completed assignment is waiting for a post-play response.")

        normalized_after_forms = cls.normalize_form_submissions(forms=after_forms, responses=responses)
        stored = await cls.store_form_payloads_async(
            provider=provider,
            assignment_id=assignment.assignment_id,
            forms_payload=normalized_after_forms,
        )
        if stored is None:
            raise ValueError("Failed to store the post-play response.")
        return stored

    @classmethod
    async def store_form_payloads_async(
        cls,
        *,
        provider: Any,
        assignment_id: str,
        forms_payload: dict[str, dict[str, Any]],
    ) -> AssignmentRecord | None:
        """Store one or more named form payloads on an assignment row."""
        updated: AssignmentRecord | None = None
        for form_name, payload in forms_payload.items():
            updated = await maybe_await(
                provider.set_assignment_form_response(
                    assignment_id=assignment_id,
                    form_key=form_name,
                    response=payload,
                )
            )
        return updated

    @classmethod
    async def store_player_form_payloads_async(
        cls,
        *,
        provider: Any,
        player_id: str,
        experiment_name: str,
        forms_payload: dict[str, dict[str, Any]],
    ) -> None:
        """Store one or more named before-play form payloads on the player forms record."""
        for form_key, payload in forms_payload.items():
            await maybe_await(
                provider.set_player_form_response(
                    player_id=player_id,
                    experiment_name=experiment_name,
                    form_key=form_key,
                    response=payload,
                )
            )

    @classmethod
    async def get_latest_assignment_for_player_async(cls, *, provider: Any, player_id: str) -> AssignmentRecord | None:
        """Return the latest experiment assignment for a player across all experiments."""
        getter = getattr(provider, "get_latest_experiment_assignment_for_player", None)
        if getter is None:
            return None
        return await maybe_await(getter(player_id=player_id))

    @classmethod
    def normalize_form_submissions(
        cls,
        *,
        forms: list[ExperimentForm],
        responses: dict[str, Any],
    ) -> dict[str, dict[str, Any]]:
        """Validate and normalize submitted answers for one or more named forms."""
        if not isinstance(responses, dict):
            raise ValueError("Form responses must be submitted as a JSON object.")

        normalized: dict[str, dict[str, Any]] = {}
        for form in forms:
            raw_answers = responses.get(form.name, {})
            if raw_answers is None:
                raw_answers = {}
            if not isinstance(raw_answers, dict):
                raise ValueError(f"Responses for form '{form.name}' must be submitted as an object.")

            normalized_answers: dict[str, Any] = {}
            for question in form.questions:
                if question.answer_type is None:
                    continue
                raw_value = raw_answers.get(question.key or "")
                answer = cls._normalize_question_answer(question=question, raw_value=raw_value)
                normalized_answers[question.key or ""] = {
                    "key": question.key,
                    "prompt": question.prompt,
                    "answer_type": question.answer_type,
                    "required": question.required,
                    "answer": answer,
                }

            normalized[form.name] = {
                "form_name": form.name,
                "before_or_after": form.before_or_after,
                "submitted_at": utc_now(),
                "answers": normalized_answers,
            }
        return normalized

    @classmethod
    def _normalize_question_answer(cls, *, question: ExperimentFormQuestion, raw_value: Any) -> Any:
        answer_type = question.answer_type
        if answer_type is None:
            return None

        if raw_value in (None, ""):
            if answer_type == "bool":
                if question.required and raw_value is None:
                    raise ValueError(f"Missing required form field: {question.key}")
                return bool(raw_value)
            if answer_type == "multi_choice":
                if question.required and raw_value in (None, ""):
                    raise ValueError(f"Missing required form field: {question.key}")
                return []
            if question.required:
                raise ValueError(f"Missing required form field: {question.key}")
            return ""

        if answer_type in {"string", "email", "phone"}:
            value = str(raw_value).strip()
            if question.required and not value:
                raise ValueError(f"Missing required form field: {question.key}")
            return value

        if answer_type == "number":
            if isinstance(raw_value, bool):
                raise ValueError(f"Invalid numeric value for form field: {question.key}")
            if isinstance(raw_value, (int, float)):
                return raw_value
            text = str(raw_value).strip()
            if "." in text:
                return float(text)
            return int(text)

        if answer_type == "bool":
            if isinstance(raw_value, bool):
                return raw_value
            text = str(raw_value).strip().lower()
            if text in {"true", "1", "yes", "on"}:
                return True
            if text in {"false", "0", "no", "off"}:
                return False
            raise ValueError(f"Invalid boolean value for form field: {question.key}")

        if answer_type == "single_choice":
            value = str(raw_value).strip()
            options = [str(option) for option in question.options or []]
            if value not in options:
                raise ValueError(f"Invalid option for form field: {question.key}")
            return value

        if answer_type == "multi_choice":
            values = raw_value if isinstance(raw_value, list) else [raw_value]
            normalized_values = [str(item).strip() for item in values if str(item).strip()]
            options = [str(option) for option in question.options or []]
            invalid_values = [item for item in normalized_values if item not in options]
            if invalid_values:
                raise ValueError(f"Invalid option for form field: {question.key}")
            if question.required and not normalized_values:
                raise ValueError(f"Missing required form field: {question.key}")
            return normalized_values

        raise ValueError(f"Unsupported form field type: {answer_type}")

    @classmethod
    async def get_eligible_options_async(
        cls,
        *,
        provider: Any,
        experiment_name: str,
        player: PlayerRecord,
    ) -> list[dict[str, str]]:
        """Return eligible {game_name, character_hid} options for a player in player_choice mode."""
        config = cls.get_experiment_config_cached(experiment_name)
        strategy = cls._strategy_for(config=config)
        get_eligible = getattr(strategy, "get_eligible_options_async", None)
        if get_eligible is None:
            return []
        return await maybe_await(get_eligible(provider=provider, config=config, player=player))

    @classmethod
    async def create_player_choice_assignment_async(
        cls,
        *,
        provider: Any,
        experiment_name: str,
        player: PlayerRecord,
        game_name: str,
        character_hid: str,
    ) -> AssignmentRecord:
        """Create a specific assignment for a player who selected game+character manually."""
        config = cls.get_experiment_config_cached(experiment_name)
        eligible = await cls.get_eligible_options_async(
            provider=provider,
            experiment_name=experiment_name,
            player=player,
        )
        eligible_set = {(opt["game_name"], opt["character_hid"]) for opt in eligible}
        if (game_name, character_hid) not in eligible_set:
            raise ValueError("The selected game and character are not available for assignment.")
        assignment = await maybe_await(
            provider.create_assignment(
                assignment_doc={
                    MongoColumns.EXPERIMENT_NAME: config.name,
                    MongoColumns.PLAYER_ID: player.id,
                    MongoColumns.GAME_NAME: game_name,
                    MongoColumns.CHARACTER_HID: character_hid,
                    MongoColumns.STATUS: "assigned",
                    MongoColumns.FORM_RESPONSES: {},
                }
            )
        )
        if assignment is None:
            raise ValueError("Failed to create the assignment.")
        return assignment

    @classmethod
    def _strategy_for(cls, *, config: ExperimentConfig):
        """Resolve the configured assignment strategy for one experiment."""
        return get_assignment_strategy(config.assignment_strategy.strategy)

    @classmethod
    def _is_completion_reason(cls, reason: str) -> bool:
        normalized = reason.strip().lower().replace(" ", "_")
        completion_reasons = {
            "game_completed",
            "game_complete",
            "max_predictions_reached",
            "player_exited",
        }
        return normalized in completion_reasons or normalized.startswith("stopping_condition_met:")
compute_progress_async(*, provider, experiment_name) async classmethod

Compute finite usability progress from assignment state.

Source code in dcs_simulation_engine/core/experiment_manager.py
106
107
108
109
110
111
@classmethod
async def compute_progress_async(cls, *, provider: Any, experiment_name: str) -> dict[str, Any]:
    """Compute finite usability progress from assignment state."""
    config = cls.get_experiment_config_cached(experiment_name)
    strategy = cls._strategy_for(config=config)
    return await maybe_await(strategy.compute_progress_async(provider=provider, config=config))
compute_status_async(*, provider, experiment_name) async classmethod

Compute quota-centric status counts for an experiment.

Source code in dcs_simulation_engine/core/experiment_manager.py
113
114
115
116
117
118
@classmethod
async def compute_status_async(cls, *, provider: Any, experiment_name: str) -> dict[str, Any]:
    """Compute quota-centric status counts for an experiment."""
    config = cls.get_experiment_config_cached(experiment_name)
    strategy = cls._strategy_for(config=config)
    return await maybe_await(strategy.compute_status_async(provider=provider, config=config))
create_player_choice_assignment_async(*, provider, experiment_name, player, game_name, character_hid) async classmethod

Create a specific assignment for a player who selected game+character manually.

Source code in dcs_simulation_engine/core/experiment_manager.py
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
@classmethod
async def create_player_choice_assignment_async(
    cls,
    *,
    provider: Any,
    experiment_name: str,
    player: PlayerRecord,
    game_name: str,
    character_hid: str,
) -> AssignmentRecord:
    """Create a specific assignment for a player who selected game+character manually."""
    config = cls.get_experiment_config_cached(experiment_name)
    eligible = await cls.get_eligible_options_async(
        provider=provider,
        experiment_name=experiment_name,
        player=player,
    )
    eligible_set = {(opt["game_name"], opt["character_hid"]) for opt in eligible}
    if (game_name, character_hid) not in eligible_set:
        raise ValueError("The selected game and character are not available for assignment.")
    assignment = await maybe_await(
        provider.create_assignment(
            assignment_doc={
                MongoColumns.EXPERIMENT_NAME: config.name,
                MongoColumns.PLAYER_ID: player.id,
                MongoColumns.GAME_NAME: game_name,
                MongoColumns.CHARACTER_HID: character_hid,
                MongoColumns.STATUS: "assigned",
                MongoColumns.FORM_RESPONSES: {},
            }
        )
    )
    if assignment is None:
        raise ValueError("Failed to create the assignment.")
    return assignment
ensure_experiment_async(*, provider, experiment_name) async classmethod

Persist the experiment config snapshot if it is not already stored.

Source code in dcs_simulation_engine/core/experiment_manager.py
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
@classmethod
async def ensure_experiment_async(cls, *, provider: Any, experiment_name: str) -> ExperimentRecord:
    """Persist the experiment config snapshot if it is not already stored."""
    config = cls.get_experiment_config_cached(experiment_name)
    existing = await maybe_await(provider.get_experiment(experiment_name=config.name))
    progress = await cls.compute_progress_async(provider=provider, experiment_name=config.name)
    if existing is not None:
        updated = await maybe_await(
            provider.set_experiment_progress(
                experiment_name=config.name,
                progress=progress,
            )
        )
        return updated or existing
    return await maybe_await(
        provider.upsert_experiment(
            experiment_name=config.name,
            description=config.description,
            config_snapshot=config.model_dump(mode="json"),
            progress=progress,
        )
    )
get_eligible_options_async(*, provider, experiment_name, player) async classmethod

Return eligible {game_name, character_hid} options for a player in player_choice mode.

Source code in dcs_simulation_engine/core/experiment_manager.py
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
@classmethod
async def get_eligible_options_async(
    cls,
    *,
    provider: Any,
    experiment_name: str,
    player: PlayerRecord,
) -> list[dict[str, str]]:
    """Return eligible {game_name, character_hid} options for a player in player_choice mode."""
    config = cls.get_experiment_config_cached(experiment_name)
    strategy = cls._strategy_for(config=config)
    get_eligible = getattr(strategy, "get_eligible_options_async", None)
    if get_eligible is None:
        return []
    return await maybe_await(get_eligible(provider=provider, config=config, player=player))
get_experiment_config_cached(experiment) classmethod

Return a defensive copy of a cached experiment config.

Source code in dcs_simulation_engine/core/experiment_manager.py
47
48
49
50
51
@classmethod
def get_experiment_config_cached(cls, experiment: str) -> ExperimentConfig:
    """Return a defensive copy of a cached experiment config."""
    cls._load_experiment_config_into_cache(experiment)
    return cls._experiment_config_cache[cls._cache_key(experiment)].model_copy(deep=True)
get_latest_assignment_for_player_async(*, provider, player_id) async classmethod

Return the latest experiment assignment for a player across all experiments.

Source code in dcs_simulation_engine/core/experiment_manager.py
360
361
362
363
364
365
366
@classmethod
async def get_latest_assignment_for_player_async(cls, *, provider: Any, player_id: str) -> AssignmentRecord | None:
    """Return the latest experiment assignment for a player across all experiments."""
    getter = getattr(provider, "get_latest_experiment_assignment_for_player", None)
    if getter is None:
        return None
    return await maybe_await(getter(player_id=player_id))
get_or_create_assignment_async(*, provider, experiment_name, player) async classmethod

Return the active assignment for a player or create one on demand.

Source code in dcs_simulation_engine/core/experiment_manager.py
217
218
219
220
221
222
223
224
225
226
227
228
@classmethod
async def get_or_create_assignment_async(
    cls,
    *,
    provider: Any,
    experiment_name: str,
    player: PlayerRecord,
) -> AssignmentRecord | None:
    """Return the active assignment for a player or create one on demand."""
    config = cls.get_experiment_config_cached(experiment_name)
    strategy = cls._strategy_for(config=config)
    return await maybe_await(strategy.get_or_create_assignment_async(provider=provider, config=config, player=player))
get_player_state_async(*, provider, experiment_name, player_id) async classmethod

Return the assignment state visible to one authenticated player.

Source code in dcs_simulation_engine/core/experiment_manager.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
@classmethod
async def get_player_state_async(
    cls,
    *,
    provider: Any,
    experiment_name: str,
    player_id: str,
) -> dict[str, Any]:
    """Return the assignment state visible to one authenticated player."""
    config = cls.get_experiment_config_cached(experiment_name)
    active_assignment = await maybe_await(provider.get_active_assignment(experiment_name=experiment_name, player_id=player_id))
    player_assignments = await maybe_await(provider.list_assignments(experiment_name=experiment_name, player_id=player_id))
    completed_assignments = [item for item in player_assignments if item.status == "completed"]
    before_form_names = {form.name for form in config.forms_for_phase(before_or_after="before")}
    after_form_names = {form.name for form in config.forms_for_phase(before_or_after="after")}
    player_forms = await maybe_await(provider.get_player_forms(player_id=player_id, experiment_name=experiment_name))
    submitted_before_keys = set((player_forms.data if player_forms else {}).keys())
    has_submitted_before_forms = not before_form_names or before_form_names.issubset(submitted_before_keys)
    pending_post_play = next(
        (
            item
            for item in reversed(completed_assignments)
            if not after_form_names.issubset(set(item.data.get(MongoColumns.FORM_RESPONSES, {}).keys()))
        ),
        None,
    )
    strategy = cls._strategy_for(config=config)
    has_finished_experiment = len(completed_assignments) >= strategy.max_assignments_per_player(config=config)

    is_player_choice = config.assignment_strategy.assignment_mode == "player_choice"
    if (
        active_assignment is None
        and pending_post_play is None
        and has_submitted_before_forms
        and not has_finished_experiment
        and not is_player_choice
    ):
        player_record = await maybe_await(provider.get_player(player_id=player_id))
        if player_record is not None:
            active_assignment = await cls.get_or_create_assignment_async(
                provider=provider,
                experiment_name=config.name,
                player=player_record,
            )

    return {
        "active_assignment": active_assignment,
        "pending_post_play": pending_post_play,
        "has_finished_experiment": has_finished_experiment,
        "has_submitted_before_forms": has_submitted_before_forms,
        "assignments": player_assignments,
    }
handle_session_terminal_state_async(*, provider, experiment_name, assignment_id, exit_reason) async classmethod

Map a gameplay terminal reason onto an assignment lifecycle status.

Source code in dcs_simulation_engine/core/experiment_manager.py
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
@classmethod
async def handle_session_terminal_state_async(
    cls,
    *,
    provider: Any,
    experiment_name: str,
    assignment_id: str,
    exit_reason: str,
) -> AssignmentRecord | None:
    """Map a gameplay terminal reason onto an assignment lifecycle status."""
    status = "completed" if cls._is_completion_reason(exit_reason) else "interrupted"
    updated = await maybe_await(provider.update_assignment_status(assignment_id=assignment_id, status=status))
    if status == "completed":
        await cls.ensure_experiment_async(provider=provider, experiment_name=experiment_name)
    return updated
normalize_form_submissions(*, forms, responses) classmethod

Validate and normalize submitted answers for one or more named forms.

Source code in dcs_simulation_engine/core/experiment_manager.py
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
@classmethod
def normalize_form_submissions(
    cls,
    *,
    forms: list[ExperimentForm],
    responses: dict[str, Any],
) -> dict[str, dict[str, Any]]:
    """Validate and normalize submitted answers for one or more named forms."""
    if not isinstance(responses, dict):
        raise ValueError("Form responses must be submitted as a JSON object.")

    normalized: dict[str, dict[str, Any]] = {}
    for form in forms:
        raw_answers = responses.get(form.name, {})
        if raw_answers is None:
            raw_answers = {}
        if not isinstance(raw_answers, dict):
            raise ValueError(f"Responses for form '{form.name}' must be submitted as an object.")

        normalized_answers: dict[str, Any] = {}
        for question in form.questions:
            if question.answer_type is None:
                continue
            raw_value = raw_answers.get(question.key or "")
            answer = cls._normalize_question_answer(question=question, raw_value=raw_value)
            normalized_answers[question.key or ""] = {
                "key": question.key,
                "prompt": question.prompt,
                "answer_type": question.answer_type,
                "required": question.required,
                "answer": answer,
            }

        normalized[form.name] = {
            "form_name": form.name,
            "before_or_after": form.before_or_after,
            "submitted_at": utc_now(),
            "answers": normalized_answers,
        }
    return normalized
preload_experiment_configs() classmethod

Load all valid experiment configs from disk into the in-memory cache.

Source code in dcs_simulation_engine/core/experiment_manager.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
@classmethod
def preload_experiment_configs(cls) -> int:
    """Load all valid experiment configs from disk into the in-memory cache."""
    experiments_dir = Path(__file__).resolve().parents[2] / "experiments"
    if not experiments_dir.exists() or not experiments_dir.is_dir():
        return 0

    discovered_names: set[str] = set()
    for path in experiments_dir.glob("*.y*ml"):
        try:
            with path.open("r", encoding="utf-8") as handle:
                doc = yaml.safe_load(handle) or {}
            raw_name = doc.get("name")
            if isinstance(raw_name, str) and raw_name.strip():
                discovered_names.add(raw_name.strip())
        except Exception:
            logger.debug("Skipping unreadable experiment config while preloading: {}", path, exc_info=True)

    loaded = 0
    for name in discovered_names:
        try:
            if cls._load_experiment_config_into_cache(name):
                loaded += 1
        except Exception:
            logger.debug("Skipping invalid experiment config '{}' during preload", name, exc_info=True)

    if loaded > 0:
        logger.info("Preloaded {} experiment config(s) into ExperimentManager cache.", loaded)
    return loaded
start_assignment_session_async(*, provider, registry, experiment_name, player, source='experiment') async classmethod

Start a gameplay session for the current assignment.

Source code in dcs_simulation_engine/core/experiment_manager.py
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
@classmethod
async def start_assignment_session_async(
    cls,
    *,
    provider: Any,
    registry: "SessionRegistry",
    experiment_name: str,
    player: PlayerRecord,
    source: str = "experiment",
) -> tuple["SessionEntry", AssignmentRecord]:
    """Start a gameplay session for the current assignment."""
    assignment = await maybe_await(provider.get_active_assignment(experiment_name=experiment_name, player_id=player.id))
    if assignment is None:
        raise ValueError("No active assignment is available for this player.")
    if assignment.status == "in_progress":
        raise ValueError("This assignment is already in progress.")

    manager = await SessionManager.create_async(
        game=assignment.game_name,
        provider=provider,
        source=source,
        pc_choice=assignment.character_hid,
        npc_choice=None,
        player_id=player.id,
    )
    entry = registry.add(
        player_id=player.id,
        game_name=assignment.game_name,
        manager=manager,
        experiment_name=experiment_name,
        assignment_id=assignment.assignment_id,
    )
    start_hook = getattr(manager, "start_persistence", None)
    if start_hook is not None:
        await maybe_await(start_hook(session_id=entry.session_id))

    updated_assignment = await maybe_await(
        provider.update_assignment_status(
            assignment_id=assignment.assignment_id,
            status="in_progress",
            active_session_id=entry.session_id,
        )
    )
    if updated_assignment is None:
        raise ValueError("Failed to mark experiment assignment as in progress.")
    return entry, updated_assignment
store_form_payloads_async(*, provider, assignment_id, forms_payload) async classmethod

Store one or more named form payloads on an assignment row.

Source code in dcs_simulation_engine/core/experiment_manager.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
@classmethod
async def store_form_payloads_async(
    cls,
    *,
    provider: Any,
    assignment_id: str,
    forms_payload: dict[str, dict[str, Any]],
) -> AssignmentRecord | None:
    """Store one or more named form payloads on an assignment row."""
    updated: AssignmentRecord | None = None
    for form_name, payload in forms_payload.items():
        updated = await maybe_await(
            provider.set_assignment_form_response(
                assignment_id=assignment_id,
                form_key=form_name,
                response=payload,
            )
        )
    return updated
store_player_form_payloads_async(*, provider, player_id, experiment_name, forms_payload) async classmethod

Store one or more named before-play form payloads on the player forms record.

Source code in dcs_simulation_engine/core/experiment_manager.py
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
@classmethod
async def store_player_form_payloads_async(
    cls,
    *,
    provider: Any,
    player_id: str,
    experiment_name: str,
    forms_payload: dict[str, dict[str, Any]],
) -> None:
    """Store one or more named before-play form payloads on the player forms record."""
    for form_key, payload in forms_payload.items():
        await maybe_await(
            provider.set_player_form_response(
                player_id=player_id,
                experiment_name=experiment_name,
                form_key=form_key,
                response=payload,
            )
        )
store_post_play_async(*, provider, experiment_name, player_id, responses) async classmethod

Store all after-play forms on the latest completed assignment.

Source code in dcs_simulation_engine/core/experiment_manager.py
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
@classmethod
async def store_post_play_async(
    cls,
    *,
    provider: Any,
    experiment_name: str,
    player_id: str,
    responses: dict[str, Any],
) -> AssignmentRecord:
    """Store all after-play forms on the latest completed assignment."""
    config = cls.get_experiment_config_cached(experiment_name)
    after_forms = config.forms_for_phase(before_or_after="after")
    state = await cls.get_player_state_async(provider=provider, experiment_name=config.name, player_id=player_id)
    assignment = state["pending_post_play"]
    if assignment is None:
        raise ValueError("No completed assignment is waiting for a post-play response.")

    normalized_after_forms = cls.normalize_form_submissions(forms=after_forms, responses=responses)
    stored = await cls.store_form_payloads_async(
        provider=provider,
        assignment_id=assignment.assignment_id,
        forms_payload=normalized_after_forms,
    )
    if stored is None:
        raise ValueError("Failed to store the post-play response.")
    return stored
submit_before_play_async(*, provider, experiment_name, player_id, responses) async classmethod

Store before-play form answers for an authenticated player and return their assignment.

Source code in dcs_simulation_engine/core/experiment_manager.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
@classmethod
async def submit_before_play_async(
    cls,
    *,
    provider: Any,
    experiment_name: str,
    player_id: str,
    responses: dict[str, Any],
) -> AssignmentRecord | None:
    """Store before-play form answers for an authenticated player and return their assignment."""
    config = cls.get_experiment_config_cached(experiment_name)

    player_record = await maybe_await(provider.get_player(player_id=player_id))
    if player_record is None:
        raise ValueError("Authenticated player could not be loaded.")

    progress = await cls.compute_progress_async(provider=provider, experiment_name=config.name)
    if progress["is_complete"]:
        raise ValueError("This experiment is no longer accepting new participants.")

    before_forms = config.forms_for_phase(before_or_after="before")
    normalized_before_forms = cls.normalize_form_submissions(forms=before_forms, responses=responses)
    await cls.ensure_experiment_async(provider=provider, experiment_name=config.name)
    assignment = await cls.get_or_create_assignment_async(
        provider=provider,
        experiment_name=config.name,
        player=player_record,
    )
    if assignment is not None and config.assignment_strategy.assignment_mode == "auto":
        strategy = cls._strategy_for(config=config)
        if hasattr(strategy, "generate_remaining_assignments_async"):
            await strategy.generate_remaining_assignments_async(
                provider=provider,
                config=config,
                player=player_record,
            )
    await cls.store_player_form_payloads_async(
        provider=provider,
        player_id=player_id,
        experiment_name=config.name,
        forms_payload=normalized_before_forms,
    )
    return assignment

forms

Shared form models for experiment workflows.

ExperimentForm

Bases: BaseModel

Named experiment form shown before or after gameplay.

Source code in dcs_simulation_engine/core/forms.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
class ExperimentForm(BaseModel):
    """Named experiment form shown before or after gameplay."""

    model_config = ConfigDict(extra="forbid")

    name: str
    before_or_after: Literal["before", "after"]
    questions: list[ExperimentFormQuestion] = Field(default_factory=list)

    @field_validator("name")
    @classmethod
    def name_format(cls, value: str) -> str:
        """Normalize and validate form names."""
        normalized = _normalize_identifier(value)
        if not normalized:
            raise ValueError("Form names must contain letters or numbers.")
        return normalized

    @model_validator(mode="after")
    def assign_question_keys(self) -> "ExperimentForm":
        """Ensure every question has a stable key."""
        seen: set[str] = set()
        for index, question in enumerate(self.questions, start=1):
            candidate = question.key or _normalize_identifier(question.prompt.splitlines()[0])
            if not candidate:
                candidate = f"{self.name}_question_{index}"
            candidate = _normalize_identifier(candidate)
            if not candidate:
                candidate = f"{self.name}_question_{index}"
            if candidate in seen:
                candidate = f"{candidate}_{index}"
            question.key = candidate
            seen.add(candidate)
        return self
assign_question_keys()

Ensure every question has a stable key.

Source code in dcs_simulation_engine/core/forms.py
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
@model_validator(mode="after")
def assign_question_keys(self) -> "ExperimentForm":
    """Ensure every question has a stable key."""
    seen: set[str] = set()
    for index, question in enumerate(self.questions, start=1):
        candidate = question.key or _normalize_identifier(question.prompt.splitlines()[0])
        if not candidate:
            candidate = f"{self.name}_question_{index}"
        candidate = _normalize_identifier(candidate)
        if not candidate:
            candidate = f"{self.name}_question_{index}"
        if candidate in seen:
            candidate = f"{candidate}_{index}"
        question.key = candidate
        seen.add(candidate)
    return self
name_format(value) classmethod

Normalize and validate form names.

Source code in dcs_simulation_engine/core/forms.py
64
65
66
67
68
69
70
71
@field_validator("name")
@classmethod
def name_format(cls, value: str) -> str:
    """Normalize and validate form names."""
    normalized = _normalize_identifier(value)
    if not normalized:
        raise ValueError("Form names must contain letters or numbers.")
    return normalized
ExperimentFormQuestion

Bases: BaseModel

Question model used by experiment forms.

Source code in dcs_simulation_engine/core/forms.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class ExperimentFormQuestion(BaseModel):
    """Question model used by experiment forms."""

    model_config = ConfigDict(extra="forbid")

    key: str | None = None
    prompt: str
    answer_type: (
        Literal[
            "string",
            "bool",
            "single_choice",
            "multi_choice",
            "number",
            "email",
            "phone",
        ]
        | None
    ) = None
    options: list[Any] | None = None
    required: bool = False

    @model_validator(mode="after")
    def validate_question(self) -> "ExperimentFormQuestion":
        """Validate options vs answer type."""
        if self.answer_type in {"single_choice", "multi_choice"} and not self.options:
            raise ValueError("Choice questions require options.")
        if self.answer_type not in {"single_choice", "multi_choice"} and self.options is not None:
            raise ValueError("Only choice questions may declare options.")
        return self
validate_question()

Validate options vs answer type.

Source code in dcs_simulation_engine/core/forms.py
45
46
47
48
49
50
51
52
@model_validator(mode="after")
def validate_question(self) -> "ExperimentFormQuestion":
    """Validate options vs answer type."""
    if self.answer_type in {"single_choice", "multi_choice"} and not self.options:
        raise ValueError("Choice questions require options.")
    if self.answer_type not in {"single_choice", "multi_choice"} and self.options is not None:
        raise ValueError("Only choice questions may declare options.")
    return self

game

Base classes for new-style game implementations.

Game

Base class for new-style games.

Replaces build_graph_config() / SimulationGraph for games that prefer to express their logic directly in Python rather than as a LangGraph graph.

SessionManager drives this class instead of RunManager.

Source code in dcs_simulation_engine/core/game.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class Game:
    """Base class for new-style games.

    Replaces build_graph_config() / SimulationGraph for games that prefer
    to express their logic directly in Python rather than as a LangGraph graph.

    SessionManager drives this class instead of RunManager.
    """

    async def step(self, user_input: str | None = None) -> AsyncIterator[GameEvent]:
        """Advance the game one turn.

        Async-yields one or more GameEvents. Handles command detection,
        validation, AI calls, and lifecycle transitions internally.
        """
        raise NotImplementedError

    def exit(self, reason: str) -> None:
        """Signal the game to end."""
        raise NotImplementedError

    @property
    def exited(self) -> bool:
        """True if the game has ended."""
        raise NotImplementedError

    @property
    def exit_reason(self) -> str:
        """Reason the game ended, or empty string."""
        raise NotImplementedError

    @classmethod
    def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "Game":
        """Factory method called by SessionManager.

        Receives character records loaded from the DB and any additional
        kwargs (e.g. model names). Returns a fully initialised Game instance.
        """
        raise NotImplementedError
exit_reason property

Reason the game ended, or empty string.

exited property

True if the game has ended.

create_from_context(pc, npc, **kwargs) classmethod

Factory method called by SessionManager.

Receives character records loaded from the DB and any additional kwargs (e.g. model names). Returns a fully initialised Game instance.

Source code in dcs_simulation_engine/core/game.py
55
56
57
58
59
60
61
62
@classmethod
def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "Game":
    """Factory method called by SessionManager.

    Receives character records loaded from the DB and any additional
    kwargs (e.g. model names). Returns a fully initialised Game instance.
    """
    raise NotImplementedError
exit(reason)

Signal the game to end.

Source code in dcs_simulation_engine/core/game.py
41
42
43
def exit(self, reason: str) -> None:
    """Signal the game to end."""
    raise NotImplementedError
step(user_input=None) async

Advance the game one turn.

Async-yields one or more GameEvents. Handles command detection, validation, AI calls, and lifecycle transitions internally.

Source code in dcs_simulation_engine/core/game.py
33
34
35
36
37
38
39
async def step(self, user_input: str | None = None) -> AsyncIterator[GameEvent]:
    """Advance the game one turn.

    Async-yields one or more GameEvents. Handles command detection,
    validation, AI calls, and lifecycle transitions internally.
    """
    raise NotImplementedError
GameEvent

Bases: NamedTuple

A single event yielded by a game step.

Source code in dcs_simulation_engine/core/game.py
10
11
12
13
14
15
16
17
18
19
20
21
class GameEvent(NamedTuple):
    """A single event yielded by a game step."""

    type: str
    content: str
    event_ts: datetime
    command_response: bool = False

    @classmethod
    def now(cls, *, type: str, content: str, command_response: bool = False) -> "GameEvent":
        """Build an event stamped with the current wall-clock time."""
        return cls(type=type, content=content, event_ts=utc_now(), command_response=command_response)
now(*, type, content, command_response=False) classmethod

Build an event stamped with the current wall-clock time.

Source code in dcs_simulation_engine/core/game.py
18
19
20
21
@classmethod
def now(cls, *, type: str, content: str, command_response: bool = False) -> "GameEvent":
    """Build an event stamped with the current wall-clock time."""
    return cls(type=type, content=content, event_ts=utc_now(), command_response=command_response)

game_config

Base game config module.

GameConfig

Bases: SerdeMixin, BaseModel

Top-level configuration for the game.

Source code in dcs_simulation_engine/core/game_config.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
class GameConfig(SerdeMixin, BaseModel):
    """Top-level configuration for the game."""

    model_config = ConfigDict(populate_by_name=True, extra="forbid")

    name: str
    description: str
    version: VersionStr
    authors: Optional[List[str]] = Field(default_factory=lambda: ["DCS"])
    stopping_conditions: Dict[str, Any] = Field(default_factory=dict)
    forms: List[ExperimentForm] = Field(default_factory=list)

    # Dotted import path to the game engine class, e.g.
    # "dcs_simulation_engine.games.explore.ExploreGame"
    game_class: str

    def get_game_class_instance(self) -> Any:
        """Dynamically import and instantiate the game engine class."""
        module_path, class_name = self.game_class.rsplit(".", 1)
        module = importlib.import_module(module_path)
        cls = getattr(module, class_name)
        return cls()

    @classmethod
    def load(cls, path: Any) -> "GameConfig":
        """Load a GameConfig from a YAML file."""
        return cls.from_yaml(path)

    def get_valid_characters(
        self,
        *,
        player_id: str | None = None,
        provider: DataProvider,
        pc_eligible_only: bool = False,
    ) -> Tuple[List[Tuple[str, str]], List[Tuple[str, str]]]:
        """Return (valid_pcs, valid_npcs) as (display_string, hid) tuples."""
        _ = player_id
        all_chars: List[CharacterRecord] = list(provider.get_characters())  # type: ignore[arg-type]
        if pc_eligible_only:
            pc_chars = [r for r in all_chars if r.data.get("pc_eligible", True)]
        else:
            pc_chars = all_chars
        pc_choices = [(record.hid, record.hid) for record in pc_chars]
        npc_choices = [(record.hid, record.hid) for record in all_chars]
        return pc_choices, npc_choices

    async def get_valid_characters_async(
        self,
        *,
        player_id: str | None = None,
        provider: Any,
        pc_eligible_only: bool = False,
    ) -> Tuple[List[Tuple[str, str]], List[Tuple[str, str]]]:
        """Async-safe variant of get_valid_characters for async providers."""
        _ = player_id
        chars = await maybe_await(provider.get_characters())
        all_chars: List[CharacterRecord] = list(chars)  # type: ignore[arg-type]
        if pc_eligible_only:
            pc_chars = [r for r in all_chars if r.data.get("pc_eligible", True)]
        else:
            pc_chars = all_chars
        pc_choices = [(record.hid, record.hid) for record in pc_chars]
        npc_choices = [(record.hid, record.hid) for record in all_chars]
        return pc_choices, npc_choices
get_game_class_instance()

Dynamically import and instantiate the game engine class.

Source code in dcs_simulation_engine/core/game_config.py
42
43
44
45
46
47
def get_game_class_instance(self) -> Any:
    """Dynamically import and instantiate the game engine class."""
    module_path, class_name = self.game_class.rsplit(".", 1)
    module = importlib.import_module(module_path)
    cls = getattr(module, class_name)
    return cls()
get_valid_characters(*, player_id=None, provider, pc_eligible_only=False)

Return (valid_pcs, valid_npcs) as (display_string, hid) tuples.

Source code in dcs_simulation_engine/core/game_config.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
def get_valid_characters(
    self,
    *,
    player_id: str | None = None,
    provider: DataProvider,
    pc_eligible_only: bool = False,
) -> Tuple[List[Tuple[str, str]], List[Tuple[str, str]]]:
    """Return (valid_pcs, valid_npcs) as (display_string, hid) tuples."""
    _ = player_id
    all_chars: List[CharacterRecord] = list(provider.get_characters())  # type: ignore[arg-type]
    if pc_eligible_only:
        pc_chars = [r for r in all_chars if r.data.get("pc_eligible", True)]
    else:
        pc_chars = all_chars
    pc_choices = [(record.hid, record.hid) for record in pc_chars]
    npc_choices = [(record.hid, record.hid) for record in all_chars]
    return pc_choices, npc_choices
get_valid_characters_async(*, player_id=None, provider, pc_eligible_only=False) async

Async-safe variant of get_valid_characters for async providers.

Source code in dcs_simulation_engine/core/game_config.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
async def get_valid_characters_async(
    self,
    *,
    player_id: str | None = None,
    provider: Any,
    pc_eligible_only: bool = False,
) -> Tuple[List[Tuple[str, str]], List[Tuple[str, str]]]:
    """Async-safe variant of get_valid_characters for async providers."""
    _ = player_id
    chars = await maybe_await(provider.get_characters())
    all_chars: List[CharacterRecord] = list(chars)  # type: ignore[arg-type]
    if pc_eligible_only:
        pc_chars = [r for r in all_chars if r.data.get("pc_eligible", True)]
    else:
        pc_chars = all_chars
    pc_choices = [(record.hid, record.hid) for record in pc_chars]
    npc_choices = [(record.hid, record.hid) for record in all_chars]
    return pc_choices, npc_choices
load(path) classmethod

Load a GameConfig from a YAML file.

Source code in dcs_simulation_engine/core/game_config.py
49
50
51
52
@classmethod
def load(cls, path: Any) -> "GameConfig":
    """Load a GameConfig from a YAML file."""
    return cls.from_yaml(path)

session_event_recorder

Session event persistence for deterministic transcript reconstruction.

RecordedSessionEvent

Bases: NamedTuple

Metadata for an event that has been queued for persistence.

Source code in dcs_simulation_engine/core/session_event_recorder.py
214
215
216
217
218
class RecordedSessionEvent(NamedTuple):
    """Metadata for an event that has been queued for persistence."""

    event_id: str
    seq: int
SessionEventRecorder

Session-scoped persistence helper for sessions + session_events.

Source code in dcs_simulation_engine/core/session_event_recorder.py
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
class SessionEventRecorder:
    """Session-scoped persistence helper for `sessions` + `session_events`."""

    def __init__(
        self,
        *,
        db: AsyncDatabase[Any],
        session_doc: dict[str, Any],
        batch_size: int = 20,
        flush_interval_ms: int = 200,
        max_queue_size: int = 1000,
    ) -> None:
        self._db = db
        self._session_doc = dict(session_doc)
        self._session_id = str(session_doc[MongoColumns.SESSION_ID])
        self._events_coll = db[MongoColumns.SESSION_EVENTS]
        self._sessions_coll = db[MongoColumns.SESSIONS]
        self._writer = AsyncMongoWriter[dict[str, Any]](
            collection=self._events_coll,
            batch_size=batch_size,
            flush_interval_ms=flush_interval_ms,
            max_queue_size=max_queue_size,
            persisted_at_field=MongoColumns.PERSISTED_AT,
            ignore_duplicate_key_errors=True,
        )
        self._seq = 0
        self._finalized = False
        self._entered = False

    @property
    def session_id(self) -> str:
        return self._session_id

    @property
    def last_seq(self) -> int:
        return self._seq

    async def __aenter__(self) -> "SessionEventRecorder":
        if self._entered:
            return self
        self._entered = True
        await self._writer.__aenter__()
        await self._sessions_coll.insert_one(self._session_doc)
        return self

    async def __aexit__(self, exc_type, exc, tb) -> None:
        try:
            await self._writer.flush()
        finally:
            await self._writer.__aexit__(exc_type, exc, tb)

    async def flush_pending(self) -> None:
        """Force currently buffered event docs to Mongo."""
        await self._writer.flush()

    async def record_inbound(
        self,
        *,
        content: str,
        turn_index: int,
        event_type: str,
        command_name: str | None = None,
        command_args: str | None = None,
    ) -> "RecordedSessionEvent":
        return await self._enqueue_event(
            direction="inbound",
            event_source="user",
            event_type=event_type,
            content=content,
            content_format="plain_text",
            turn_index=turn_index,
            command_name=command_name,
            command_args=command_args,
            event_ts=None,
        )

    async def record_outbound(
        self,
        *,
        event_type: str,
        event_source: str,
        content: str,
        turn_index: int,
        command_name: str | None = None,
        command_args: str | None = None,
        event_ts: datetime | None = None,
    ) -> "RecordedSessionEvent":
        return await self._enqueue_event(
            direction="outbound",
            event_source=event_source,
            event_type=event_type,
            content=content,
            content_format="markdown",
            turn_index=turn_index,
            command_name=command_name,
            command_args=command_args,
            event_ts=event_ts,
        )

    async def record_internal(self, *, event_type: str, detail: str, turn_index: int) -> "RecordedSessionEvent":
        return await self._enqueue_event(
            direction="internal",
            event_source="system",
            event_type=event_type,
            content=f"{event_type}: {detail}",
            content_format="plain_text",
            turn_index=turn_index,
            command_name=None,
            command_args=None,
            event_ts=None,
        )

    async def finalize(
        self,
        *,
        termination_reason: str,
        status: str,
        turns_completed: int,
    ) -> None:
        if self._finalized:
            return
        self._finalized = True

        ended_at = utc_now()
        await self.record_internal(event_type="session_end", detail=termination_reason, turn_index=turns_completed)
        await self._writer.flush()
        await self._sessions_coll.update_one(
            {MongoColumns.SESSION_ID: self._session_id},
            {
                "$set": {
                    MongoColumns.STATUS: status,
                    MongoColumns.TERMINATION_REASON: termination_reason,
                    MongoColumns.SESSION_ENDED_AT: ended_at,
                    MongoColumns.TURNS_COMPLETED: turns_completed,
                    MongoColumns.LAST_SEQ: self._seq,
                    MongoColumns.UPDATED_AT: utc_now(),
                }
            },
        )
        logger.info("Finalized session persistence: {} ({})", self._session_id, termination_reason)

    async def _enqueue_event(
        self,
        *,
        direction: str,
        event_source: str,
        event_type: str,
        content: str,
        content_format: str,
        turn_index: int,
        command_name: str | None,
        command_args: str | None,
        event_ts: datetime | None,
    ) -> "RecordedSessionEvent":
        _validate_event_classification(
            direction=direction,
            event_source=event_source,
            event_type=event_type,
        )
        ts = event_ts or utc_now()
        self._seq += 1
        event_id = str(uuid4())
        doc = {
            MongoColumns.SESSION_ID: self._session_id,
            MongoColumns.SEQ: self._seq,
            MongoColumns.EVENT_ID: event_id,
            MongoColumns.EVENT_TS: ts,
            MongoColumns.DIRECTION: direction,
            MongoColumns.EVENT_TYPE: event_type,
            MongoColumns.EVENT_SOURCE: event_source,
            MongoColumns.CONTENT: content,
            MongoColumns.CONTENT_FORMAT: content_format,
            MongoColumns.TURN_INDEX: turn_index,
            MongoColumns.COMMAND_NAME: command_name,
            MongoColumns.COMMAND_ARGS: command_args,
            MongoColumns.VISIBLE_TO_USER: True,
        }
        await self._writer.enqueue(doc)
        return RecordedSessionEvent(event_id=event_id, seq=self._seq)
flush_pending() async

Force currently buffered event docs to Mongo.

Source code in dcs_simulation_engine/core/session_event_recorder.py
84
85
86
async def flush_pending(self) -> None:
    """Force currently buffered event docs to Mongo."""
    await self._writer.flush()

session_manager

SessionManager: drives new-style Game classes.

SessionManager

Manages a single session of a Game.

Source code in dcs_simulation_engine/core/session_manager.py
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
class SessionManager:
    """Manages a single session of a Game."""

    _game_config_cache: dict[str, GameConfig] = {}

    @classmethod
    def _cache_key(cls, game: str) -> str:
        """Normalize cache lookup key for one exact game name."""
        return game.strip()

    @classmethod
    def _load_game_config_into_cache(cls, game: str) -> bool:
        """Load and cache one game config by name if it's not already cached."""
        cache_key = cls._cache_key(game)
        if cache_key in cls._game_config_cache:
            return False
        game_config_fpath = get_game_config(game)
        cls._game_config_cache[cache_key] = GameConfig.load(game_config_fpath)
        return True

    @classmethod
    def get_game_config_cached(cls, game: str) -> GameConfig:
        """Return a defensive copy of a cached game config, loading it on first use."""
        cls._load_game_config_into_cache(game)
        return cls._game_config_cache[cls._cache_key(game)].model_copy(deep=True)

    def __init__(
        self,
        name: str,
        game: Game,
        game_config: GameConfig,
        provider: DataProvider | Any,
        source: str = "unknown",
        player_id: Optional[str] = None,
        stopping_conditions: Optional[Dict[str, List[str]]] = None,
    ) -> None:
        """Initialize session state, runtime counters, and persistence hooks."""
        self.name = name
        self.game = game
        self.game_config = game_config
        self._provider = provider
        self.source = source
        self.player_id = player_id
        self.start_ts: datetime = utc_now()
        self.end_ts: Optional[datetime] = None
        self._exited = False
        self._exit_reason = ""
        self._turn_count = 0
        self._events: List[Dict[str, Any]] = []
        self._saved: bool = False
        self._session_id: str | None = None
        self._recorder: SessionEventRecorder | None = None
        self._recorder_open = False
        self._finalized = False
        self.stopping_conditions: Dict[str, List[str]] = stopping_conditions or {
            "turns": [">500"],
            "runtime_seconds": [">3600"],
        }

    @classmethod
    async def create_async(
        cls,
        game: "str | GameConfig",
        provider: Any,
        source: str = "unknown",
        pc_choice: Optional[str] = None,
        npc_choice: Optional[str] = None,
        player_id: Optional[str] = None,
    ) -> "SessionManager":
        """Create a session for async runtime paths."""
        if isinstance(game, str):
            game_config = cls.get_game_config_cached(game)
        elif isinstance(game, GameConfig):
            game_config = game
        else:
            raise TypeError(f"Invalid game parameter type: {type(game)}")

        get_valid = getattr(game_config, "get_valid_characters_async", None)
        if get_valid is None:
            valid_pcs, valid_npcs = await maybe_await(game_config.get_valid_characters(player_id=player_id, provider=provider))
        else:
            valid_pcs, valid_npcs = await maybe_await(get_valid(player_id=player_id, provider=provider))
        valid_pc_hids = [hid for _, hid in valid_pcs]
        valid_npc_hids = [hid for _, hid in valid_npcs]
        pc_hid, npc_hid = cls._validate_choices(
            valid_pc_hids=valid_pc_hids,
            valid_npc_hids=valid_npc_hids,
            pc_choice=pc_choice,
            npc_choice=npc_choice,
        )

        pc: CharacterRecord = await maybe_await(provider.get_character(hid=pc_hid))
        npc: CharacterRecord = await maybe_await(provider.get_character(hid=npc_hid))
        game_instance = cls._build_game_instance(game_config=game_config, pc=pc, npc=npc)
        session = cls._build_session(
            game_config=game_config,
            game_instance=game_instance,
            provider=provider,
            source=source,
            player_id=player_id,
        )
        logger.info("SessionManager created: {}, pc={}, npc={}", session.name, pc_hid, npc_hid)
        return session

    @classmethod
    def preload_game_configs(cls) -> int:
        """Load all valid game configs from disk into the in-memory cache."""
        games_dir = Path(__file__).resolve().parents[2] / "games"
        if not games_dir.exists() or not games_dir.is_dir():
            return 0

        discovered_names: set[str] = set()
        for path in games_dir.glob("*.y*ml"):
            try:
                with path.open("r", encoding="utf-8") as f:
                    doc = yaml.safe_load(f) or {}
                raw_name = doc.get("name")
                if isinstance(raw_name, str) and raw_name.strip():
                    discovered_names.add(raw_name.strip())
            except Exception:
                logger.debug("Skipping unreadable game config while preloading: {}", path, exc_info=True)

        loaded = 0
        for name in discovered_names:
            try:
                if cls._load_game_config_into_cache(name):
                    loaded += 1
            except Exception:
                logger.debug("Skipping invalid game config '{}' during preload", name, exc_info=True)

        if loaded > 0:
            logger.info("Preloaded {} game config(s) into SessionManager cache.", loaded)
        return loaded

    @classmethod
    def _build_game_instance(cls, *, game_config: GameConfig, pc: CharacterRecord, npc: CharacterRecord) -> Game:
        module_path, class_name = game_config.game_class.rsplit(".", 1)
        module = importlib.import_module(module_path)
        game_cls = getattr(module, class_name)
        return game_cls.create_from_context(pc=pc, npc=npc)

    @classmethod
    def _build_session(
        cls,
        *,
        game_config: GameConfig,
        game_instance: Game,
        provider: Any,
        source: str,
        player_id: str | None,
    ) -> "SessionManager":
        # Use a deterministic name format for easier testing and indexing, but include a timestamp for uniqueness.
        time_str = utc_now().strftime("%Y%m%d-%H%M%S")
        name = f"{source}-{game_config.name}-{time_str}".lower().replace(" ", "-")
        stopping = dict(game_config.stopping_conditions) if game_config.stopping_conditions else {}
        return cls(
            name=name,
            game=game_instance,
            game_config=game_config,
            provider=provider,
            source=source,
            player_id=player_id,
            stopping_conditions=stopping or None,
        )

    @classmethod
    def _validate_choices(
        cls,
        *,
        valid_pc_hids: list[str],
        valid_npc_hids: list[str],
        pc_choice: str | None,
        npc_choice: str | None,
    ) -> tuple[str, str]:
        if not valid_pc_hids:
            raise ValueError("No valid player character choices found.")
        if not valid_npc_hids:
            raise ValueError("No valid non-player character choices found.")
        if pc_choice and pc_choice not in valid_pc_hids:
            raise ValueError(f"Invalid pc_choice: {pc_choice}")
        if npc_choice and npc_choice not in valid_npc_hids:
            raise ValueError(f"Invalid npc_choice: {npc_choice}")
        return (pc_choice or random.choice(valid_pc_hids), npc_choice or random.choice(valid_npc_hids))

    @property
    def exited(self) -> bool:
        """Return True when the session or game lifecycle is finished."""
        return self._exited or self.game.exited

    @property
    def exit_reason(self) -> str:
        """Return the terminal reason string, if available."""
        return self._exit_reason or self.game.exit_reason

    @property
    def turns(self) -> int:
        """Return completed AI turns."""
        return self._turn_count

    @property
    def runtime_seconds(self) -> int:
        """Return elapsed runtime in seconds."""
        end = self.end_ts or utc_now()
        return int((end - self.start_ts).total_seconds())

    async def start_persistence(self, *, session_id: str) -> None:
        """Initialize session + event persistence once session_id is assigned."""
        if self._recorder_open:
            return

        get_db = getattr(self._provider, "get_db", None)
        if get_db is None:
            return
        db = get_db()
        insert_one = getattr(db[MongoColumns.SESSIONS], "insert_one", None)
        if insert_one is None or not inspect.iscoroutinefunction(insert_one):
            # Transcript persistence requires async collection methods.
            return

        self._session_id = session_id
        pc = getattr(self.game, "_pc", None)
        npc = getattr(self.game, "_npc", None)

        session_doc: dict[str, Any] = {
            MongoColumns.SESSION_ID: session_id,
            MongoColumns.NAME: self.name,
            MongoColumns.PLAYER_ID: self.player_id,
            MongoColumns.GAME_NAME: self.game_config.name,
            MongoColumns.SOURCE: self.source,
            MongoColumns.PC_HID: getattr(pc, "hid", None),
            MongoColumns.NPC_HID: getattr(npc, "hid", None),
            MongoColumns.SESSION_STARTED_AT: self.start_ts,
            MongoColumns.SESSION_ENDED_AT: None,
            MongoColumns.TERMINATION_REASON: None,
            MongoColumns.STATUS: "active",
            MongoColumns.TURNS_COMPLETED: 0,
            MongoColumns.MODEL_PROFILE: {
                "updater_model": getattr(getattr(self.game, "_updater", None), "_model", None),
                "validator_model": getattr(getattr(self.game, "_validator", None), "_model", None),
                "scorer_model": getattr(getattr(self.game, "_scorer", None), "_model", None),
            },
            MongoColumns.GAME_CONFIG_SNAPSHOT: self.game_config.model_dump(mode="json"),
            MongoColumns.LAST_SEQ: 0,
            MongoColumns.CREATED_AT: utc_now(),
            MongoColumns.UPDATED_AT: utc_now(),
        }

        self._recorder = SessionEventRecorder(db=db, session_doc=session_doc)
        await self._recorder.__aenter__()
        self._recorder_open = True
        await self._recorder.record_internal(event_type="session_start", detail="created", turn_index=0)

    def _begin_exit(self, reason: str) -> None:
        """Mark local exit state before persistence finalization."""
        if self._exited:
            return
        self._exited = True
        self._exit_reason = reason
        self.end_ts = utc_now()
        self.game.exit(reason)

    async def exit_async(self, reason: str) -> None:
        """Mark session ended, finalize persistence, and close recorder."""
        self._begin_exit(reason)

        if self._finalized:
            return

        if self._recorder_open and self._recorder is not None:
            normalized = self._normalize_termination_reason(reason)
            status = "error" if normalized == "server_error" else "closed"
            await self._recorder.finalize(
                termination_reason=normalized,
                status=status,
                turns_completed=self.turns,
            )
            await self._recorder.__aexit__(None, None, None)
            self._recorder_open = False
        self._finalized = True
        logger.info("Session exited. Reason: {}", reason)

    async def flush_persistence_async(self) -> None:
        """Flush any queued transcript events so follow-on writes can target them safely."""
        if self._recorder_open and self._recorder is not None:
            await self._recorder.flush_pending()

    def save(self) -> None:
        """Compatibility no-op; session transcript writes now use session_events."""
        self._saved = True
        logger.debug("SessionManager.save() is a no-op; persistence is event-sourced.")

    async def step_async(self, user_input: Optional[str] = None) -> List[Dict[str, Any]]:
        """Advance one turn asynchronously and return normalized event dicts."""
        if self.exited:
            return [{"type": "info", "content": f"Session has ended. ({self.exit_reason})"}]

        stopping_reason = self._check_stopping_conditions()
        if stopping_reason is not None:
            await self.exit_async(stopping_reason)
            return [{"type": "info", "content": f"Session ended: {self.exit_reason}"}]

        turn_index = self._turn_count + 1
        parsed_command = _parse_command_input(user_input)

        events = await self._collect_events(user_input)
        recognized_game_command = parsed_command is not None and any(event.command_response for event in events)
        if isinstance(user_input, str) and user_input != "" and self._recorder_open and self._recorder is not None:
            inbound_event_type = "command" if recognized_game_command else "message"
            inbound_command_name = parsed_command[0] if recognized_game_command and parsed_command is not None else None
            inbound_command_args = parsed_command[1] if recognized_game_command and parsed_command is not None else None
            await self._recorder.record_inbound(
                content=user_input,
                turn_index=turn_index,
                event_type=inbound_event_type,
                command_name=inbound_command_name,
                command_args=inbound_command_args,
            )

        emitted: List[Dict[str, Any]] = []
        yielded_ai = False
        for event in events:
            if event.type == "ai":
                yielded_ai = True
            payload = {"type": event.type, "content": event.content}
            self._events.append(payload)
            if self._recorder_open and self._recorder is not None:
                persisted_event_type, persisted_event_source = self._classify_persisted_outbound_event(event)
                outbound_command_name = parsed_command[0] if event.command_response and parsed_command is not None else None
                outbound_command_args = parsed_command[1] if event.command_response and parsed_command is not None else None
                recorded = await self._recorder.record_outbound(
                    event_type=persisted_event_type,
                    event_source=persisted_event_source,
                    content=event.content,
                    turn_index=turn_index,
                    command_name=outbound_command_name,
                    command_args=outbound_command_args,
                    event_ts=event.event_ts,
                )
                payload["event_id"] = recorded.event_id
            emitted.append(payload)

        if yielded_ai:
            self._turn_count += 1

        if self.game.exited and not self._exited:
            await self.exit_async(self.game.exit_reason or "game_completed")
        return emitted

    async def _collect_events(self, user_input: Optional[str]) -> List[GameEvent]:
        events: List[GameEvent] = []
        async for event in self.game.step(user_input):
            events.append(event)
        return events

    def _classify_persisted_outbound_event(self, event: GameEvent) -> tuple[str, str]:
        event_type = str(event.type or "info").lower()
        if event.command_response:
            return ("command", "system")
        if event_type == "ai":
            return ("message", "npc")
        if event_type == "error":
            return ("error", "system")
        return ("info", "system")

    def _check_stopping_conditions(self) -> str | None:
        for attr, cond_list in self.stopping_conditions.items():
            val = getattr(self, attr, None)
            if val is None:
                continue
            for condition in cond_list:
                condition = condition.strip()
                try:
                    if isinstance(val, (int, float)) and condition[0] in "<>!=":
                        if eval(f"{val}{condition}"):  # noqa: S307
                            return f"stopping condition met: {attr} {condition}"
                except Exception as exc:
                    logger.error("Error evaluating stopping condition {}={!r}: {}", attr, condition, exc)
        return None

    def _normalize_termination_reason(self, reason: str) -> str:
        reason_l = reason.strip().lower().replace(" ", "_")
        if reason_l in {"received_close_request", "user_close_button"}:
            return "user_close_button"
        if reason_l in {"received_exit_command", "user_exit_command"}:
            return "user_exit_command"
        if reason_l in {"game_completed", "game_complete"}:
            return "game_completed"
        if reason_l in {"session_ttl_expired"} or "ttl" in reason_l:
            return "session_ttl_expired"
        if reason_l in {"websocket_disconnect"}:
            return "websocket_disconnect"
        if reason_l in {"retry_budget_exhausted", "validation_retry_exhausted"}:
            return "validation_retry_exhausted"
        if reason_l in {"server_error", "internal_server_error"}:
            return "server_error"
        return reason_l
exit_reason property

Return the terminal reason string, if available.

exited property

Return True when the session or game lifecycle is finished.

runtime_seconds property

Return elapsed runtime in seconds.

turns property

Return completed AI turns.

__init__(name, game, game_config, provider, source='unknown', player_id=None, stopping_conditions=None)

Initialize session state, runtime counters, and persistence hooks.

Source code in dcs_simulation_engine/core/session_manager.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
def __init__(
    self,
    name: str,
    game: Game,
    game_config: GameConfig,
    provider: DataProvider | Any,
    source: str = "unknown",
    player_id: Optional[str] = None,
    stopping_conditions: Optional[Dict[str, List[str]]] = None,
) -> None:
    """Initialize session state, runtime counters, and persistence hooks."""
    self.name = name
    self.game = game
    self.game_config = game_config
    self._provider = provider
    self.source = source
    self.player_id = player_id
    self.start_ts: datetime = utc_now()
    self.end_ts: Optional[datetime] = None
    self._exited = False
    self._exit_reason = ""
    self._turn_count = 0
    self._events: List[Dict[str, Any]] = []
    self._saved: bool = False
    self._session_id: str | None = None
    self._recorder: SessionEventRecorder | None = None
    self._recorder_open = False
    self._finalized = False
    self.stopping_conditions: Dict[str, List[str]] = stopping_conditions or {
        "turns": [">500"],
        "runtime_seconds": [">3600"],
    }
create_async(game, provider, source='unknown', pc_choice=None, npc_choice=None, player_id=None) async classmethod

Create a session for async runtime paths.

Source code in dcs_simulation_engine/core/session_manager.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
@classmethod
async def create_async(
    cls,
    game: "str | GameConfig",
    provider: Any,
    source: str = "unknown",
    pc_choice: Optional[str] = None,
    npc_choice: Optional[str] = None,
    player_id: Optional[str] = None,
) -> "SessionManager":
    """Create a session for async runtime paths."""
    if isinstance(game, str):
        game_config = cls.get_game_config_cached(game)
    elif isinstance(game, GameConfig):
        game_config = game
    else:
        raise TypeError(f"Invalid game parameter type: {type(game)}")

    get_valid = getattr(game_config, "get_valid_characters_async", None)
    if get_valid is None:
        valid_pcs, valid_npcs = await maybe_await(game_config.get_valid_characters(player_id=player_id, provider=provider))
    else:
        valid_pcs, valid_npcs = await maybe_await(get_valid(player_id=player_id, provider=provider))
    valid_pc_hids = [hid for _, hid in valid_pcs]
    valid_npc_hids = [hid for _, hid in valid_npcs]
    pc_hid, npc_hid = cls._validate_choices(
        valid_pc_hids=valid_pc_hids,
        valid_npc_hids=valid_npc_hids,
        pc_choice=pc_choice,
        npc_choice=npc_choice,
    )

    pc: CharacterRecord = await maybe_await(provider.get_character(hid=pc_hid))
    npc: CharacterRecord = await maybe_await(provider.get_character(hid=npc_hid))
    game_instance = cls._build_game_instance(game_config=game_config, pc=pc, npc=npc)
    session = cls._build_session(
        game_config=game_config,
        game_instance=game_instance,
        provider=provider,
        source=source,
        player_id=player_id,
    )
    logger.info("SessionManager created: {}, pc={}, npc={}", session.name, pc_hid, npc_hid)
    return session
exit_async(reason) async

Mark session ended, finalize persistence, and close recorder.

Source code in dcs_simulation_engine/core/session_manager.py
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
async def exit_async(self, reason: str) -> None:
    """Mark session ended, finalize persistence, and close recorder."""
    self._begin_exit(reason)

    if self._finalized:
        return

    if self._recorder_open and self._recorder is not None:
        normalized = self._normalize_termination_reason(reason)
        status = "error" if normalized == "server_error" else "closed"
        await self._recorder.finalize(
            termination_reason=normalized,
            status=status,
            turns_completed=self.turns,
        )
        await self._recorder.__aexit__(None, None, None)
        self._recorder_open = False
    self._finalized = True
    logger.info("Session exited. Reason: {}", reason)
flush_persistence_async() async

Flush any queued transcript events so follow-on writes can target them safely.

Source code in dcs_simulation_engine/core/session_manager.py
318
319
320
321
async def flush_persistence_async(self) -> None:
    """Flush any queued transcript events so follow-on writes can target them safely."""
    if self._recorder_open and self._recorder is not None:
        await self._recorder.flush_pending()
get_game_config_cached(game) classmethod

Return a defensive copy of a cached game config, loading it on first use.

Source code in dcs_simulation_engine/core/session_manager.py
57
58
59
60
61
@classmethod
def get_game_config_cached(cls, game: str) -> GameConfig:
    """Return a defensive copy of a cached game config, loading it on first use."""
    cls._load_game_config_into_cache(game)
    return cls._game_config_cache[cls._cache_key(game)].model_copy(deep=True)
preload_game_configs() classmethod

Load all valid game configs from disk into the in-memory cache.

Source code in dcs_simulation_engine/core/session_manager.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
@classmethod
def preload_game_configs(cls) -> int:
    """Load all valid game configs from disk into the in-memory cache."""
    games_dir = Path(__file__).resolve().parents[2] / "games"
    if not games_dir.exists() or not games_dir.is_dir():
        return 0

    discovered_names: set[str] = set()
    for path in games_dir.glob("*.y*ml"):
        try:
            with path.open("r", encoding="utf-8") as f:
                doc = yaml.safe_load(f) or {}
            raw_name = doc.get("name")
            if isinstance(raw_name, str) and raw_name.strip():
                discovered_names.add(raw_name.strip())
        except Exception:
            logger.debug("Skipping unreadable game config while preloading: {}", path, exc_info=True)

    loaded = 0
    for name in discovered_names:
        try:
            if cls._load_game_config_into_cache(name):
                loaded += 1
        except Exception:
            logger.debug("Skipping invalid game config '{}' during preload", name, exc_info=True)

    if loaded > 0:
        logger.info("Preloaded {} game config(s) into SessionManager cache.", loaded)
    return loaded
save()

Compatibility no-op; session transcript writes now use session_events.

Source code in dcs_simulation_engine/core/session_manager.py
323
324
325
326
def save(self) -> None:
    """Compatibility no-op; session transcript writes now use session_events."""
    self._saved = True
    logger.debug("SessionManager.save() is a no-op; persistence is event-sourced.")
start_persistence(*, session_id) async

Initialize session + event persistence once session_id is assigned.

Source code in dcs_simulation_engine/core/session_manager.py
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
async def start_persistence(self, *, session_id: str) -> None:
    """Initialize session + event persistence once session_id is assigned."""
    if self._recorder_open:
        return

    get_db = getattr(self._provider, "get_db", None)
    if get_db is None:
        return
    db = get_db()
    insert_one = getattr(db[MongoColumns.SESSIONS], "insert_one", None)
    if insert_one is None or not inspect.iscoroutinefunction(insert_one):
        # Transcript persistence requires async collection methods.
        return

    self._session_id = session_id
    pc = getattr(self.game, "_pc", None)
    npc = getattr(self.game, "_npc", None)

    session_doc: dict[str, Any] = {
        MongoColumns.SESSION_ID: session_id,
        MongoColumns.NAME: self.name,
        MongoColumns.PLAYER_ID: self.player_id,
        MongoColumns.GAME_NAME: self.game_config.name,
        MongoColumns.SOURCE: self.source,
        MongoColumns.PC_HID: getattr(pc, "hid", None),
        MongoColumns.NPC_HID: getattr(npc, "hid", None),
        MongoColumns.SESSION_STARTED_AT: self.start_ts,
        MongoColumns.SESSION_ENDED_AT: None,
        MongoColumns.TERMINATION_REASON: None,
        MongoColumns.STATUS: "active",
        MongoColumns.TURNS_COMPLETED: 0,
        MongoColumns.MODEL_PROFILE: {
            "updater_model": getattr(getattr(self.game, "_updater", None), "_model", None),
            "validator_model": getattr(getattr(self.game, "_validator", None), "_model", None),
            "scorer_model": getattr(getattr(self.game, "_scorer", None), "_model", None),
        },
        MongoColumns.GAME_CONFIG_SNAPSHOT: self.game_config.model_dump(mode="json"),
        MongoColumns.LAST_SEQ: 0,
        MongoColumns.CREATED_AT: utc_now(),
        MongoColumns.UPDATED_AT: utc_now(),
    }

    self._recorder = SessionEventRecorder(db=db, session_doc=session_doc)
    await self._recorder.__aenter__()
    self._recorder_open = True
    await self._recorder.record_internal(event_type="session_start", detail="created", turn_index=0)
step_async(user_input=None) async

Advance one turn asynchronously and return normalized event dicts.

Source code in dcs_simulation_engine/core/session_manager.py
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
async def step_async(self, user_input: Optional[str] = None) -> List[Dict[str, Any]]:
    """Advance one turn asynchronously and return normalized event dicts."""
    if self.exited:
        return [{"type": "info", "content": f"Session has ended. ({self.exit_reason})"}]

    stopping_reason = self._check_stopping_conditions()
    if stopping_reason is not None:
        await self.exit_async(stopping_reason)
        return [{"type": "info", "content": f"Session ended: {self.exit_reason}"}]

    turn_index = self._turn_count + 1
    parsed_command = _parse_command_input(user_input)

    events = await self._collect_events(user_input)
    recognized_game_command = parsed_command is not None and any(event.command_response for event in events)
    if isinstance(user_input, str) and user_input != "" and self._recorder_open and self._recorder is not None:
        inbound_event_type = "command" if recognized_game_command else "message"
        inbound_command_name = parsed_command[0] if recognized_game_command and parsed_command is not None else None
        inbound_command_args = parsed_command[1] if recognized_game_command and parsed_command is not None else None
        await self._recorder.record_inbound(
            content=user_input,
            turn_index=turn_index,
            event_type=inbound_event_type,
            command_name=inbound_command_name,
            command_args=inbound_command_args,
        )

    emitted: List[Dict[str, Any]] = []
    yielded_ai = False
    for event in events:
        if event.type == "ai":
            yielded_ai = True
        payload = {"type": event.type, "content": event.content}
        self._events.append(payload)
        if self._recorder_open and self._recorder is not None:
            persisted_event_type, persisted_event_source = self._classify_persisted_outbound_event(event)
            outbound_command_name = parsed_command[0] if event.command_response and parsed_command is not None else None
            outbound_command_args = parsed_command[1] if event.command_response and parsed_command is not None else None
            recorded = await self._recorder.record_outbound(
                event_type=persisted_event_type,
                event_source=persisted_event_source,
                content=event.content,
                turn_index=turn_index,
                command_name=outbound_command_name,
                command_args=outbound_command_args,
                event_ts=event.event_ts,
            )
            payload["event_id"] = recorded.event_id
        emitted.append(payload)

    if yielded_ai:
        self._turn_count += 1

    if self.game.exited and not self._exited:
        await self.exit_async(self.game.exit_reason or "game_completed")
    return emitted

dal

Data abstraction layer.

base

DAL base: record types and DataProvider interface.

AssignmentRecord

Bases: NamedTuple

A persisted experiment assignment row.

Source code in dcs_simulation_engine/dal/base.py
68
69
70
71
72
73
74
75
76
77
78
class AssignmentRecord(NamedTuple):
    """A persisted experiment assignment row."""

    assignment_id: str
    experiment_name: str
    player_id: str
    game_name: str
    character_hid: str
    status: str
    assigned_at: Any
    data: dict[str, Any]
CharacterRecord

Bases: NamedTuple

A character loaded from the data store.

Source code in dcs_simulation_engine/dal/base.py
 6
 7
 8
 9
10
11
12
class CharacterRecord(NamedTuple):
    """A character loaded from the data store."""

    hid: str
    name: str
    short_description: str
    data: dict[str, Any]
DataProvider

Abstract data provider interface.

Subclasses implement storage-specific logic. All methods raise NotImplementedError by default.

Source code in dcs_simulation_engine/dal/base.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
class DataProvider:
    """Abstract data provider interface.

    Subclasses implement storage-specific logic. All methods raise
    NotImplementedError by default.
    """

    def get_character(self, *, hid: str) -> CharacterRecord:
        """Return the character with the given HID.

        Raises:
            ValueError: If no character with that HID exists.
        """
        return self.get_characters(hid=hid)

    def get_characters(self, *, hid: str | None = None) -> list[CharacterRecord] | CharacterRecord:
        """Return all characters, or a single character if hid is given."""
        raise NotImplementedError

    def list_characters(self) -> list[CharacterRecord]:
        """Return all characters."""
        raise NotImplementedError

    def get_player(self, *, player_id: str) -> PlayerRecord | None:
        """Return a single player by id, or None if not found."""
        raise NotImplementedError

    def create_player(
        self,
        *,
        player_data: dict[str, Any],
        player_id: str | None = None,
        issue_access_key: bool = False,
        access_key: str | None = None,
    ) -> tuple[PlayerRecord, str | None]:
        """Create or upsert a player.

        Returns:
            (record, raw_key) where raw_key is None unless an access key was issued or explicitly provided.
        """
        raise NotImplementedError

    def get_players(self, *, access_key: str | None = None) -> list[PlayerRecord] | PlayerRecord | None:
        """Return all players, or a single player by access key."""
        raise NotImplementedError

    def upsert_character(self, data: dict[str, Any], *, character_id: str | None = None) -> str:
        """Create or update a character. Returns the character's id string."""
        raise NotImplementedError

    def delete_character(self, character_id: str) -> None:
        """Delete a character by id."""
        raise NotImplementedError

    def delete_player(self, player_id: str) -> None:
        """Delete a player by id."""
        raise NotImplementedError

    def get_session(self, *, session_id: str, player_id: str | None) -> SessionRecord | None:
        """Return one persisted session header for a player."""
        raise NotImplementedError

    def list_session_events(self, *, session_id: str) -> list[SessionEventRecord]:
        """Return ordered persisted events for a session."""
        raise NotImplementedError

    def append_session_event(
        self,
        *,
        session_id: str,
        player_id: str | None,
        direction: str,
        event_type: str,
        event_source: str,
        content: str,
        content_format: str,
        turn_index: int,
        visible_to_user: bool,
    ) -> SessionEventRecord | None:
        """Append one owned session event to an existing persisted session."""
        raise NotImplementedError

    def set_session_event_feedback(
        self,
        *,
        session_id: str,
        player_id: str | None,
        event_id: str,
        feedback: dict[str, Any],
    ) -> dict[str, Any] | None:
        """Store feedback on a persisted NPC-message event."""
        raise NotImplementedError

    def clear_session_event_feedback(
        self,
        *,
        session_id: str,
        player_id: str | None,
        event_id: str,
    ) -> bool:
        """Remove feedback from a persisted NPC-message event."""
        raise NotImplementedError

    def get_experiment(self, *, experiment_name: str) -> ExperimentRecord | None:
        """Return a persisted experiment record by name."""
        raise NotImplementedError

    def upsert_experiment(
        self,
        *,
        experiment_name: str,
        description: str,
        config_snapshot: dict[str, Any],
        progress: dict[str, Any],
    ) -> ExperimentRecord:
        """Create or update a persisted experiment metadata record."""
        raise NotImplementedError

    def set_experiment_progress(
        self,
        *,
        experiment_name: str,
        progress: dict[str, Any],
    ) -> ExperimentRecord | None:
        """Persist the latest experiment progress snapshot."""
        raise NotImplementedError

    def create_assignment(self, *, assignment_doc: dict[str, Any], allow_concurrent: bool = False) -> AssignmentRecord:
        """Persist a new experiment assignment row."""
        raise NotImplementedError

    def get_assignment(self, *, assignment_id: str) -> AssignmentRecord | None:
        """Return one assignment row by assignment id."""
        raise NotImplementedError

    def get_active_assignment(self, *, experiment_name: str, player_id: str) -> AssignmentRecord | None:
        """Return the current active assignment for one player in one experiment."""
        raise NotImplementedError

    def get_latest_experiment_assignment_for_player(self, *, player_id: str) -> AssignmentRecord | None:
        """Return the newest experiment assignment for one player across experiments."""
        raise NotImplementedError

    def list_assignments(
        self,
        *,
        experiment_name: str,
        player_id: str | None = None,
        statuses: list[str] | None = None,
        game_name: str | None = None,
    ) -> list[AssignmentRecord]:
        """List experiment assignments matching the provided filters."""
        raise NotImplementedError

    def update_assignment_status(
        self,
        *,
        assignment_id: str,
        status: str,
        active_session_id: str | None = None,
    ) -> AssignmentRecord | None:
        """Update assignment status and lifecycle timestamps."""
        raise NotImplementedError

    def set_assignment_form_response(
        self,
        *,
        assignment_id: str,
        form_key: str,
        response: dict[str, Any],
    ) -> AssignmentRecord | None:
        """Store one experiment form response payload on an assignment row."""
        raise NotImplementedError

    def set_player_form_response(
        self,
        *,
        player_id: str,
        experiment_name: str,
        form_key: str,
        response: dict[str, Any],
    ) -> PlayerFormsRecord | None:
        """Store one before-play form response in the forms collection."""
        raise NotImplementedError

    def get_player_forms(
        self,
        *,
        player_id: str,
        experiment_name: str,
    ) -> PlayerFormsRecord | None:
        """Return the before-play form responses for a player in an experiment."""
        raise NotImplementedError
append_session_event(*, session_id, player_id, direction, event_type, event_source, content, content_format, turn_index, visible_to_user)

Append one owned session event to an existing persisted session.

Source code in dcs_simulation_engine/dal/base.py
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
def append_session_event(
    self,
    *,
    session_id: str,
    player_id: str | None,
    direction: str,
    event_type: str,
    event_source: str,
    content: str,
    content_format: str,
    turn_index: int,
    visible_to_user: bool,
) -> SessionEventRecord | None:
    """Append one owned session event to an existing persisted session."""
    raise NotImplementedError
clear_session_event_feedback(*, session_id, player_id, event_id)

Remove feedback from a persisted NPC-message event.

Source code in dcs_simulation_engine/dal/base.py
174
175
176
177
178
179
180
181
182
def clear_session_event_feedback(
    self,
    *,
    session_id: str,
    player_id: str | None,
    event_id: str,
) -> bool:
    """Remove feedback from a persisted NPC-message event."""
    raise NotImplementedError
create_assignment(*, assignment_doc, allow_concurrent=False)

Persist a new experiment assignment row.

Source code in dcs_simulation_engine/dal/base.py
208
209
210
def create_assignment(self, *, assignment_doc: dict[str, Any], allow_concurrent: bool = False) -> AssignmentRecord:
    """Persist a new experiment assignment row."""
    raise NotImplementedError
create_player(*, player_data, player_id=None, issue_access_key=False, access_key=None)

Create or upsert a player.

Returns:

Type Description
tuple[PlayerRecord, str | None]

(record, raw_key) where raw_key is None unless an access key was issued or explicitly provided.

Source code in dcs_simulation_engine/dal/base.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def create_player(
    self,
    *,
    player_data: dict[str, Any],
    player_id: str | None = None,
    issue_access_key: bool = False,
    access_key: str | None = None,
) -> tuple[PlayerRecord, str | None]:
    """Create or upsert a player.

    Returns:
        (record, raw_key) where raw_key is None unless an access key was issued or explicitly provided.
    """
    raise NotImplementedError
delete_character(character_id)

Delete a character by id.

Source code in dcs_simulation_engine/dal/base.py
131
132
133
def delete_character(self, character_id: str) -> None:
    """Delete a character by id."""
    raise NotImplementedError
delete_player(player_id)

Delete a player by id.

Source code in dcs_simulation_engine/dal/base.py
135
136
137
def delete_player(self, player_id: str) -> None:
    """Delete a player by id."""
    raise NotImplementedError
get_active_assignment(*, experiment_name, player_id)

Return the current active assignment for one player in one experiment.

Source code in dcs_simulation_engine/dal/base.py
216
217
218
def get_active_assignment(self, *, experiment_name: str, player_id: str) -> AssignmentRecord | None:
    """Return the current active assignment for one player in one experiment."""
    raise NotImplementedError
get_assignment(*, assignment_id)

Return one assignment row by assignment id.

Source code in dcs_simulation_engine/dal/base.py
212
213
214
def get_assignment(self, *, assignment_id: str) -> AssignmentRecord | None:
    """Return one assignment row by assignment id."""
    raise NotImplementedError
get_character(*, hid)

Return the character with the given HID.

Raises:

Type Description
ValueError

If no character with that HID exists.

Source code in dcs_simulation_engine/dal/base.py
88
89
90
91
92
93
94
def get_character(self, *, hid: str) -> CharacterRecord:
    """Return the character with the given HID.

    Raises:
        ValueError: If no character with that HID exists.
    """
    return self.get_characters(hid=hid)
get_characters(*, hid=None)

Return all characters, or a single character if hid is given.

Source code in dcs_simulation_engine/dal/base.py
96
97
98
def get_characters(self, *, hid: str | None = None) -> list[CharacterRecord] | CharacterRecord:
    """Return all characters, or a single character if hid is given."""
    raise NotImplementedError
get_experiment(*, experiment_name)

Return a persisted experiment record by name.

Source code in dcs_simulation_engine/dal/base.py
184
185
186
def get_experiment(self, *, experiment_name: str) -> ExperimentRecord | None:
    """Return a persisted experiment record by name."""
    raise NotImplementedError
get_latest_experiment_assignment_for_player(*, player_id)

Return the newest experiment assignment for one player across experiments.

Source code in dcs_simulation_engine/dal/base.py
220
221
222
def get_latest_experiment_assignment_for_player(self, *, player_id: str) -> AssignmentRecord | None:
    """Return the newest experiment assignment for one player across experiments."""
    raise NotImplementedError
get_player(*, player_id)

Return a single player by id, or None if not found.

Source code in dcs_simulation_engine/dal/base.py
104
105
106
def get_player(self, *, player_id: str) -> PlayerRecord | None:
    """Return a single player by id, or None if not found."""
    raise NotImplementedError
get_player_forms(*, player_id, experiment_name)

Return the before-play form responses for a player in an experiment.

Source code in dcs_simulation_engine/dal/base.py
266
267
268
269
270
271
272
273
def get_player_forms(
    self,
    *,
    player_id: str,
    experiment_name: str,
) -> PlayerFormsRecord | None:
    """Return the before-play form responses for a player in an experiment."""
    raise NotImplementedError
get_players(*, access_key=None)

Return all players, or a single player by access key.

Source code in dcs_simulation_engine/dal/base.py
123
124
125
def get_players(self, *, access_key: str | None = None) -> list[PlayerRecord] | PlayerRecord | None:
    """Return all players, or a single player by access key."""
    raise NotImplementedError
get_session(*, session_id, player_id)

Return one persisted session header for a player.

Source code in dcs_simulation_engine/dal/base.py
139
140
141
def get_session(self, *, session_id: str, player_id: str | None) -> SessionRecord | None:
    """Return one persisted session header for a player."""
    raise NotImplementedError
list_assignments(*, experiment_name, player_id=None, statuses=None, game_name=None)

List experiment assignments matching the provided filters.

Source code in dcs_simulation_engine/dal/base.py
224
225
226
227
228
229
230
231
232
233
def list_assignments(
    self,
    *,
    experiment_name: str,
    player_id: str | None = None,
    statuses: list[str] | None = None,
    game_name: str | None = None,
) -> list[AssignmentRecord]:
    """List experiment assignments matching the provided filters."""
    raise NotImplementedError
list_characters()

Return all characters.

Source code in dcs_simulation_engine/dal/base.py
100
101
102
def list_characters(self) -> list[CharacterRecord]:
    """Return all characters."""
    raise NotImplementedError
list_session_events(*, session_id)

Return ordered persisted events for a session.

Source code in dcs_simulation_engine/dal/base.py
143
144
145
def list_session_events(self, *, session_id: str) -> list[SessionEventRecord]:
    """Return ordered persisted events for a session."""
    raise NotImplementedError
set_assignment_form_response(*, assignment_id, form_key, response)

Store one experiment form response payload on an assignment row.

Source code in dcs_simulation_engine/dal/base.py
245
246
247
248
249
250
251
252
253
def set_assignment_form_response(
    self,
    *,
    assignment_id: str,
    form_key: str,
    response: dict[str, Any],
) -> AssignmentRecord | None:
    """Store one experiment form response payload on an assignment row."""
    raise NotImplementedError
set_experiment_progress(*, experiment_name, progress)

Persist the latest experiment progress snapshot.

Source code in dcs_simulation_engine/dal/base.py
199
200
201
202
203
204
205
206
def set_experiment_progress(
    self,
    *,
    experiment_name: str,
    progress: dict[str, Any],
) -> ExperimentRecord | None:
    """Persist the latest experiment progress snapshot."""
    raise NotImplementedError
set_player_form_response(*, player_id, experiment_name, form_key, response)

Store one before-play form response in the forms collection.

Source code in dcs_simulation_engine/dal/base.py
255
256
257
258
259
260
261
262
263
264
def set_player_form_response(
    self,
    *,
    player_id: str,
    experiment_name: str,
    form_key: str,
    response: dict[str, Any],
) -> PlayerFormsRecord | None:
    """Store one before-play form response in the forms collection."""
    raise NotImplementedError
set_session_event_feedback(*, session_id, player_id, event_id, feedback)

Store feedback on a persisted NPC-message event.

Source code in dcs_simulation_engine/dal/base.py
163
164
165
166
167
168
169
170
171
172
def set_session_event_feedback(
    self,
    *,
    session_id: str,
    player_id: str | None,
    event_id: str,
    feedback: dict[str, Any],
) -> dict[str, Any] | None:
    """Store feedback on a persisted NPC-message event."""
    raise NotImplementedError
update_assignment_status(*, assignment_id, status, active_session_id=None)

Update assignment status and lifecycle timestamps.

Source code in dcs_simulation_engine/dal/base.py
235
236
237
238
239
240
241
242
243
def update_assignment_status(
    self,
    *,
    assignment_id: str,
    status: str,
    active_session_id: str | None = None,
) -> AssignmentRecord | None:
    """Update assignment status and lifecycle timestamps."""
    raise NotImplementedError
upsert_character(data, *, character_id=None)

Create or update a character. Returns the character's id string.

Source code in dcs_simulation_engine/dal/base.py
127
128
129
def upsert_character(self, data: dict[str, Any], *, character_id: str | None = None) -> str:
    """Create or update a character. Returns the character's id string."""
    raise NotImplementedError
upsert_experiment(*, experiment_name, description, config_snapshot, progress)

Create or update a persisted experiment metadata record.

Source code in dcs_simulation_engine/dal/base.py
188
189
190
191
192
193
194
195
196
197
def upsert_experiment(
    self,
    *,
    experiment_name: str,
    description: str,
    config_snapshot: dict[str, Any],
    progress: dict[str, Any],
) -> ExperimentRecord:
    """Create or update a persisted experiment metadata record."""
    raise NotImplementedError
ExperimentRecord

Bases: NamedTuple

A persisted experiment metadata record.

Source code in dcs_simulation_engine/dal/base.py
49
50
51
52
53
54
55
class ExperimentRecord(NamedTuple):
    """A persisted experiment metadata record."""

    name: str
    created_at: Any
    updated_at: Any
    data: dict[str, Any]
PlayerFormsRecord

Bases: NamedTuple

Before-play form responses for a player in a specific experiment.

Source code in dcs_simulation_engine/dal/base.py
58
59
60
61
62
63
64
65
class PlayerFormsRecord(NamedTuple):
    """Before-play form responses for a player in a specific experiment."""

    player_id: str
    experiment_name: str
    data: dict[str, Any]
    created_at: Any
    updated_at: Any
PlayerRecord

Bases: NamedTuple

A player record (non-PII fields only).

Source code in dcs_simulation_engine/dal/base.py
15
16
17
18
19
20
21
class PlayerRecord(NamedTuple):
    """A player record (non-PII fields only)."""

    id: str
    created_at: Any
    access_key: Optional[str]
    data: dict[str, Any]
SessionEventRecord

Bases: NamedTuple

A persisted event row for a session transcript.

Source code in dcs_simulation_engine/dal/base.py
35
36
37
38
39
40
41
42
43
44
45
46
class SessionEventRecord(NamedTuple):
    """A persisted event row for a session transcript."""

    session_id: str
    seq: int
    event_id: str
    event_ts: Any
    direction: str
    event_type: str
    event_source: str
    content: str
    data: dict[str, Any]
SessionRecord

Bases: NamedTuple

A persisted chat session header record.

Source code in dcs_simulation_engine/dal/base.py
24
25
26
27
28
29
30
31
32
class SessionRecord(NamedTuple):
    """A persisted chat session header record."""

    session_id: str
    player_id: str | None
    game_name: str
    status: str
    created_at: Any
    data: dict[str, Any]

mongo

MongoDB DAL: provider and admin classes.

AsyncMongoProvider

Async provider backed by PyMongo AsyncMongoClient.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
class AsyncMongoProvider:
    """Async provider backed by PyMongo AsyncMongoClient."""

    def __init__(self, db: Any) -> None:
        # We inject the database instance rather than creating it here.
        # This makes the class easily testable (you can pass in a mock database).
        self._db = db

    def get_db(self) -> Any:
        return self._db

    async def get_characters(self, *, hid: str | None = None) -> list[CharacterRecord] | CharacterRecord:
        """Fetch a specific character by ID, or list all of them if no ID is provided."""
        if hid is not None:
            # projection={"_id": 0} tells Mongo to NOT return its internal ObjectId.
            # We do this because ObjectIds often aren't JSON serializable by default.
            doc = await maybe_await(self._db[MongoColumns.CHARACTERS].find_one({"hid": hid}, projection={"_id": 0}))
            if not doc:
                raise ValueError(f"Character with hid='{hid}' not found")
            return _to_character_record(doc)

        # If no 'hid' was provided, fall back to fetching everything.
        return await self.list_characters()

    async def get_character(self, *, hid: str) -> CharacterRecord:
        """Strict version of get_characters that guarantees a single record is returned."""
        result = await self.get_characters(hid=hid)
        if not isinstance(result, CharacterRecord):
            raise ValueError(f"Character with hid='{hid}' not found")
        return result

    async def list_characters(self) -> list[CharacterRecord]:
        """Fetch all character records from the database."""
        # Find with an empty query `{}` means "get everything".
        cursor = self._db[MongoColumns.CHARACTERS].find({}, projection={"_id": 0})
        docs = await _cursor_to_docs(cursor)
        return [_to_character_record(doc) for doc in docs]

    async def get_player(self, *, player_id: str) -> PlayerRecord | None:
        """Look up a player by their ID."""
        # player_id_variants likely handles the fact that an ID could be stored
        # as a raw string or an ObjectId in the database.
        ids = player_id_variants(player_id)
        if not ids:
            return None

        # $or is a Mongo operator: "Find a document where _id matches ANY of the IDs in this list."
        doc = await maybe_await(self._db[MongoColumns.PLAYERS].find_one({"$or": [{"_id": pid} for pid in ids]}))
        if not doc:
            return None

        # Rename the internal Mongo '_id' to a standard 'id' for the application to use.
        # .pop() removes it from the dict and returns the value at the same time.
        doc["id"] = str(doc.pop("_id"))
        return player_doc_to_record(doc)

    async def create_player(
        self,
        *,
        player_data: dict[str, Any],
        player_id: str | None = None,
        issue_access_key: bool = False,
        access_key: str | None = None,
    ) -> tuple[PlayerRecord, str | None]:
        """Create or update a player, optionally issuing them a new access key."""
        sanitized = sanitize_player_data(player_data)
        raw_key: str | None = None

        if issue_access_key and access_key is not None:
            raise ValueError("Use either issue_access_key=True or an explicit access_key, not both.")

        if access_key is not None:
            raw_key = validate_access_key(access_key)
            sanitized.update(
                {
                    "access_key": raw_key,
                    "access_key_revoked": False,
                    "last_key_issued_at": utc_now(),
                }
            )
        elif issue_access_key:
            raw_key = generate_access_key()
            sanitized.update(
                {
                    "access_key": raw_key,
                    "access_key_revoked": False,
                    "last_key_issued_at": utc_now(),
                }
            )

        # SECURITY BEST PRACTICE: Split PII (Personally Identifiable Information like emails/names)
        # away from standard gameplay data. This makes GDPR compliance and data deletion much easier.
        non_pii_data, pii_fields = split_pii(sanitized)
        coll = self._db[MongoColumns.PLAYERS]

        if player_id is not None:
            # upsert=True means "Update this document if it exists. If it doesn't, create it."
            # $set ensures we only update the fields provided, leaving other existing fields alone.
            await maybe_await(coll.update_one({"_id": player_id}, {"$set": non_pii_data}, upsert=True))
            created_id = str(player_id)
        else:
            # If no ID was provided, just insert it and let Mongo generate a new ObjectId.
            created_id = str((await maybe_await(coll.insert_one(non_pii_data))).inserted_id)

        # Store the sensitive PII data in an entirely different database collection.
        if pii_fields:
            await maybe_await(
                self._db[MongoColumns.PII].update_one(
                    {"player_id": created_id},
                    {
                        "$set": {
                            "player_id": created_id,
                            "fields": pii_fields,
                            "updated_at": utc_now(),
                        },
                        # $setOnInsert is a cool Mongo feature: this field is ONLY written
                        # if the document is being created for the first time, ignored on updates.
                        "$setOnInsert": {"created_at": utc_now()},
                    },
                    upsert=True,
                )
            )

        # Reconstruct a complete domain model to return to the application.
        doc = dict(non_pii_data)
        doc["id"] = created_id
        return player_doc_to_record(doc), raw_key

    async def get_players(self, *, access_key: str | None = None) -> list[PlayerRecord] | PlayerRecord | None:
        """Fetch all players, or specifically authenticate and fetch one by access_key."""
        if access_key is not None:
            key = access_key.strip()
            if not key:
                return None

            # $ne means "Not Equal". Find the user with this key, where revoked is NOT True.
            doc = await maybe_await(
                self._db[MongoColumns.PLAYERS].find_one(
                    {"access_key": key, "access_key_revoked": {"$ne": True}},
                    projection={"access_key": 0},  # Never return the key back out in the results
                )
            )
            if not doc:
                return None
            doc["id"] = str(doc.pop("_id"))
            return player_doc_to_record(doc)

        out: list[PlayerRecord] = []
        cursor = self._db[MongoColumns.PLAYERS].find({}, projection={"access_key": 0})
        for doc in await _cursor_to_docs(cursor):
            doc["id"] = str(doc.pop("_id"))
            out.append(player_doc_to_record(doc))
        return out

    async def upsert_character(self, data: dict[str, Any], *, character_id: str | None = None) -> str:
        """Create a new character or update an existing one."""
        if not isinstance(data, dict):
            raise ValueError("data must be a dict")

        doc = dict(data)
        # setdefault only applies the timestamp if 'created_at' isn't already in the dict.
        doc.setdefault(MongoColumns.CREATED_AT, utc_now())

        coll = self._db[MongoColumns.CHARACTERS]
        hid = character_id or doc.get("hid")

        if hid:
            await maybe_await(coll.update_one({"hid": hid}, {"$set": doc}, upsert=True))
            return str(hid)

        result = await maybe_await(coll.insert_one(doc))
        return str(result.inserted_id)

    async def delete_character(self, character_id: str) -> None:
        """Remove a character by ID."""
        await maybe_await(self._db[MongoColumns.CHARACTERS].delete_one({"hid": character_id}))

    async def delete_player(self, player_id: str) -> None:
        """Remove a player by ID, checking all variant ID types."""
        ids = player_id_variants(player_id)
        if not ids:
            return
        await maybe_await(self._db[MongoColumns.PLAYERS].delete_one({"$or": [{"_id": pid} for pid in ids]}))

    async def create_session(self, session_doc: dict[str, Any]) -> None:
        """Log the start of a new game/app session."""
        await maybe_await(self._db[MongoColumns.SESSIONS].insert_one(session_doc))

    async def finalize_session(
        self,
        *,
        session_id: str,
        termination_reason: str,
        status: str,
        session_ended_at: datetime,
        turns_completed: int,
        last_seq: int,
    ) -> None:
        """Update a session record with final metrics when it ends."""
        await maybe_await(
            self._db[MongoColumns.SESSIONS].update_one(
                {"session_id": session_id},
                {
                    "$set": {
                        "termination_reason": termination_reason,
                        "status": status,
                        "session_ended_at": session_ended_at,
                        "turns_completed": turns_completed,
                        "last_seq": last_seq,
                        "updated_at": utc_now(),
                    }
                },
            )
        )

    async def pause_session(self, *, session_id: str, paused_at: datetime) -> None:
        """Update a session record to reflect it is paused and awaiting resume."""
        await maybe_await(
            self._db[MongoColumns.SESSIONS].update_one(
                {"session_id": session_id},
                {
                    "$set": {
                        "status": "paused",
                        "paused_at": paused_at,
                        "updated_at": utc_now(),
                    }
                },
            )
        )

    async def resume_session(self, *, session_id: str, resumed_at: datetime) -> None:
        """Update a session record to reflect it has been resumed."""
        await maybe_await(
            self._db[MongoColumns.SESSIONS].update_one(
                {"session_id": session_id},
                {
                    "$set": {
                        "status": "active",
                        "resumed_at": resumed_at,
                        "updated_at": utc_now(),
                    },
                    "$unset": {"paused_at": ""},
                },
            )
        )

    async def get_session(self, *, session_id: str, player_id: str | None) -> SessionRecord | None:
        """Return a single persisted session record for the player."""
        doc = await maybe_await(
            self._db[MongoColumns.SESSIONS].find_one(
                {
                    MongoColumns.SESSION_ID: session_id,
                    MongoColumns.PLAYER_ID: player_id,
                }
            )
        )
        if not doc:
            return None
        return _to_session_record(doc)

    async def list_session_events(self, *, session_id: str) -> list[SessionEventRecord]:
        """Return all persisted session events in sequence order."""
        cursor = self._db[MongoColumns.SESSION_EVENTS].find({MongoColumns.SESSION_ID: session_id})
        sorter = getattr(cursor, "sort", None)
        if callable(sorter):
            cursor = sorter(MongoColumns.SEQ, 1)
        docs = await _cursor_to_docs(cursor)
        return [_to_session_event_record(doc) for doc in docs]

    async def append_session_event(
        self,
        *,
        session_id: str,
        player_id: str | None,
        direction: str,
        event_type: str,
        event_source: str,
        content: str,
        content_format: str,
        turn_index: int,
        visible_to_user: bool,
    ) -> SessionEventRecord | None:
        """Append one owned session event and advance the parent session sequence counter."""
        session_doc = await maybe_await(
            self._db[MongoColumns.SESSIONS].find_one(
                {
                    MongoColumns.SESSION_ID: session_id,
                    MongoColumns.PLAYER_ID: player_id,
                },
                projection={
                    MongoColumns.SESSION_ID: 1,
                    MongoColumns.LAST_SEQ: 1,
                },
            )
        )
        if not session_doc:
            return None

        last_seq = int(session_doc.get(MongoColumns.LAST_SEQ, 0) or 0)
        next_seq = last_seq + 1
        now = utc_now()
        event_id = str(uuid4())
        doc = {
            MongoColumns.SESSION_ID: session_id,
            MongoColumns.SEQ: next_seq,
            MongoColumns.EVENT_ID: event_id,
            MongoColumns.EVENT_TS: now,
            MongoColumns.DIRECTION: direction,
            MongoColumns.EVENT_TYPE: event_type,
            MongoColumns.EVENT_SOURCE: event_source,
            MongoColumns.CONTENT: content,
            MongoColumns.CONTENT_FORMAT: content_format,
            MongoColumns.TURN_INDEX: turn_index,
            MongoColumns.VISIBLE_TO_USER: visible_to_user,
            MongoColumns.PERSISTED_AT: now,
            MongoColumns.UPDATED_AT: now,
        }
        await maybe_await(self._db[MongoColumns.SESSION_EVENTS].insert_one(doc))
        await maybe_await(
            self._db[MongoColumns.SESSIONS].update_one(
                {MongoColumns.SESSION_ID: session_id},
                {
                    "$set": {
                        MongoColumns.LAST_SEQ: next_seq,
                        MongoColumns.UPDATED_AT: now,
                    }
                },
            )
        )
        return _to_session_event_record(doc)

    async def set_session_event_feedback(
        self,
        *,
        session_id: str,
        player_id: str | None,
        event_id: str,
        feedback: dict[str, Any],
    ) -> dict[str, Any] | None:
        """Store feedback on one persisted NPC message event owned by the player."""
        session_doc = await maybe_await(
            self._db[MongoColumns.SESSIONS].find_one(
                {
                    MongoColumns.SESSION_ID: session_id,
                    MongoColumns.PLAYER_ID: player_id,
                },
                projection={MongoColumns.SESSION_ID: 1},
            )
        )
        if not session_doc:
            return None

        now = utc_now()
        result = await maybe_await(
            self._db[MongoColumns.SESSION_EVENTS].update_one(
                {
                    MongoColumns.SESSION_ID: session_id,
                    MongoColumns.EVENT_ID: event_id,
                    MongoColumns.DIRECTION: "outbound",
                    MongoColumns.EVENT_TYPE: "message",
                    MongoColumns.EVENT_SOURCE: "npc",
                },
                {
                    "$set": {
                        MongoColumns.FEEDBACK: dict(feedback),
                        MongoColumns.UPDATED_AT: now,
                    }
                },
            )
        )
        if getattr(result, "matched_count", 0) == 0:
            return None

        await maybe_await(
            self._db[MongoColumns.SESSIONS].update_one(
                {MongoColumns.SESSION_ID: session_id},
                {"$set": {MongoColumns.UPDATED_AT: now}},
            )
        )
        return dict(feedback)

    async def clear_session_event_feedback(
        self,
        *,
        session_id: str,
        player_id: str | None,
        event_id: str,
    ) -> bool:
        """Remove feedback from one persisted NPC message event owned by the player."""
        session_doc = await maybe_await(
            self._db[MongoColumns.SESSIONS].find_one(
                {
                    MongoColumns.SESSION_ID: session_id,
                    MongoColumns.PLAYER_ID: player_id,
                },
                projection={MongoColumns.SESSION_ID: 1},
            )
        )
        if not session_doc:
            return False

        now = utc_now()
        result = await maybe_await(
            self._db[MongoColumns.SESSION_EVENTS].update_one(
                {
                    MongoColumns.SESSION_ID: session_id,
                    MongoColumns.EVENT_ID: event_id,
                    MongoColumns.DIRECTION: "outbound",
                    MongoColumns.EVENT_TYPE: "message",
                    MongoColumns.EVENT_SOURCE: "npc",
                },
                {
                    "$unset": {
                        MongoColumns.FEEDBACK: "",
                    },
                    "$set": {
                        MongoColumns.UPDATED_AT: now,
                    },
                },
            )
        )
        if getattr(result, "matched_count", 0) == 0:
            return False

        await maybe_await(
            self._db[MongoColumns.SESSIONS].update_one(
                {MongoColumns.SESSION_ID: session_id},
                {"$set": {MongoColumns.UPDATED_AT: now}},
            )
        )
        return True

    async def get_experiment(self, *, experiment_name: str) -> ExperimentRecord | None:
        """Return one persisted experiment record."""
        doc = await maybe_await(self._db[MongoColumns.EXPERIMENTS].find_one({MongoColumns.NAME: experiment_name}))
        if not doc:
            return None
        return _to_experiment_record(doc)

    async def upsert_experiment(
        self,
        *,
        experiment_name: str,
        description: str,
        config_snapshot: dict[str, Any],
        progress: dict[str, Any],
    ) -> ExperimentRecord:
        """Create or update an experiment metadata row."""
        now = utc_now()
        await maybe_await(
            self._db[MongoColumns.EXPERIMENTS].update_one(
                {MongoColumns.NAME: experiment_name},
                {
                    "$set": {
                        MongoColumns.NAME: experiment_name,
                        "description": description,
                        MongoColumns.CONFIG_SNAPSHOT: config_snapshot,
                        MongoColumns.PROGRESS: progress,
                        MongoColumns.UPDATED_AT: now,
                    },
                    "$setOnInsert": {MongoColumns.CREATED_AT: now},
                },
                upsert=True,
            )
        )
        record = await self.get_experiment(experiment_name=experiment_name)
        if record is None:
            raise ValueError(f"Experiment {experiment_name!r} was not persisted")
        return record

    async def set_experiment_progress(
        self,
        *,
        experiment_name: str,
        progress: dict[str, Any],
    ) -> ExperimentRecord | None:
        """Persist the latest experiment progress snapshot."""
        now = utc_now()
        await maybe_await(
            self._db[MongoColumns.EXPERIMENTS].update_one(
                {MongoColumns.NAME: experiment_name},
                {
                    "$set": {
                        MongoColumns.PROGRESS: progress,
                        MongoColumns.UPDATED_AT: now,
                    }
                },
            )
        )
        return await self.get_experiment(experiment_name=experiment_name)

    async def create_assignment(self, *, assignment_doc: dict[str, Any], allow_concurrent: bool = False) -> AssignmentRecord:
        """Persist a new experiment assignment row."""
        experiment_name = str(assignment_doc.get(MongoColumns.EXPERIMENT_NAME) or "")
        player_id = str(assignment_doc.get(MongoColumns.PLAYER_ID) or "")
        if not experiment_name or not player_id:
            raise ValueError("assignment_doc must include experiment_name and player_id")

        if not allow_concurrent:
            existing = await self.get_active_assignment(experiment_name=experiment_name, player_id=player_id)
            if existing is not None:
                raise ValueError("Player already has an active assignment for this experiment")

        now = utc_now()
        doc = dict(assignment_doc)
        doc.setdefault(MongoColumns.ASSIGNMENT_ID, str(uuid4()))
        doc.setdefault(MongoColumns.STATUS, "assigned")
        doc.setdefault("assigned_at", now)
        doc.setdefault(MongoColumns.CREATED_AT, now)
        doc[MongoColumns.UPDATED_AT] = now
        await maybe_await(self._db[MongoColumns.ASSIGNMENTS].insert_one(doc))
        record = await self.get_assignment(assignment_id=doc[MongoColumns.ASSIGNMENT_ID])
        if record is None:
            raise ValueError("Assignment insert did not persist")
        return record

    async def get_assignment(self, *, assignment_id: str) -> AssignmentRecord | None:
        """Return one assignment row by assignment_id."""
        doc = await maybe_await(self._db[MongoColumns.ASSIGNMENTS].find_one({MongoColumns.ASSIGNMENT_ID: assignment_id}))
        if not doc:
            return None
        return _to_assignment_record(doc)

    async def get_active_assignment(self, *, experiment_name: str, player_id: str) -> AssignmentRecord | None:
        """Return the current active assignment for one player in one experiment."""
        cursor = self._db[MongoColumns.ASSIGNMENTS].find(
            {
                MongoColumns.EXPERIMENT_NAME: experiment_name,
                MongoColumns.PLAYER_ID: player_id,
                MongoColumns.STATUS: {"$in": sorted(ACTIVE_ASSIGNMENT_STATUSES)},
            }
        )
        sorter = getattr(cursor, "sort", None)
        if callable(sorter):
            cursor = sorter(MongoColumns.UPDATED_AT, -1)
        docs = await _cursor_to_docs(cursor)
        if not docs:
            return None
        return _to_assignment_record(docs[0])

    async def get_assignment_for_session_id(self, *, session_id: str) -> AssignmentRecord | None:
        """Return the assignment that has this session as its active session."""
        doc = await maybe_await(self._db[MongoColumns.ASSIGNMENTS].find_one({MongoColumns.ACTIVE_SESSION_ID: session_id}))
        if not doc:
            return None
        return _to_assignment_record(doc)

    async def get_latest_experiment_assignment_for_player(self, *, player_id: str) -> AssignmentRecord | None:
        """Return the newest experiment assignment for one player."""
        cursor = self._db[MongoColumns.ASSIGNMENTS].find({MongoColumns.PLAYER_ID: player_id})
        sorter = getattr(cursor, "sort", None)
        if callable(sorter):
            cursor = sorter(MongoColumns.UPDATED_AT, -1)
        docs = await _cursor_to_docs(cursor)
        if not docs:
            return None
        return _to_assignment_record(docs[0])

    async def list_assignments(
        self,
        *,
        experiment_name: str,
        player_id: str | None = None,
        statuses: list[str] | None = None,
        game_name: str | None = None,
    ) -> list[AssignmentRecord]:
        """List assignment rows matching the requested filters."""
        query: dict[str, Any] = {MongoColumns.EXPERIMENT_NAME: experiment_name}
        if player_id is not None:
            query[MongoColumns.PLAYER_ID] = player_id
        if statuses:
            query[MongoColumns.STATUS] = {"$in": list(statuses)}
        if game_name is not None:
            query[MongoColumns.GAME_NAME] = game_name

        cursor = self._db[MongoColumns.ASSIGNMENTS].find(query)
        sorter = getattr(cursor, "sort", None)
        if callable(sorter):
            cursor = sorter("assigned_at", 1)
        docs = await _cursor_to_docs(cursor)
        return [_to_assignment_record(doc) for doc in docs]

    async def update_assignment_status(
        self,
        *,
        assignment_id: str,
        status: str,
        active_session_id: str | None = None,
    ) -> AssignmentRecord | None:
        """Update assignment status and lifecycle timestamps."""
        now = utc_now()
        updates: dict[str, Any] = {
            MongoColumns.STATUS: status,
            MongoColumns.UPDATED_AT: now,
        }
        if status == "assigned":
            updates.setdefault("assigned_at", now)
            updates[MongoColumns.ACTIVE_SESSION_ID] = None
        elif status == "in_progress":
            updates["started_at"] = now
            updates[MongoColumns.ACTIVE_SESSION_ID] = active_session_id
        elif status == "completed":
            updates["completed_at"] = now
            updates[MongoColumns.ACTIVE_SESSION_ID] = None
        elif status == "interrupted":
            updates["interrupted_at"] = now
            updates[MongoColumns.ACTIVE_SESSION_ID] = None

        await maybe_await(
            self._db[MongoColumns.ASSIGNMENTS].update_one(
                {MongoColumns.ASSIGNMENT_ID: assignment_id},
                {"$set": updates},
            )
        )
        return await self.get_assignment(assignment_id=assignment_id)

    async def set_assignment_form_response(
        self,
        *,
        assignment_id: str,
        form_key: str,
        response: dict[str, Any],
    ) -> AssignmentRecord | None:
        """Store one form response payload on an assignment row."""
        await maybe_await(
            self._db[MongoColumns.ASSIGNMENTS].update_one(
                {MongoColumns.ASSIGNMENT_ID: assignment_id},
                {
                    "$set": {
                        f"{MongoColumns.FORM_RESPONSES}.{form_key}": response,
                        MongoColumns.UPDATED_AT: utc_now(),
                    }
                },
            )
        )
        return await self.get_assignment(assignment_id=assignment_id)

    async def set_player_form_response(
        self,
        *,
        player_id: str,
        experiment_name: str,
        form_key: str,
        response: dict[str, Any],
    ) -> PlayerFormsRecord | None:
        """Upsert one before-play form response into the forms collection."""
        now = utc_now()
        await maybe_await(
            self._db[MongoColumns.FORMS].update_one(
                {MongoColumns.PLAYER_ID: player_id, MongoColumns.EXPERIMENT_NAME: experiment_name},
                {
                    "$set": {f"data.{form_key}": response, MongoColumns.UPDATED_AT: now},
                    "$setOnInsert": {
                        MongoColumns.PLAYER_ID: player_id,
                        MongoColumns.EXPERIMENT_NAME: experiment_name,
                        MongoColumns.CREATED_AT: now,
                    },
                },
                upsert=True,
            )
        )
        return await self.get_player_forms(player_id=player_id, experiment_name=experiment_name)

    async def get_player_forms(
        self,
        *,
        player_id: str,
        experiment_name: str,
    ) -> PlayerFormsRecord | None:
        """Return the before-play form responses for a player in an experiment."""
        doc = await maybe_await(
            self._db[MongoColumns.FORMS].find_one({MongoColumns.PLAYER_ID: player_id, MongoColumns.EXPERIMENT_NAME: experiment_name})
        )
        if not doc:
            return None
        return PlayerFormsRecord(
            player_id=doc[MongoColumns.PLAYER_ID],
            experiment_name=doc[MongoColumns.EXPERIMENT_NAME],
            data=doc.get("data", {}),
            created_at=doc.get(MongoColumns.CREATED_AT),
            updated_at=doc.get(MongoColumns.UPDATED_AT),
        )

    async def get_session_reconstruction(
        self,
        *,
        session_id: str,
        player_id: str,
    ) -> dict[str, Any] | None:
        """Return session metadata and ordered event stream for replay."""
        # 1. Fetch the parent session record
        session_doc = await maybe_await(
            self._db[MongoColumns.SESSIONS].find_one(
                {"session_id": session_id, "player_id": player_id},
                projection={"_id": 0},
            )
        )
        if not session_doc:
            return None

        # 2. Fetch all individual events tied to this session
        events: list[dict[str, Any]] = []
        cursor = self._db[MongoColumns.SESSION_EVENTS].find(
            {"session_id": session_id},
            projection={"_id": 0},
        )

        # 3. Ensure the events are sorted by their sequence number ('seq') in ascending order (1).
        # This guarantees they are replayed in the exact order they occurred.
        sorter = getattr(cursor, "sort", None)
        if callable(sorter):
            cursor = sorter("seq", 1)

        events.extend(await _cursor_to_docs(cursor))

        # Return both parts together
        return {"session": session_doc, "events": events}
append_session_event(*, session_id, player_id, direction, event_type, event_source, content, content_format, turn_index, visible_to_user) async

Append one owned session event and advance the parent session sequence counter.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
async def append_session_event(
    self,
    *,
    session_id: str,
    player_id: str | None,
    direction: str,
    event_type: str,
    event_source: str,
    content: str,
    content_format: str,
    turn_index: int,
    visible_to_user: bool,
) -> SessionEventRecord | None:
    """Append one owned session event and advance the parent session sequence counter."""
    session_doc = await maybe_await(
        self._db[MongoColumns.SESSIONS].find_one(
            {
                MongoColumns.SESSION_ID: session_id,
                MongoColumns.PLAYER_ID: player_id,
            },
            projection={
                MongoColumns.SESSION_ID: 1,
                MongoColumns.LAST_SEQ: 1,
            },
        )
    )
    if not session_doc:
        return None

    last_seq = int(session_doc.get(MongoColumns.LAST_SEQ, 0) or 0)
    next_seq = last_seq + 1
    now = utc_now()
    event_id = str(uuid4())
    doc = {
        MongoColumns.SESSION_ID: session_id,
        MongoColumns.SEQ: next_seq,
        MongoColumns.EVENT_ID: event_id,
        MongoColumns.EVENT_TS: now,
        MongoColumns.DIRECTION: direction,
        MongoColumns.EVENT_TYPE: event_type,
        MongoColumns.EVENT_SOURCE: event_source,
        MongoColumns.CONTENT: content,
        MongoColumns.CONTENT_FORMAT: content_format,
        MongoColumns.TURN_INDEX: turn_index,
        MongoColumns.VISIBLE_TO_USER: visible_to_user,
        MongoColumns.PERSISTED_AT: now,
        MongoColumns.UPDATED_AT: now,
    }
    await maybe_await(self._db[MongoColumns.SESSION_EVENTS].insert_one(doc))
    await maybe_await(
        self._db[MongoColumns.SESSIONS].update_one(
            {MongoColumns.SESSION_ID: session_id},
            {
                "$set": {
                    MongoColumns.LAST_SEQ: next_seq,
                    MongoColumns.UPDATED_AT: now,
                }
            },
        )
    )
    return _to_session_event_record(doc)
clear_session_event_feedback(*, session_id, player_id, event_id) async

Remove feedback from one persisted NPC message event owned by the player.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
async def clear_session_event_feedback(
    self,
    *,
    session_id: str,
    player_id: str | None,
    event_id: str,
) -> bool:
    """Remove feedback from one persisted NPC message event owned by the player."""
    session_doc = await maybe_await(
        self._db[MongoColumns.SESSIONS].find_one(
            {
                MongoColumns.SESSION_ID: session_id,
                MongoColumns.PLAYER_ID: player_id,
            },
            projection={MongoColumns.SESSION_ID: 1},
        )
    )
    if not session_doc:
        return False

    now = utc_now()
    result = await maybe_await(
        self._db[MongoColumns.SESSION_EVENTS].update_one(
            {
                MongoColumns.SESSION_ID: session_id,
                MongoColumns.EVENT_ID: event_id,
                MongoColumns.DIRECTION: "outbound",
                MongoColumns.EVENT_TYPE: "message",
                MongoColumns.EVENT_SOURCE: "npc",
            },
            {
                "$unset": {
                    MongoColumns.FEEDBACK: "",
                },
                "$set": {
                    MongoColumns.UPDATED_AT: now,
                },
            },
        )
    )
    if getattr(result, "matched_count", 0) == 0:
        return False

    await maybe_await(
        self._db[MongoColumns.SESSIONS].update_one(
            {MongoColumns.SESSION_ID: session_id},
            {"$set": {MongoColumns.UPDATED_AT: now}},
        )
    )
    return True
create_assignment(*, assignment_doc, allow_concurrent=False) async

Persist a new experiment assignment row.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
async def create_assignment(self, *, assignment_doc: dict[str, Any], allow_concurrent: bool = False) -> AssignmentRecord:
    """Persist a new experiment assignment row."""
    experiment_name = str(assignment_doc.get(MongoColumns.EXPERIMENT_NAME) or "")
    player_id = str(assignment_doc.get(MongoColumns.PLAYER_ID) or "")
    if not experiment_name or not player_id:
        raise ValueError("assignment_doc must include experiment_name and player_id")

    if not allow_concurrent:
        existing = await self.get_active_assignment(experiment_name=experiment_name, player_id=player_id)
        if existing is not None:
            raise ValueError("Player already has an active assignment for this experiment")

    now = utc_now()
    doc = dict(assignment_doc)
    doc.setdefault(MongoColumns.ASSIGNMENT_ID, str(uuid4()))
    doc.setdefault(MongoColumns.STATUS, "assigned")
    doc.setdefault("assigned_at", now)
    doc.setdefault(MongoColumns.CREATED_AT, now)
    doc[MongoColumns.UPDATED_AT] = now
    await maybe_await(self._db[MongoColumns.ASSIGNMENTS].insert_one(doc))
    record = await self.get_assignment(assignment_id=doc[MongoColumns.ASSIGNMENT_ID])
    if record is None:
        raise ValueError("Assignment insert did not persist")
    return record
create_player(*, player_data, player_id=None, issue_access_key=False, access_key=None) async

Create or update a player, optionally issuing them a new access key.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
async def create_player(
    self,
    *,
    player_data: dict[str, Any],
    player_id: str | None = None,
    issue_access_key: bool = False,
    access_key: str | None = None,
) -> tuple[PlayerRecord, str | None]:
    """Create or update a player, optionally issuing them a new access key."""
    sanitized = sanitize_player_data(player_data)
    raw_key: str | None = None

    if issue_access_key and access_key is not None:
        raise ValueError("Use either issue_access_key=True or an explicit access_key, not both.")

    if access_key is not None:
        raw_key = validate_access_key(access_key)
        sanitized.update(
            {
                "access_key": raw_key,
                "access_key_revoked": False,
                "last_key_issued_at": utc_now(),
            }
        )
    elif issue_access_key:
        raw_key = generate_access_key()
        sanitized.update(
            {
                "access_key": raw_key,
                "access_key_revoked": False,
                "last_key_issued_at": utc_now(),
            }
        )

    # SECURITY BEST PRACTICE: Split PII (Personally Identifiable Information like emails/names)
    # away from standard gameplay data. This makes GDPR compliance and data deletion much easier.
    non_pii_data, pii_fields = split_pii(sanitized)
    coll = self._db[MongoColumns.PLAYERS]

    if player_id is not None:
        # upsert=True means "Update this document if it exists. If it doesn't, create it."
        # $set ensures we only update the fields provided, leaving other existing fields alone.
        await maybe_await(coll.update_one({"_id": player_id}, {"$set": non_pii_data}, upsert=True))
        created_id = str(player_id)
    else:
        # If no ID was provided, just insert it and let Mongo generate a new ObjectId.
        created_id = str((await maybe_await(coll.insert_one(non_pii_data))).inserted_id)

    # Store the sensitive PII data in an entirely different database collection.
    if pii_fields:
        await maybe_await(
            self._db[MongoColumns.PII].update_one(
                {"player_id": created_id},
                {
                    "$set": {
                        "player_id": created_id,
                        "fields": pii_fields,
                        "updated_at": utc_now(),
                    },
                    # $setOnInsert is a cool Mongo feature: this field is ONLY written
                    # if the document is being created for the first time, ignored on updates.
                    "$setOnInsert": {"created_at": utc_now()},
                },
                upsert=True,
            )
        )

    # Reconstruct a complete domain model to return to the application.
    doc = dict(non_pii_data)
    doc["id"] = created_id
    return player_doc_to_record(doc), raw_key
create_session(session_doc) async

Log the start of a new game/app session.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
319
320
321
async def create_session(self, session_doc: dict[str, Any]) -> None:
    """Log the start of a new game/app session."""
    await maybe_await(self._db[MongoColumns.SESSIONS].insert_one(session_doc))
delete_character(character_id) async

Remove a character by ID.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
308
309
310
async def delete_character(self, character_id: str) -> None:
    """Remove a character by ID."""
    await maybe_await(self._db[MongoColumns.CHARACTERS].delete_one({"hid": character_id}))
delete_player(player_id) async

Remove a player by ID, checking all variant ID types.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
312
313
314
315
316
317
async def delete_player(self, player_id: str) -> None:
    """Remove a player by ID, checking all variant ID types."""
    ids = player_id_variants(player_id)
    if not ids:
        return
    await maybe_await(self._db[MongoColumns.PLAYERS].delete_one({"$or": [{"_id": pid} for pid in ids]}))
finalize_session(*, session_id, termination_reason, status, session_ended_at, turns_completed, last_seq) async

Update a session record with final metrics when it ends.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
async def finalize_session(
    self,
    *,
    session_id: str,
    termination_reason: str,
    status: str,
    session_ended_at: datetime,
    turns_completed: int,
    last_seq: int,
) -> None:
    """Update a session record with final metrics when it ends."""
    await maybe_await(
        self._db[MongoColumns.SESSIONS].update_one(
            {"session_id": session_id},
            {
                "$set": {
                    "termination_reason": termination_reason,
                    "status": status,
                    "session_ended_at": session_ended_at,
                    "turns_completed": turns_completed,
                    "last_seq": last_seq,
                    "updated_at": utc_now(),
                }
            },
        )
    )
get_active_assignment(*, experiment_name, player_id) async

Return the current active assignment for one player in one experiment.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
async def get_active_assignment(self, *, experiment_name: str, player_id: str) -> AssignmentRecord | None:
    """Return the current active assignment for one player in one experiment."""
    cursor = self._db[MongoColumns.ASSIGNMENTS].find(
        {
            MongoColumns.EXPERIMENT_NAME: experiment_name,
            MongoColumns.PLAYER_ID: player_id,
            MongoColumns.STATUS: {"$in": sorted(ACTIVE_ASSIGNMENT_STATUSES)},
        }
    )
    sorter = getattr(cursor, "sort", None)
    if callable(sorter):
        cursor = sorter(MongoColumns.UPDATED_AT, -1)
    docs = await _cursor_to_docs(cursor)
    if not docs:
        return None
    return _to_assignment_record(docs[0])
get_assignment(*, assignment_id) async

Return one assignment row by assignment_id.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
651
652
653
654
655
656
async def get_assignment(self, *, assignment_id: str) -> AssignmentRecord | None:
    """Return one assignment row by assignment_id."""
    doc = await maybe_await(self._db[MongoColumns.ASSIGNMENTS].find_one({MongoColumns.ASSIGNMENT_ID: assignment_id}))
    if not doc:
        return None
    return _to_assignment_record(doc)
get_assignment_for_session_id(*, session_id) async

Return the assignment that has this session as its active session.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
675
676
677
678
679
680
async def get_assignment_for_session_id(self, *, session_id: str) -> AssignmentRecord | None:
    """Return the assignment that has this session as its active session."""
    doc = await maybe_await(self._db[MongoColumns.ASSIGNMENTS].find_one({MongoColumns.ACTIVE_SESSION_ID: session_id}))
    if not doc:
        return None
    return _to_assignment_record(doc)
get_character(*, hid) async

Strict version of get_characters that guarantees a single record is returned.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
159
160
161
162
163
164
async def get_character(self, *, hid: str) -> CharacterRecord:
    """Strict version of get_characters that guarantees a single record is returned."""
    result = await self.get_characters(hid=hid)
    if not isinstance(result, CharacterRecord):
        raise ValueError(f"Character with hid='{hid}' not found")
    return result
get_characters(*, hid=None) async

Fetch a specific character by ID, or list all of them if no ID is provided.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
146
147
148
149
150
151
152
153
154
155
156
157
async def get_characters(self, *, hid: str | None = None) -> list[CharacterRecord] | CharacterRecord:
    """Fetch a specific character by ID, or list all of them if no ID is provided."""
    if hid is not None:
        # projection={"_id": 0} tells Mongo to NOT return its internal ObjectId.
        # We do this because ObjectIds often aren't JSON serializable by default.
        doc = await maybe_await(self._db[MongoColumns.CHARACTERS].find_one({"hid": hid}, projection={"_id": 0}))
        if not doc:
            raise ValueError(f"Character with hid='{hid}' not found")
        return _to_character_record(doc)

    # If no 'hid' was provided, fall back to fetching everything.
    return await self.list_characters()
get_experiment(*, experiment_name) async

Return one persisted experiment record.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
567
568
569
570
571
572
async def get_experiment(self, *, experiment_name: str) -> ExperimentRecord | None:
    """Return one persisted experiment record."""
    doc = await maybe_await(self._db[MongoColumns.EXPERIMENTS].find_one({MongoColumns.NAME: experiment_name}))
    if not doc:
        return None
    return _to_experiment_record(doc)
get_latest_experiment_assignment_for_player(*, player_id) async

Return the newest experiment assignment for one player.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
682
683
684
685
686
687
688
689
690
691
async def get_latest_experiment_assignment_for_player(self, *, player_id: str) -> AssignmentRecord | None:
    """Return the newest experiment assignment for one player."""
    cursor = self._db[MongoColumns.ASSIGNMENTS].find({MongoColumns.PLAYER_ID: player_id})
    sorter = getattr(cursor, "sort", None)
    if callable(sorter):
        cursor = sorter(MongoColumns.UPDATED_AT, -1)
    docs = await _cursor_to_docs(cursor)
    if not docs:
        return None
    return _to_assignment_record(docs[0])
get_player(*, player_id) async

Look up a player by their ID.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
async def get_player(self, *, player_id: str) -> PlayerRecord | None:
    """Look up a player by their ID."""
    # player_id_variants likely handles the fact that an ID could be stored
    # as a raw string or an ObjectId in the database.
    ids = player_id_variants(player_id)
    if not ids:
        return None

    # $or is a Mongo operator: "Find a document where _id matches ANY of the IDs in this list."
    doc = await maybe_await(self._db[MongoColumns.PLAYERS].find_one({"$or": [{"_id": pid} for pid in ids]}))
    if not doc:
        return None

    # Rename the internal Mongo '_id' to a standard 'id' for the application to use.
    # .pop() removes it from the dict and returns the value at the same time.
    doc["id"] = str(doc.pop("_id"))
    return player_doc_to_record(doc)
get_player_forms(*, player_id, experiment_name) async

Return the before-play form responses for a player in an experiment.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
async def get_player_forms(
    self,
    *,
    player_id: str,
    experiment_name: str,
) -> PlayerFormsRecord | None:
    """Return the before-play form responses for a player in an experiment."""
    doc = await maybe_await(
        self._db[MongoColumns.FORMS].find_one({MongoColumns.PLAYER_ID: player_id, MongoColumns.EXPERIMENT_NAME: experiment_name})
    )
    if not doc:
        return None
    return PlayerFormsRecord(
        player_id=doc[MongoColumns.PLAYER_ID],
        experiment_name=doc[MongoColumns.EXPERIMENT_NAME],
        data=doc.get("data", {}),
        created_at=doc.get(MongoColumns.CREATED_AT),
        updated_at=doc.get(MongoColumns.UPDATED_AT),
    )
get_players(*, access_key=None) async

Fetch all players, or specifically authenticate and fetch one by access_key.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
async def get_players(self, *, access_key: str | None = None) -> list[PlayerRecord] | PlayerRecord | None:
    """Fetch all players, or specifically authenticate and fetch one by access_key."""
    if access_key is not None:
        key = access_key.strip()
        if not key:
            return None

        # $ne means "Not Equal". Find the user with this key, where revoked is NOT True.
        doc = await maybe_await(
            self._db[MongoColumns.PLAYERS].find_one(
                {"access_key": key, "access_key_revoked": {"$ne": True}},
                projection={"access_key": 0},  # Never return the key back out in the results
            )
        )
        if not doc:
            return None
        doc["id"] = str(doc.pop("_id"))
        return player_doc_to_record(doc)

    out: list[PlayerRecord] = []
    cursor = self._db[MongoColumns.PLAYERS].find({}, projection={"access_key": 0})
    for doc in await _cursor_to_docs(cursor):
        doc["id"] = str(doc.pop("_id"))
        out.append(player_doc_to_record(doc))
    return out
get_session(*, session_id, player_id) async

Return a single persisted session record for the player.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
381
382
383
384
385
386
387
388
389
390
391
392
393
async def get_session(self, *, session_id: str, player_id: str | None) -> SessionRecord | None:
    """Return a single persisted session record for the player."""
    doc = await maybe_await(
        self._db[MongoColumns.SESSIONS].find_one(
            {
                MongoColumns.SESSION_ID: session_id,
                MongoColumns.PLAYER_ID: player_id,
            }
        )
    )
    if not doc:
        return None
    return _to_session_record(doc)
get_session_reconstruction(*, session_id, player_id) async

Return session metadata and ordered event stream for replay.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
async def get_session_reconstruction(
    self,
    *,
    session_id: str,
    player_id: str,
) -> dict[str, Any] | None:
    """Return session metadata and ordered event stream for replay."""
    # 1. Fetch the parent session record
    session_doc = await maybe_await(
        self._db[MongoColumns.SESSIONS].find_one(
            {"session_id": session_id, "player_id": player_id},
            projection={"_id": 0},
        )
    )
    if not session_doc:
        return None

    # 2. Fetch all individual events tied to this session
    events: list[dict[str, Any]] = []
    cursor = self._db[MongoColumns.SESSION_EVENTS].find(
        {"session_id": session_id},
        projection={"_id": 0},
    )

    # 3. Ensure the events are sorted by their sequence number ('seq') in ascending order (1).
    # This guarantees they are replayed in the exact order they occurred.
    sorter = getattr(cursor, "sort", None)
    if callable(sorter):
        cursor = sorter("seq", 1)

    events.extend(await _cursor_to_docs(cursor))

    # Return both parts together
    return {"session": session_doc, "events": events}
list_assignments(*, experiment_name, player_id=None, statuses=None, game_name=None) async

List assignment rows matching the requested filters.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
async def list_assignments(
    self,
    *,
    experiment_name: str,
    player_id: str | None = None,
    statuses: list[str] | None = None,
    game_name: str | None = None,
) -> list[AssignmentRecord]:
    """List assignment rows matching the requested filters."""
    query: dict[str, Any] = {MongoColumns.EXPERIMENT_NAME: experiment_name}
    if player_id is not None:
        query[MongoColumns.PLAYER_ID] = player_id
    if statuses:
        query[MongoColumns.STATUS] = {"$in": list(statuses)}
    if game_name is not None:
        query[MongoColumns.GAME_NAME] = game_name

    cursor = self._db[MongoColumns.ASSIGNMENTS].find(query)
    sorter = getattr(cursor, "sort", None)
    if callable(sorter):
        cursor = sorter("assigned_at", 1)
    docs = await _cursor_to_docs(cursor)
    return [_to_assignment_record(doc) for doc in docs]
list_characters() async

Fetch all character records from the database.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
166
167
168
169
170
171
async def list_characters(self) -> list[CharacterRecord]:
    """Fetch all character records from the database."""
    # Find with an empty query `{}` means "get everything".
    cursor = self._db[MongoColumns.CHARACTERS].find({}, projection={"_id": 0})
    docs = await _cursor_to_docs(cursor)
    return [_to_character_record(doc) for doc in docs]
list_session_events(*, session_id) async

Return all persisted session events in sequence order.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
395
396
397
398
399
400
401
402
async def list_session_events(self, *, session_id: str) -> list[SessionEventRecord]:
    """Return all persisted session events in sequence order."""
    cursor = self._db[MongoColumns.SESSION_EVENTS].find({MongoColumns.SESSION_ID: session_id})
    sorter = getattr(cursor, "sort", None)
    if callable(sorter):
        cursor = sorter(MongoColumns.SEQ, 1)
    docs = await _cursor_to_docs(cursor)
    return [_to_session_event_record(doc) for doc in docs]
pause_session(*, session_id, paused_at) async

Update a session record to reflect it is paused and awaiting resume.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
350
351
352
353
354
355
356
357
358
359
360
361
362
363
async def pause_session(self, *, session_id: str, paused_at: datetime) -> None:
    """Update a session record to reflect it is paused and awaiting resume."""
    await maybe_await(
        self._db[MongoColumns.SESSIONS].update_one(
            {"session_id": session_id},
            {
                "$set": {
                    "status": "paused",
                    "paused_at": paused_at,
                    "updated_at": utc_now(),
                }
            },
        )
    )
resume_session(*, session_id, resumed_at) async

Update a session record to reflect it has been resumed.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
async def resume_session(self, *, session_id: str, resumed_at: datetime) -> None:
    """Update a session record to reflect it has been resumed."""
    await maybe_await(
        self._db[MongoColumns.SESSIONS].update_one(
            {"session_id": session_id},
            {
                "$set": {
                    "status": "active",
                    "resumed_at": resumed_at,
                    "updated_at": utc_now(),
                },
                "$unset": {"paused_at": ""},
            },
        )
    )
set_assignment_form_response(*, assignment_id, form_key, response) async

Store one form response payload on an assignment row.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
async def set_assignment_form_response(
    self,
    *,
    assignment_id: str,
    form_key: str,
    response: dict[str, Any],
) -> AssignmentRecord | None:
    """Store one form response payload on an assignment row."""
    await maybe_await(
        self._db[MongoColumns.ASSIGNMENTS].update_one(
            {MongoColumns.ASSIGNMENT_ID: assignment_id},
            {
                "$set": {
                    f"{MongoColumns.FORM_RESPONSES}.{form_key}": response,
                    MongoColumns.UPDATED_AT: utc_now(),
                }
            },
        )
    )
    return await self.get_assignment(assignment_id=assignment_id)
set_experiment_progress(*, experiment_name, progress) async

Persist the latest experiment progress snapshot.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
async def set_experiment_progress(
    self,
    *,
    experiment_name: str,
    progress: dict[str, Any],
) -> ExperimentRecord | None:
    """Persist the latest experiment progress snapshot."""
    now = utc_now()
    await maybe_await(
        self._db[MongoColumns.EXPERIMENTS].update_one(
            {MongoColumns.NAME: experiment_name},
            {
                "$set": {
                    MongoColumns.PROGRESS: progress,
                    MongoColumns.UPDATED_AT: now,
                }
            },
        )
    )
    return await self.get_experiment(experiment_name=experiment_name)
set_player_form_response(*, player_id, experiment_name, form_key, response) async

Upsert one before-play form response into the forms collection.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
async def set_player_form_response(
    self,
    *,
    player_id: str,
    experiment_name: str,
    form_key: str,
    response: dict[str, Any],
) -> PlayerFormsRecord | None:
    """Upsert one before-play form response into the forms collection."""
    now = utc_now()
    await maybe_await(
        self._db[MongoColumns.FORMS].update_one(
            {MongoColumns.PLAYER_ID: player_id, MongoColumns.EXPERIMENT_NAME: experiment_name},
            {
                "$set": {f"data.{form_key}": response, MongoColumns.UPDATED_AT: now},
                "$setOnInsert": {
                    MongoColumns.PLAYER_ID: player_id,
                    MongoColumns.EXPERIMENT_NAME: experiment_name,
                    MongoColumns.CREATED_AT: now,
                },
            },
            upsert=True,
        )
    )
    return await self.get_player_forms(player_id=player_id, experiment_name=experiment_name)
set_session_event_feedback(*, session_id, player_id, event_id, feedback) async

Store feedback on one persisted NPC message event owned by the player.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
async def set_session_event_feedback(
    self,
    *,
    session_id: str,
    player_id: str | None,
    event_id: str,
    feedback: dict[str, Any],
) -> dict[str, Any] | None:
    """Store feedback on one persisted NPC message event owned by the player."""
    session_doc = await maybe_await(
        self._db[MongoColumns.SESSIONS].find_one(
            {
                MongoColumns.SESSION_ID: session_id,
                MongoColumns.PLAYER_ID: player_id,
            },
            projection={MongoColumns.SESSION_ID: 1},
        )
    )
    if not session_doc:
        return None

    now = utc_now()
    result = await maybe_await(
        self._db[MongoColumns.SESSION_EVENTS].update_one(
            {
                MongoColumns.SESSION_ID: session_id,
                MongoColumns.EVENT_ID: event_id,
                MongoColumns.DIRECTION: "outbound",
                MongoColumns.EVENT_TYPE: "message",
                MongoColumns.EVENT_SOURCE: "npc",
            },
            {
                "$set": {
                    MongoColumns.FEEDBACK: dict(feedback),
                    MongoColumns.UPDATED_AT: now,
                }
            },
        )
    )
    if getattr(result, "matched_count", 0) == 0:
        return None

    await maybe_await(
        self._db[MongoColumns.SESSIONS].update_one(
            {MongoColumns.SESSION_ID: session_id},
            {"$set": {MongoColumns.UPDATED_AT: now}},
        )
    )
    return dict(feedback)
update_assignment_status(*, assignment_id, status, active_session_id=None) async

Update assignment status and lifecycle timestamps.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
async def update_assignment_status(
    self,
    *,
    assignment_id: str,
    status: str,
    active_session_id: str | None = None,
) -> AssignmentRecord | None:
    """Update assignment status and lifecycle timestamps."""
    now = utc_now()
    updates: dict[str, Any] = {
        MongoColumns.STATUS: status,
        MongoColumns.UPDATED_AT: now,
    }
    if status == "assigned":
        updates.setdefault("assigned_at", now)
        updates[MongoColumns.ACTIVE_SESSION_ID] = None
    elif status == "in_progress":
        updates["started_at"] = now
        updates[MongoColumns.ACTIVE_SESSION_ID] = active_session_id
    elif status == "completed":
        updates["completed_at"] = now
        updates[MongoColumns.ACTIVE_SESSION_ID] = None
    elif status == "interrupted":
        updates["interrupted_at"] = now
        updates[MongoColumns.ACTIVE_SESSION_ID] = None

    await maybe_await(
        self._db[MongoColumns.ASSIGNMENTS].update_one(
            {MongoColumns.ASSIGNMENT_ID: assignment_id},
            {"$set": updates},
        )
    )
    return await self.get_assignment(assignment_id=assignment_id)
upsert_character(data, *, character_id=None) async

Create a new character or update an existing one.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
async def upsert_character(self, data: dict[str, Any], *, character_id: str | None = None) -> str:
    """Create a new character or update an existing one."""
    if not isinstance(data, dict):
        raise ValueError("data must be a dict")

    doc = dict(data)
    # setdefault only applies the timestamp if 'created_at' isn't already in the dict.
    doc.setdefault(MongoColumns.CREATED_AT, utc_now())

    coll = self._db[MongoColumns.CHARACTERS]
    hid = character_id or doc.get("hid")

    if hid:
        await maybe_await(coll.update_one({"hid": hid}, {"$set": doc}, upsert=True))
        return str(hid)

    result = await maybe_await(coll.insert_one(doc))
    return str(result.inserted_id)
upsert_experiment(*, experiment_name, description, config_snapshot, progress) async

Create or update an experiment metadata row.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
async def upsert_experiment(
    self,
    *,
    experiment_name: str,
    description: str,
    config_snapshot: dict[str, Any],
    progress: dict[str, Any],
) -> ExperimentRecord:
    """Create or update an experiment metadata row."""
    now = utc_now()
    await maybe_await(
        self._db[MongoColumns.EXPERIMENTS].update_one(
            {MongoColumns.NAME: experiment_name},
            {
                "$set": {
                    MongoColumns.NAME: experiment_name,
                    "description": description,
                    MongoColumns.CONFIG_SNAPSHOT: config_snapshot,
                    MongoColumns.PROGRESS: progress,
                    MongoColumns.UPDATED_AT: now,
                },
                "$setOnInsert": {MongoColumns.CREATED_AT: now},
            },
            upsert=True,
        )
    )
    record = await self.get_experiment(experiment_name=experiment_name)
    if record is None:
        raise ValueError(f"Experiment {experiment_name!r} was not persisted")
    return record
MongoAdmin

Administrative operations over a specific Mongo DB handle.

Source code in dcs_simulation_engine/dal/mongo/admin.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
class MongoAdmin:
    """Administrative operations over a specific Mongo DB handle."""

    def __init__(self, db: Database[Any]) -> None:
        """Bind admin operations to the given database handle."""
        self._db = db

    def backup_db(self, outdir: Path, *, append_ts: bool = True) -> Path:
        """Backup entire DB to a directory. Returns the path written."""
        db = self._db

        ts = datetime.now().strftime("%Y%m%d-%H%M%S")
        root = Path(outdir) / (f"{ts}" if append_ts else "db")
        root.mkdir(parents=True, exist_ok=False)

        collections = sorted(db.list_collection_names())

        for coll_name in collections:
            self.backup_collection(db, coll_name, root)

        manifest = {
            "db_name": db.name,
            "created_at": datetime.now(timezone.utc).isoformat(),
            "collections": collections,
            "format": {
                "collection_dump": "<collection>.ndjson",
                "indexes_dump": "<collection>.__indexes__.json",
                "ndjson_encoding": "bson.json_util extended json",
            },
        }
        (root / "__manifest__.json").write_text(
            json.dumps(manifest, indent=2, sort_keys=True),
            encoding="utf-8",
        )

        return root

    def load_seed_documents(self, path: Path) -> list[dict[str, Any]]:
        """Parse a seed file (.json or .ndjson) and return a list of documents."""
        text = path.read_text(encoding="utf-8").strip()
        if not text:
            logger.debug(f"{path} is empty; skipping.")
            return []

        if path.suffix.lower() == ".ndjson":
            docs: list[dict[str, Any]] = []
            for i, line in enumerate(text.splitlines(), start=1):
                if not line.strip():
                    continue
                obj = json_util.loads(line)
                if not isinstance(obj, dict):
                    raise ValueError(f"Line {i} in {path} is not a JSON object.")
                docs.append(obj)
            return docs

        data = json_util.loads(text)
        if isinstance(data, list):
            if not all(isinstance(x, dict) for x in data):
                raise ValueError(f"Array in {path} must contain only objects.")
            return data

        if isinstance(data, dict) and "documents" in data and isinstance(data["documents"], list):
            docs = data["documents"]
            if not all(isinstance(x, dict) for x in docs):
                raise ValueError(f"'documents' in {path} must be an array of objects.")
            return docs

        raise ValueError(f"Unsupported JSON structure in {path}. Expected array, NDJSON, or object with 'documents'.")

    def backup_root_dir(self, db_name: str) -> Path:
        """Return a timestamped backup root path and create the directory."""
        ts = datetime.now().strftime("%Y%m%d-%H%M%S")
        root = Path("database_backups") / f"{db_name}-{ts}"
        root.mkdir(parents=True, exist_ok=True)
        return root

    def backup_collection(self, db: Database[Any], coll_name: str, root: Path) -> None:
        """Dump a single collection to ndjson and write its index metadata."""
        coll = db[coll_name]
        out_path = root / f"{coll_name}.ndjson"
        idx_path = root / f"{coll_name}.__indexes__.json"

        with out_path.open("w", encoding="utf-8") as f:
            cursor = coll.find({}).batch_size(1000)
            try:
                for doc in cursor:
                    f.write(json_util.dumps(doc))
                    f.write("\n")
            finally:
                cursor.close()

        with idx_path.open("w", encoding="utf-8") as f:
            json.dump(coll.index_information(), f, default=json_util.default, indent=2)

    def seed_collection(self, coll: Collection[Any], docs: Sequence[dict[str, Any]]) -> int:
        """Drop and repopulate a collection with docs. Returns inserted count."""
        coll.drop()
        if not docs:
            logger.info("Dropped '%s'; creating empty collection.", coll.name)
            try:
                coll.database.create_collection(coll.name)
            except CollectionInvalid:
                pass
            return 0

        result = coll.insert_many(list(docs), ordered=False)
        return len(result.inserted_ids)

    def create_indices(self, coll: Collection[Any]) -> None:
        """Create any configured indexes for the given collection."""
        defs = INDEX_DEFS.get(coll.name)
        if not defs:
            return
        for spec in defs:
            fields = spec["fields"]
            unique = spec.get("unique", False)
            coll.create_index(fields, unique=unique)
            logger.info("Created index on %s: %s (unique=%s)", coll.name, fields, unique)

    def seed_database(self, seed_dir: Path) -> int:
        """Seed all collections from seed_dir. Existing collections are dropped and replaced."""
        seed_files = list(seed_dir.glob("**/*.json")) + list(seed_dir.glob("**/*.ndjson"))

        total_inserted = 0
        for seed_file in seed_files:
            if seed_file.name == "__manifest__.json" or seed_file.name.endswith(".__indexes__.json"):
                logger.debug("Skipping metadata seed file {}", seed_file.name)
                continue
            collection_name = seed_file.stem
            logger.info(f"Seeding collection '{collection_name}' from {seed_file.name}")
            docs = self.load_seed_documents(seed_file)
            num_inserted = self.seed_collection(self._db[collection_name], docs)
            logger.info(f"Inserted {num_inserted} document(s) into '{collection_name}'")
            self.create_indices(self._db[collection_name])
            total_inserted += num_inserted

        # Reapply baseline runtime indexes after any collection drops during seeding.
        ensure_default_indexes(self._db)
        return total_inserted
__init__(db)

Bind admin operations to the given database handle.

Source code in dcs_simulation_engine/dal/mongo/admin.py
24
25
26
def __init__(self, db: Database[Any]) -> None:
    """Bind admin operations to the given database handle."""
    self._db = db
backup_collection(db, coll_name, root)

Dump a single collection to ndjson and write its index metadata.

Source code in dcs_simulation_engine/dal/mongo/admin.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def backup_collection(self, db: Database[Any], coll_name: str, root: Path) -> None:
    """Dump a single collection to ndjson and write its index metadata."""
    coll = db[coll_name]
    out_path = root / f"{coll_name}.ndjson"
    idx_path = root / f"{coll_name}.__indexes__.json"

    with out_path.open("w", encoding="utf-8") as f:
        cursor = coll.find({}).batch_size(1000)
        try:
            for doc in cursor:
                f.write(json_util.dumps(doc))
                f.write("\n")
        finally:
            cursor.close()

    with idx_path.open("w", encoding="utf-8") as f:
        json.dump(coll.index_information(), f, default=json_util.default, indent=2)
backup_db(outdir, *, append_ts=True)

Backup entire DB to a directory. Returns the path written.

Source code in dcs_simulation_engine/dal/mongo/admin.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
def backup_db(self, outdir: Path, *, append_ts: bool = True) -> Path:
    """Backup entire DB to a directory. Returns the path written."""
    db = self._db

    ts = datetime.now().strftime("%Y%m%d-%H%M%S")
    root = Path(outdir) / (f"{ts}" if append_ts else "db")
    root.mkdir(parents=True, exist_ok=False)

    collections = sorted(db.list_collection_names())

    for coll_name in collections:
        self.backup_collection(db, coll_name, root)

    manifest = {
        "db_name": db.name,
        "created_at": datetime.now(timezone.utc).isoformat(),
        "collections": collections,
        "format": {
            "collection_dump": "<collection>.ndjson",
            "indexes_dump": "<collection>.__indexes__.json",
            "ndjson_encoding": "bson.json_util extended json",
        },
    }
    (root / "__manifest__.json").write_text(
        json.dumps(manifest, indent=2, sort_keys=True),
        encoding="utf-8",
    )

    return root
backup_root_dir(db_name)

Return a timestamped backup root path and create the directory.

Source code in dcs_simulation_engine/dal/mongo/admin.py
90
91
92
93
94
95
def backup_root_dir(self, db_name: str) -> Path:
    """Return a timestamped backup root path and create the directory."""
    ts = datetime.now().strftime("%Y%m%d-%H%M%S")
    root = Path("database_backups") / f"{db_name}-{ts}"
    root.mkdir(parents=True, exist_ok=True)
    return root
create_indices(coll)

Create any configured indexes for the given collection.

Source code in dcs_simulation_engine/dal/mongo/admin.py
129
130
131
132
133
134
135
136
137
138
def create_indices(self, coll: Collection[Any]) -> None:
    """Create any configured indexes for the given collection."""
    defs = INDEX_DEFS.get(coll.name)
    if not defs:
        return
    for spec in defs:
        fields = spec["fields"]
        unique = spec.get("unique", False)
        coll.create_index(fields, unique=unique)
        logger.info("Created index on %s: %s (unique=%s)", coll.name, fields, unique)
load_seed_documents(path)

Parse a seed file (.json or .ndjson) and return a list of documents.

Source code in dcs_simulation_engine/dal/mongo/admin.py
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def load_seed_documents(self, path: Path) -> list[dict[str, Any]]:
    """Parse a seed file (.json or .ndjson) and return a list of documents."""
    text = path.read_text(encoding="utf-8").strip()
    if not text:
        logger.debug(f"{path} is empty; skipping.")
        return []

    if path.suffix.lower() == ".ndjson":
        docs: list[dict[str, Any]] = []
        for i, line in enumerate(text.splitlines(), start=1):
            if not line.strip():
                continue
            obj = json_util.loads(line)
            if not isinstance(obj, dict):
                raise ValueError(f"Line {i} in {path} is not a JSON object.")
            docs.append(obj)
        return docs

    data = json_util.loads(text)
    if isinstance(data, list):
        if not all(isinstance(x, dict) for x in data):
            raise ValueError(f"Array in {path} must contain only objects.")
        return data

    if isinstance(data, dict) and "documents" in data and isinstance(data["documents"], list):
        docs = data["documents"]
        if not all(isinstance(x, dict) for x in docs):
            raise ValueError(f"'documents' in {path} must be an array of objects.")
        return docs

    raise ValueError(f"Unsupported JSON structure in {path}. Expected array, NDJSON, or object with 'documents'.")
seed_collection(coll, docs)

Drop and repopulate a collection with docs. Returns inserted count.

Source code in dcs_simulation_engine/dal/mongo/admin.py
115
116
117
118
119
120
121
122
123
124
125
126
127
def seed_collection(self, coll: Collection[Any], docs: Sequence[dict[str, Any]]) -> int:
    """Drop and repopulate a collection with docs. Returns inserted count."""
    coll.drop()
    if not docs:
        logger.info("Dropped '%s'; creating empty collection.", coll.name)
        try:
            coll.database.create_collection(coll.name)
        except CollectionInvalid:
            pass
        return 0

    result = coll.insert_many(list(docs), ordered=False)
    return len(result.inserted_ids)
seed_database(seed_dir)

Seed all collections from seed_dir. Existing collections are dropped and replaced.

Source code in dcs_simulation_engine/dal/mongo/admin.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
def seed_database(self, seed_dir: Path) -> int:
    """Seed all collections from seed_dir. Existing collections are dropped and replaced."""
    seed_files = list(seed_dir.glob("**/*.json")) + list(seed_dir.glob("**/*.ndjson"))

    total_inserted = 0
    for seed_file in seed_files:
        if seed_file.name == "__manifest__.json" or seed_file.name.endswith(".__indexes__.json"):
            logger.debug("Skipping metadata seed file {}", seed_file.name)
            continue
        collection_name = seed_file.stem
        logger.info(f"Seeding collection '{collection_name}' from {seed_file.name}")
        docs = self.load_seed_documents(seed_file)
        num_inserted = self.seed_collection(self._db[collection_name], docs)
        logger.info(f"Inserted {num_inserted} document(s) into '{collection_name}'")
        self.create_indices(self._db[collection_name])
        total_inserted += num_inserted

    # Reapply baseline runtime indexes after any collection drops during seeding.
    ensure_default_indexes(self._db)
    return total_inserted
admin

MongoDB administrative operations for CLI bootstrap and teardown.

MongoAdmin

Administrative operations over a specific Mongo DB handle.

Source code in dcs_simulation_engine/dal/mongo/admin.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
class MongoAdmin:
    """Administrative operations over a specific Mongo DB handle."""

    def __init__(self, db: Database[Any]) -> None:
        """Bind admin operations to the given database handle."""
        self._db = db

    def backup_db(self, outdir: Path, *, append_ts: bool = True) -> Path:
        """Backup entire DB to a directory. Returns the path written."""
        db = self._db

        ts = datetime.now().strftime("%Y%m%d-%H%M%S")
        root = Path(outdir) / (f"{ts}" if append_ts else "db")
        root.mkdir(parents=True, exist_ok=False)

        collections = sorted(db.list_collection_names())

        for coll_name in collections:
            self.backup_collection(db, coll_name, root)

        manifest = {
            "db_name": db.name,
            "created_at": datetime.now(timezone.utc).isoformat(),
            "collections": collections,
            "format": {
                "collection_dump": "<collection>.ndjson",
                "indexes_dump": "<collection>.__indexes__.json",
                "ndjson_encoding": "bson.json_util extended json",
            },
        }
        (root / "__manifest__.json").write_text(
            json.dumps(manifest, indent=2, sort_keys=True),
            encoding="utf-8",
        )

        return root

    def load_seed_documents(self, path: Path) -> list[dict[str, Any]]:
        """Parse a seed file (.json or .ndjson) and return a list of documents."""
        text = path.read_text(encoding="utf-8").strip()
        if not text:
            logger.debug(f"{path} is empty; skipping.")
            return []

        if path.suffix.lower() == ".ndjson":
            docs: list[dict[str, Any]] = []
            for i, line in enumerate(text.splitlines(), start=1):
                if not line.strip():
                    continue
                obj = json_util.loads(line)
                if not isinstance(obj, dict):
                    raise ValueError(f"Line {i} in {path} is not a JSON object.")
                docs.append(obj)
            return docs

        data = json_util.loads(text)
        if isinstance(data, list):
            if not all(isinstance(x, dict) for x in data):
                raise ValueError(f"Array in {path} must contain only objects.")
            return data

        if isinstance(data, dict) and "documents" in data and isinstance(data["documents"], list):
            docs = data["documents"]
            if not all(isinstance(x, dict) for x in docs):
                raise ValueError(f"'documents' in {path} must be an array of objects.")
            return docs

        raise ValueError(f"Unsupported JSON structure in {path}. Expected array, NDJSON, or object with 'documents'.")

    def backup_root_dir(self, db_name: str) -> Path:
        """Return a timestamped backup root path and create the directory."""
        ts = datetime.now().strftime("%Y%m%d-%H%M%S")
        root = Path("database_backups") / f"{db_name}-{ts}"
        root.mkdir(parents=True, exist_ok=True)
        return root

    def backup_collection(self, db: Database[Any], coll_name: str, root: Path) -> None:
        """Dump a single collection to ndjson and write its index metadata."""
        coll = db[coll_name]
        out_path = root / f"{coll_name}.ndjson"
        idx_path = root / f"{coll_name}.__indexes__.json"

        with out_path.open("w", encoding="utf-8") as f:
            cursor = coll.find({}).batch_size(1000)
            try:
                for doc in cursor:
                    f.write(json_util.dumps(doc))
                    f.write("\n")
            finally:
                cursor.close()

        with idx_path.open("w", encoding="utf-8") as f:
            json.dump(coll.index_information(), f, default=json_util.default, indent=2)

    def seed_collection(self, coll: Collection[Any], docs: Sequence[dict[str, Any]]) -> int:
        """Drop and repopulate a collection with docs. Returns inserted count."""
        coll.drop()
        if not docs:
            logger.info("Dropped '%s'; creating empty collection.", coll.name)
            try:
                coll.database.create_collection(coll.name)
            except CollectionInvalid:
                pass
            return 0

        result = coll.insert_many(list(docs), ordered=False)
        return len(result.inserted_ids)

    def create_indices(self, coll: Collection[Any]) -> None:
        """Create any configured indexes for the given collection."""
        defs = INDEX_DEFS.get(coll.name)
        if not defs:
            return
        for spec in defs:
            fields = spec["fields"]
            unique = spec.get("unique", False)
            coll.create_index(fields, unique=unique)
            logger.info("Created index on %s: %s (unique=%s)", coll.name, fields, unique)

    def seed_database(self, seed_dir: Path) -> int:
        """Seed all collections from seed_dir. Existing collections are dropped and replaced."""
        seed_files = list(seed_dir.glob("**/*.json")) + list(seed_dir.glob("**/*.ndjson"))

        total_inserted = 0
        for seed_file in seed_files:
            if seed_file.name == "__manifest__.json" or seed_file.name.endswith(".__indexes__.json"):
                logger.debug("Skipping metadata seed file {}", seed_file.name)
                continue
            collection_name = seed_file.stem
            logger.info(f"Seeding collection '{collection_name}' from {seed_file.name}")
            docs = self.load_seed_documents(seed_file)
            num_inserted = self.seed_collection(self._db[collection_name], docs)
            logger.info(f"Inserted {num_inserted} document(s) into '{collection_name}'")
            self.create_indices(self._db[collection_name])
            total_inserted += num_inserted

        # Reapply baseline runtime indexes after any collection drops during seeding.
        ensure_default_indexes(self._db)
        return total_inserted
__init__(db)

Bind admin operations to the given database handle.

Source code in dcs_simulation_engine/dal/mongo/admin.py
24
25
26
def __init__(self, db: Database[Any]) -> None:
    """Bind admin operations to the given database handle."""
    self._db = db
backup_collection(db, coll_name, root)

Dump a single collection to ndjson and write its index metadata.

Source code in dcs_simulation_engine/dal/mongo/admin.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def backup_collection(self, db: Database[Any], coll_name: str, root: Path) -> None:
    """Dump a single collection to ndjson and write its index metadata."""
    coll = db[coll_name]
    out_path = root / f"{coll_name}.ndjson"
    idx_path = root / f"{coll_name}.__indexes__.json"

    with out_path.open("w", encoding="utf-8") as f:
        cursor = coll.find({}).batch_size(1000)
        try:
            for doc in cursor:
                f.write(json_util.dumps(doc))
                f.write("\n")
        finally:
            cursor.close()

    with idx_path.open("w", encoding="utf-8") as f:
        json.dump(coll.index_information(), f, default=json_util.default, indent=2)
backup_db(outdir, *, append_ts=True)

Backup entire DB to a directory. Returns the path written.

Source code in dcs_simulation_engine/dal/mongo/admin.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
def backup_db(self, outdir: Path, *, append_ts: bool = True) -> Path:
    """Backup entire DB to a directory. Returns the path written."""
    db = self._db

    ts = datetime.now().strftime("%Y%m%d-%H%M%S")
    root = Path(outdir) / (f"{ts}" if append_ts else "db")
    root.mkdir(parents=True, exist_ok=False)

    collections = sorted(db.list_collection_names())

    for coll_name in collections:
        self.backup_collection(db, coll_name, root)

    manifest = {
        "db_name": db.name,
        "created_at": datetime.now(timezone.utc).isoformat(),
        "collections": collections,
        "format": {
            "collection_dump": "<collection>.ndjson",
            "indexes_dump": "<collection>.__indexes__.json",
            "ndjson_encoding": "bson.json_util extended json",
        },
    }
    (root / "__manifest__.json").write_text(
        json.dumps(manifest, indent=2, sort_keys=True),
        encoding="utf-8",
    )

    return root
backup_root_dir(db_name)

Return a timestamped backup root path and create the directory.

Source code in dcs_simulation_engine/dal/mongo/admin.py
90
91
92
93
94
95
def backup_root_dir(self, db_name: str) -> Path:
    """Return a timestamped backup root path and create the directory."""
    ts = datetime.now().strftime("%Y%m%d-%H%M%S")
    root = Path("database_backups") / f"{db_name}-{ts}"
    root.mkdir(parents=True, exist_ok=True)
    return root
create_indices(coll)

Create any configured indexes for the given collection.

Source code in dcs_simulation_engine/dal/mongo/admin.py
129
130
131
132
133
134
135
136
137
138
def create_indices(self, coll: Collection[Any]) -> None:
    """Create any configured indexes for the given collection."""
    defs = INDEX_DEFS.get(coll.name)
    if not defs:
        return
    for spec in defs:
        fields = spec["fields"]
        unique = spec.get("unique", False)
        coll.create_index(fields, unique=unique)
        logger.info("Created index on %s: %s (unique=%s)", coll.name, fields, unique)
load_seed_documents(path)

Parse a seed file (.json or .ndjson) and return a list of documents.

Source code in dcs_simulation_engine/dal/mongo/admin.py
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def load_seed_documents(self, path: Path) -> list[dict[str, Any]]:
    """Parse a seed file (.json or .ndjson) and return a list of documents."""
    text = path.read_text(encoding="utf-8").strip()
    if not text:
        logger.debug(f"{path} is empty; skipping.")
        return []

    if path.suffix.lower() == ".ndjson":
        docs: list[dict[str, Any]] = []
        for i, line in enumerate(text.splitlines(), start=1):
            if not line.strip():
                continue
            obj = json_util.loads(line)
            if not isinstance(obj, dict):
                raise ValueError(f"Line {i} in {path} is not a JSON object.")
            docs.append(obj)
        return docs

    data = json_util.loads(text)
    if isinstance(data, list):
        if not all(isinstance(x, dict) for x in data):
            raise ValueError(f"Array in {path} must contain only objects.")
        return data

    if isinstance(data, dict) and "documents" in data and isinstance(data["documents"], list):
        docs = data["documents"]
        if not all(isinstance(x, dict) for x in docs):
            raise ValueError(f"'documents' in {path} must be an array of objects.")
        return docs

    raise ValueError(f"Unsupported JSON structure in {path}. Expected array, NDJSON, or object with 'documents'.")
seed_collection(coll, docs)

Drop and repopulate a collection with docs. Returns inserted count.

Source code in dcs_simulation_engine/dal/mongo/admin.py
115
116
117
118
119
120
121
122
123
124
125
126
127
def seed_collection(self, coll: Collection[Any], docs: Sequence[dict[str, Any]]) -> int:
    """Drop and repopulate a collection with docs. Returns inserted count."""
    coll.drop()
    if not docs:
        logger.info("Dropped '%s'; creating empty collection.", coll.name)
        try:
            coll.database.create_collection(coll.name)
        except CollectionInvalid:
            pass
        return 0

    result = coll.insert_many(list(docs), ordered=False)
    return len(result.inserted_ids)
seed_database(seed_dir)

Seed all collections from seed_dir. Existing collections are dropped and replaced.

Source code in dcs_simulation_engine/dal/mongo/admin.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
def seed_database(self, seed_dir: Path) -> int:
    """Seed all collections from seed_dir. Existing collections are dropped and replaced."""
    seed_files = list(seed_dir.glob("**/*.json")) + list(seed_dir.glob("**/*.ndjson"))

    total_inserted = 0
    for seed_file in seed_files:
        if seed_file.name == "__manifest__.json" or seed_file.name.endswith(".__indexes__.json"):
            logger.debug("Skipping metadata seed file {}", seed_file.name)
            continue
        collection_name = seed_file.stem
        logger.info(f"Seeding collection '{collection_name}' from {seed_file.name}")
        docs = self.load_seed_documents(seed_file)
        num_inserted = self.seed_collection(self._db[collection_name], docs)
        logger.info(f"Inserted {num_inserted} document(s) into '{collection_name}'")
        self.create_indices(self._db[collection_name])
        total_inserted += num_inserted

    # Reapply baseline runtime indexes after any collection drops during seeding.
    ensure_default_indexes(self._db)
    return total_inserted
async_provider

Async MongoDB implementation for runtime server paths.

AsyncMongoProvider

Async provider backed by PyMongo AsyncMongoClient.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
class AsyncMongoProvider:
    """Async provider backed by PyMongo AsyncMongoClient."""

    def __init__(self, db: Any) -> None:
        # We inject the database instance rather than creating it here.
        # This makes the class easily testable (you can pass in a mock database).
        self._db = db

    def get_db(self) -> Any:
        return self._db

    async def get_characters(self, *, hid: str | None = None) -> list[CharacterRecord] | CharacterRecord:
        """Fetch a specific character by ID, or list all of them if no ID is provided."""
        if hid is not None:
            # projection={"_id": 0} tells Mongo to NOT return its internal ObjectId.
            # We do this because ObjectIds often aren't JSON serializable by default.
            doc = await maybe_await(self._db[MongoColumns.CHARACTERS].find_one({"hid": hid}, projection={"_id": 0}))
            if not doc:
                raise ValueError(f"Character with hid='{hid}' not found")
            return _to_character_record(doc)

        # If no 'hid' was provided, fall back to fetching everything.
        return await self.list_characters()

    async def get_character(self, *, hid: str) -> CharacterRecord:
        """Strict version of get_characters that guarantees a single record is returned."""
        result = await self.get_characters(hid=hid)
        if not isinstance(result, CharacterRecord):
            raise ValueError(f"Character with hid='{hid}' not found")
        return result

    async def list_characters(self) -> list[CharacterRecord]:
        """Fetch all character records from the database."""
        # Find with an empty query `{}` means "get everything".
        cursor = self._db[MongoColumns.CHARACTERS].find({}, projection={"_id": 0})
        docs = await _cursor_to_docs(cursor)
        return [_to_character_record(doc) for doc in docs]

    async def get_player(self, *, player_id: str) -> PlayerRecord | None:
        """Look up a player by their ID."""
        # player_id_variants likely handles the fact that an ID could be stored
        # as a raw string or an ObjectId in the database.
        ids = player_id_variants(player_id)
        if not ids:
            return None

        # $or is a Mongo operator: "Find a document where _id matches ANY of the IDs in this list."
        doc = await maybe_await(self._db[MongoColumns.PLAYERS].find_one({"$or": [{"_id": pid} for pid in ids]}))
        if not doc:
            return None

        # Rename the internal Mongo '_id' to a standard 'id' for the application to use.
        # .pop() removes it from the dict and returns the value at the same time.
        doc["id"] = str(doc.pop("_id"))
        return player_doc_to_record(doc)

    async def create_player(
        self,
        *,
        player_data: dict[str, Any],
        player_id: str | None = None,
        issue_access_key: bool = False,
        access_key: str | None = None,
    ) -> tuple[PlayerRecord, str | None]:
        """Create or update a player, optionally issuing them a new access key."""
        sanitized = sanitize_player_data(player_data)
        raw_key: str | None = None

        if issue_access_key and access_key is not None:
            raise ValueError("Use either issue_access_key=True or an explicit access_key, not both.")

        if access_key is not None:
            raw_key = validate_access_key(access_key)
            sanitized.update(
                {
                    "access_key": raw_key,
                    "access_key_revoked": False,
                    "last_key_issued_at": utc_now(),
                }
            )
        elif issue_access_key:
            raw_key = generate_access_key()
            sanitized.update(
                {
                    "access_key": raw_key,
                    "access_key_revoked": False,
                    "last_key_issued_at": utc_now(),
                }
            )

        # SECURITY BEST PRACTICE: Split PII (Personally Identifiable Information like emails/names)
        # away from standard gameplay data. This makes GDPR compliance and data deletion much easier.
        non_pii_data, pii_fields = split_pii(sanitized)
        coll = self._db[MongoColumns.PLAYERS]

        if player_id is not None:
            # upsert=True means "Update this document if it exists. If it doesn't, create it."
            # $set ensures we only update the fields provided, leaving other existing fields alone.
            await maybe_await(coll.update_one({"_id": player_id}, {"$set": non_pii_data}, upsert=True))
            created_id = str(player_id)
        else:
            # If no ID was provided, just insert it and let Mongo generate a new ObjectId.
            created_id = str((await maybe_await(coll.insert_one(non_pii_data))).inserted_id)

        # Store the sensitive PII data in an entirely different database collection.
        if pii_fields:
            await maybe_await(
                self._db[MongoColumns.PII].update_one(
                    {"player_id": created_id},
                    {
                        "$set": {
                            "player_id": created_id,
                            "fields": pii_fields,
                            "updated_at": utc_now(),
                        },
                        # $setOnInsert is a cool Mongo feature: this field is ONLY written
                        # if the document is being created for the first time, ignored on updates.
                        "$setOnInsert": {"created_at": utc_now()},
                    },
                    upsert=True,
                )
            )

        # Reconstruct a complete domain model to return to the application.
        doc = dict(non_pii_data)
        doc["id"] = created_id
        return player_doc_to_record(doc), raw_key

    async def get_players(self, *, access_key: str | None = None) -> list[PlayerRecord] | PlayerRecord | None:
        """Fetch all players, or specifically authenticate and fetch one by access_key."""
        if access_key is not None:
            key = access_key.strip()
            if not key:
                return None

            # $ne means "Not Equal". Find the user with this key, where revoked is NOT True.
            doc = await maybe_await(
                self._db[MongoColumns.PLAYERS].find_one(
                    {"access_key": key, "access_key_revoked": {"$ne": True}},
                    projection={"access_key": 0},  # Never return the key back out in the results
                )
            )
            if not doc:
                return None
            doc["id"] = str(doc.pop("_id"))
            return player_doc_to_record(doc)

        out: list[PlayerRecord] = []
        cursor = self._db[MongoColumns.PLAYERS].find({}, projection={"access_key": 0})
        for doc in await _cursor_to_docs(cursor):
            doc["id"] = str(doc.pop("_id"))
            out.append(player_doc_to_record(doc))
        return out

    async def upsert_character(self, data: dict[str, Any], *, character_id: str | None = None) -> str:
        """Create a new character or update an existing one."""
        if not isinstance(data, dict):
            raise ValueError("data must be a dict")

        doc = dict(data)
        # setdefault only applies the timestamp if 'created_at' isn't already in the dict.
        doc.setdefault(MongoColumns.CREATED_AT, utc_now())

        coll = self._db[MongoColumns.CHARACTERS]
        hid = character_id or doc.get("hid")

        if hid:
            await maybe_await(coll.update_one({"hid": hid}, {"$set": doc}, upsert=True))
            return str(hid)

        result = await maybe_await(coll.insert_one(doc))
        return str(result.inserted_id)

    async def delete_character(self, character_id: str) -> None:
        """Remove a character by ID."""
        await maybe_await(self._db[MongoColumns.CHARACTERS].delete_one({"hid": character_id}))

    async def delete_player(self, player_id: str) -> None:
        """Remove a player by ID, checking all variant ID types."""
        ids = player_id_variants(player_id)
        if not ids:
            return
        await maybe_await(self._db[MongoColumns.PLAYERS].delete_one({"$or": [{"_id": pid} for pid in ids]}))

    async def create_session(self, session_doc: dict[str, Any]) -> None:
        """Log the start of a new game/app session."""
        await maybe_await(self._db[MongoColumns.SESSIONS].insert_one(session_doc))

    async def finalize_session(
        self,
        *,
        session_id: str,
        termination_reason: str,
        status: str,
        session_ended_at: datetime,
        turns_completed: int,
        last_seq: int,
    ) -> None:
        """Update a session record with final metrics when it ends."""
        await maybe_await(
            self._db[MongoColumns.SESSIONS].update_one(
                {"session_id": session_id},
                {
                    "$set": {
                        "termination_reason": termination_reason,
                        "status": status,
                        "session_ended_at": session_ended_at,
                        "turns_completed": turns_completed,
                        "last_seq": last_seq,
                        "updated_at": utc_now(),
                    }
                },
            )
        )

    async def pause_session(self, *, session_id: str, paused_at: datetime) -> None:
        """Update a session record to reflect it is paused and awaiting resume."""
        await maybe_await(
            self._db[MongoColumns.SESSIONS].update_one(
                {"session_id": session_id},
                {
                    "$set": {
                        "status": "paused",
                        "paused_at": paused_at,
                        "updated_at": utc_now(),
                    }
                },
            )
        )

    async def resume_session(self, *, session_id: str, resumed_at: datetime) -> None:
        """Update a session record to reflect it has been resumed."""
        await maybe_await(
            self._db[MongoColumns.SESSIONS].update_one(
                {"session_id": session_id},
                {
                    "$set": {
                        "status": "active",
                        "resumed_at": resumed_at,
                        "updated_at": utc_now(),
                    },
                    "$unset": {"paused_at": ""},
                },
            )
        )

    async def get_session(self, *, session_id: str, player_id: str | None) -> SessionRecord | None:
        """Return a single persisted session record for the player."""
        doc = await maybe_await(
            self._db[MongoColumns.SESSIONS].find_one(
                {
                    MongoColumns.SESSION_ID: session_id,
                    MongoColumns.PLAYER_ID: player_id,
                }
            )
        )
        if not doc:
            return None
        return _to_session_record(doc)

    async def list_session_events(self, *, session_id: str) -> list[SessionEventRecord]:
        """Return all persisted session events in sequence order."""
        cursor = self._db[MongoColumns.SESSION_EVENTS].find({MongoColumns.SESSION_ID: session_id})
        sorter = getattr(cursor, "sort", None)
        if callable(sorter):
            cursor = sorter(MongoColumns.SEQ, 1)
        docs = await _cursor_to_docs(cursor)
        return [_to_session_event_record(doc) for doc in docs]

    async def append_session_event(
        self,
        *,
        session_id: str,
        player_id: str | None,
        direction: str,
        event_type: str,
        event_source: str,
        content: str,
        content_format: str,
        turn_index: int,
        visible_to_user: bool,
    ) -> SessionEventRecord | None:
        """Append one owned session event and advance the parent session sequence counter."""
        session_doc = await maybe_await(
            self._db[MongoColumns.SESSIONS].find_one(
                {
                    MongoColumns.SESSION_ID: session_id,
                    MongoColumns.PLAYER_ID: player_id,
                },
                projection={
                    MongoColumns.SESSION_ID: 1,
                    MongoColumns.LAST_SEQ: 1,
                },
            )
        )
        if not session_doc:
            return None

        last_seq = int(session_doc.get(MongoColumns.LAST_SEQ, 0) or 0)
        next_seq = last_seq + 1
        now = utc_now()
        event_id = str(uuid4())
        doc = {
            MongoColumns.SESSION_ID: session_id,
            MongoColumns.SEQ: next_seq,
            MongoColumns.EVENT_ID: event_id,
            MongoColumns.EVENT_TS: now,
            MongoColumns.DIRECTION: direction,
            MongoColumns.EVENT_TYPE: event_type,
            MongoColumns.EVENT_SOURCE: event_source,
            MongoColumns.CONTENT: content,
            MongoColumns.CONTENT_FORMAT: content_format,
            MongoColumns.TURN_INDEX: turn_index,
            MongoColumns.VISIBLE_TO_USER: visible_to_user,
            MongoColumns.PERSISTED_AT: now,
            MongoColumns.UPDATED_AT: now,
        }
        await maybe_await(self._db[MongoColumns.SESSION_EVENTS].insert_one(doc))
        await maybe_await(
            self._db[MongoColumns.SESSIONS].update_one(
                {MongoColumns.SESSION_ID: session_id},
                {
                    "$set": {
                        MongoColumns.LAST_SEQ: next_seq,
                        MongoColumns.UPDATED_AT: now,
                    }
                },
            )
        )
        return _to_session_event_record(doc)

    async def set_session_event_feedback(
        self,
        *,
        session_id: str,
        player_id: str | None,
        event_id: str,
        feedback: dict[str, Any],
    ) -> dict[str, Any] | None:
        """Store feedback on one persisted NPC message event owned by the player."""
        session_doc = await maybe_await(
            self._db[MongoColumns.SESSIONS].find_one(
                {
                    MongoColumns.SESSION_ID: session_id,
                    MongoColumns.PLAYER_ID: player_id,
                },
                projection={MongoColumns.SESSION_ID: 1},
            )
        )
        if not session_doc:
            return None

        now = utc_now()
        result = await maybe_await(
            self._db[MongoColumns.SESSION_EVENTS].update_one(
                {
                    MongoColumns.SESSION_ID: session_id,
                    MongoColumns.EVENT_ID: event_id,
                    MongoColumns.DIRECTION: "outbound",
                    MongoColumns.EVENT_TYPE: "message",
                    MongoColumns.EVENT_SOURCE: "npc",
                },
                {
                    "$set": {
                        MongoColumns.FEEDBACK: dict(feedback),
                        MongoColumns.UPDATED_AT: now,
                    }
                },
            )
        )
        if getattr(result, "matched_count", 0) == 0:
            return None

        await maybe_await(
            self._db[MongoColumns.SESSIONS].update_one(
                {MongoColumns.SESSION_ID: session_id},
                {"$set": {MongoColumns.UPDATED_AT: now}},
            )
        )
        return dict(feedback)

    async def clear_session_event_feedback(
        self,
        *,
        session_id: str,
        player_id: str | None,
        event_id: str,
    ) -> bool:
        """Remove feedback from one persisted NPC message event owned by the player."""
        session_doc = await maybe_await(
            self._db[MongoColumns.SESSIONS].find_one(
                {
                    MongoColumns.SESSION_ID: session_id,
                    MongoColumns.PLAYER_ID: player_id,
                },
                projection={MongoColumns.SESSION_ID: 1},
            )
        )
        if not session_doc:
            return False

        now = utc_now()
        result = await maybe_await(
            self._db[MongoColumns.SESSION_EVENTS].update_one(
                {
                    MongoColumns.SESSION_ID: session_id,
                    MongoColumns.EVENT_ID: event_id,
                    MongoColumns.DIRECTION: "outbound",
                    MongoColumns.EVENT_TYPE: "message",
                    MongoColumns.EVENT_SOURCE: "npc",
                },
                {
                    "$unset": {
                        MongoColumns.FEEDBACK: "",
                    },
                    "$set": {
                        MongoColumns.UPDATED_AT: now,
                    },
                },
            )
        )
        if getattr(result, "matched_count", 0) == 0:
            return False

        await maybe_await(
            self._db[MongoColumns.SESSIONS].update_one(
                {MongoColumns.SESSION_ID: session_id},
                {"$set": {MongoColumns.UPDATED_AT: now}},
            )
        )
        return True

    async def get_experiment(self, *, experiment_name: str) -> ExperimentRecord | None:
        """Return one persisted experiment record."""
        doc = await maybe_await(self._db[MongoColumns.EXPERIMENTS].find_one({MongoColumns.NAME: experiment_name}))
        if not doc:
            return None
        return _to_experiment_record(doc)

    async def upsert_experiment(
        self,
        *,
        experiment_name: str,
        description: str,
        config_snapshot: dict[str, Any],
        progress: dict[str, Any],
    ) -> ExperimentRecord:
        """Create or update an experiment metadata row."""
        now = utc_now()
        await maybe_await(
            self._db[MongoColumns.EXPERIMENTS].update_one(
                {MongoColumns.NAME: experiment_name},
                {
                    "$set": {
                        MongoColumns.NAME: experiment_name,
                        "description": description,
                        MongoColumns.CONFIG_SNAPSHOT: config_snapshot,
                        MongoColumns.PROGRESS: progress,
                        MongoColumns.UPDATED_AT: now,
                    },
                    "$setOnInsert": {MongoColumns.CREATED_AT: now},
                },
                upsert=True,
            )
        )
        record = await self.get_experiment(experiment_name=experiment_name)
        if record is None:
            raise ValueError(f"Experiment {experiment_name!r} was not persisted")
        return record

    async def set_experiment_progress(
        self,
        *,
        experiment_name: str,
        progress: dict[str, Any],
    ) -> ExperimentRecord | None:
        """Persist the latest experiment progress snapshot."""
        now = utc_now()
        await maybe_await(
            self._db[MongoColumns.EXPERIMENTS].update_one(
                {MongoColumns.NAME: experiment_name},
                {
                    "$set": {
                        MongoColumns.PROGRESS: progress,
                        MongoColumns.UPDATED_AT: now,
                    }
                },
            )
        )
        return await self.get_experiment(experiment_name=experiment_name)

    async def create_assignment(self, *, assignment_doc: dict[str, Any], allow_concurrent: bool = False) -> AssignmentRecord:
        """Persist a new experiment assignment row."""
        experiment_name = str(assignment_doc.get(MongoColumns.EXPERIMENT_NAME) or "")
        player_id = str(assignment_doc.get(MongoColumns.PLAYER_ID) or "")
        if not experiment_name or not player_id:
            raise ValueError("assignment_doc must include experiment_name and player_id")

        if not allow_concurrent:
            existing = await self.get_active_assignment(experiment_name=experiment_name, player_id=player_id)
            if existing is not None:
                raise ValueError("Player already has an active assignment for this experiment")

        now = utc_now()
        doc = dict(assignment_doc)
        doc.setdefault(MongoColumns.ASSIGNMENT_ID, str(uuid4()))
        doc.setdefault(MongoColumns.STATUS, "assigned")
        doc.setdefault("assigned_at", now)
        doc.setdefault(MongoColumns.CREATED_AT, now)
        doc[MongoColumns.UPDATED_AT] = now
        await maybe_await(self._db[MongoColumns.ASSIGNMENTS].insert_one(doc))
        record = await self.get_assignment(assignment_id=doc[MongoColumns.ASSIGNMENT_ID])
        if record is None:
            raise ValueError("Assignment insert did not persist")
        return record

    async def get_assignment(self, *, assignment_id: str) -> AssignmentRecord | None:
        """Return one assignment row by assignment_id."""
        doc = await maybe_await(self._db[MongoColumns.ASSIGNMENTS].find_one({MongoColumns.ASSIGNMENT_ID: assignment_id}))
        if not doc:
            return None
        return _to_assignment_record(doc)

    async def get_active_assignment(self, *, experiment_name: str, player_id: str) -> AssignmentRecord | None:
        """Return the current active assignment for one player in one experiment."""
        cursor = self._db[MongoColumns.ASSIGNMENTS].find(
            {
                MongoColumns.EXPERIMENT_NAME: experiment_name,
                MongoColumns.PLAYER_ID: player_id,
                MongoColumns.STATUS: {"$in": sorted(ACTIVE_ASSIGNMENT_STATUSES)},
            }
        )
        sorter = getattr(cursor, "sort", None)
        if callable(sorter):
            cursor = sorter(MongoColumns.UPDATED_AT, -1)
        docs = await _cursor_to_docs(cursor)
        if not docs:
            return None
        return _to_assignment_record(docs[0])

    async def get_assignment_for_session_id(self, *, session_id: str) -> AssignmentRecord | None:
        """Return the assignment that has this session as its active session."""
        doc = await maybe_await(self._db[MongoColumns.ASSIGNMENTS].find_one({MongoColumns.ACTIVE_SESSION_ID: session_id}))
        if not doc:
            return None
        return _to_assignment_record(doc)

    async def get_latest_experiment_assignment_for_player(self, *, player_id: str) -> AssignmentRecord | None:
        """Return the newest experiment assignment for one player."""
        cursor = self._db[MongoColumns.ASSIGNMENTS].find({MongoColumns.PLAYER_ID: player_id})
        sorter = getattr(cursor, "sort", None)
        if callable(sorter):
            cursor = sorter(MongoColumns.UPDATED_AT, -1)
        docs = await _cursor_to_docs(cursor)
        if not docs:
            return None
        return _to_assignment_record(docs[0])

    async def list_assignments(
        self,
        *,
        experiment_name: str,
        player_id: str | None = None,
        statuses: list[str] | None = None,
        game_name: str | None = None,
    ) -> list[AssignmentRecord]:
        """List assignment rows matching the requested filters."""
        query: dict[str, Any] = {MongoColumns.EXPERIMENT_NAME: experiment_name}
        if player_id is not None:
            query[MongoColumns.PLAYER_ID] = player_id
        if statuses:
            query[MongoColumns.STATUS] = {"$in": list(statuses)}
        if game_name is not None:
            query[MongoColumns.GAME_NAME] = game_name

        cursor = self._db[MongoColumns.ASSIGNMENTS].find(query)
        sorter = getattr(cursor, "sort", None)
        if callable(sorter):
            cursor = sorter("assigned_at", 1)
        docs = await _cursor_to_docs(cursor)
        return [_to_assignment_record(doc) for doc in docs]

    async def update_assignment_status(
        self,
        *,
        assignment_id: str,
        status: str,
        active_session_id: str | None = None,
    ) -> AssignmentRecord | None:
        """Update assignment status and lifecycle timestamps."""
        now = utc_now()
        updates: dict[str, Any] = {
            MongoColumns.STATUS: status,
            MongoColumns.UPDATED_AT: now,
        }
        if status == "assigned":
            updates.setdefault("assigned_at", now)
            updates[MongoColumns.ACTIVE_SESSION_ID] = None
        elif status == "in_progress":
            updates["started_at"] = now
            updates[MongoColumns.ACTIVE_SESSION_ID] = active_session_id
        elif status == "completed":
            updates["completed_at"] = now
            updates[MongoColumns.ACTIVE_SESSION_ID] = None
        elif status == "interrupted":
            updates["interrupted_at"] = now
            updates[MongoColumns.ACTIVE_SESSION_ID] = None

        await maybe_await(
            self._db[MongoColumns.ASSIGNMENTS].update_one(
                {MongoColumns.ASSIGNMENT_ID: assignment_id},
                {"$set": updates},
            )
        )
        return await self.get_assignment(assignment_id=assignment_id)

    async def set_assignment_form_response(
        self,
        *,
        assignment_id: str,
        form_key: str,
        response: dict[str, Any],
    ) -> AssignmentRecord | None:
        """Store one form response payload on an assignment row."""
        await maybe_await(
            self._db[MongoColumns.ASSIGNMENTS].update_one(
                {MongoColumns.ASSIGNMENT_ID: assignment_id},
                {
                    "$set": {
                        f"{MongoColumns.FORM_RESPONSES}.{form_key}": response,
                        MongoColumns.UPDATED_AT: utc_now(),
                    }
                },
            )
        )
        return await self.get_assignment(assignment_id=assignment_id)

    async def set_player_form_response(
        self,
        *,
        player_id: str,
        experiment_name: str,
        form_key: str,
        response: dict[str, Any],
    ) -> PlayerFormsRecord | None:
        """Upsert one before-play form response into the forms collection."""
        now = utc_now()
        await maybe_await(
            self._db[MongoColumns.FORMS].update_one(
                {MongoColumns.PLAYER_ID: player_id, MongoColumns.EXPERIMENT_NAME: experiment_name},
                {
                    "$set": {f"data.{form_key}": response, MongoColumns.UPDATED_AT: now},
                    "$setOnInsert": {
                        MongoColumns.PLAYER_ID: player_id,
                        MongoColumns.EXPERIMENT_NAME: experiment_name,
                        MongoColumns.CREATED_AT: now,
                    },
                },
                upsert=True,
            )
        )
        return await self.get_player_forms(player_id=player_id, experiment_name=experiment_name)

    async def get_player_forms(
        self,
        *,
        player_id: str,
        experiment_name: str,
    ) -> PlayerFormsRecord | None:
        """Return the before-play form responses for a player in an experiment."""
        doc = await maybe_await(
            self._db[MongoColumns.FORMS].find_one({MongoColumns.PLAYER_ID: player_id, MongoColumns.EXPERIMENT_NAME: experiment_name})
        )
        if not doc:
            return None
        return PlayerFormsRecord(
            player_id=doc[MongoColumns.PLAYER_ID],
            experiment_name=doc[MongoColumns.EXPERIMENT_NAME],
            data=doc.get("data", {}),
            created_at=doc.get(MongoColumns.CREATED_AT),
            updated_at=doc.get(MongoColumns.UPDATED_AT),
        )

    async def get_session_reconstruction(
        self,
        *,
        session_id: str,
        player_id: str,
    ) -> dict[str, Any] | None:
        """Return session metadata and ordered event stream for replay."""
        # 1. Fetch the parent session record
        session_doc = await maybe_await(
            self._db[MongoColumns.SESSIONS].find_one(
                {"session_id": session_id, "player_id": player_id},
                projection={"_id": 0},
            )
        )
        if not session_doc:
            return None

        # 2. Fetch all individual events tied to this session
        events: list[dict[str, Any]] = []
        cursor = self._db[MongoColumns.SESSION_EVENTS].find(
            {"session_id": session_id},
            projection={"_id": 0},
        )

        # 3. Ensure the events are sorted by their sequence number ('seq') in ascending order (1).
        # This guarantees they are replayed in the exact order they occurred.
        sorter = getattr(cursor, "sort", None)
        if callable(sorter):
            cursor = sorter("seq", 1)

        events.extend(await _cursor_to_docs(cursor))

        # Return both parts together
        return {"session": session_doc, "events": events}
append_session_event(*, session_id, player_id, direction, event_type, event_source, content, content_format, turn_index, visible_to_user) async

Append one owned session event and advance the parent session sequence counter.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
async def append_session_event(
    self,
    *,
    session_id: str,
    player_id: str | None,
    direction: str,
    event_type: str,
    event_source: str,
    content: str,
    content_format: str,
    turn_index: int,
    visible_to_user: bool,
) -> SessionEventRecord | None:
    """Append one owned session event and advance the parent session sequence counter."""
    session_doc = await maybe_await(
        self._db[MongoColumns.SESSIONS].find_one(
            {
                MongoColumns.SESSION_ID: session_id,
                MongoColumns.PLAYER_ID: player_id,
            },
            projection={
                MongoColumns.SESSION_ID: 1,
                MongoColumns.LAST_SEQ: 1,
            },
        )
    )
    if not session_doc:
        return None

    last_seq = int(session_doc.get(MongoColumns.LAST_SEQ, 0) or 0)
    next_seq = last_seq + 1
    now = utc_now()
    event_id = str(uuid4())
    doc = {
        MongoColumns.SESSION_ID: session_id,
        MongoColumns.SEQ: next_seq,
        MongoColumns.EVENT_ID: event_id,
        MongoColumns.EVENT_TS: now,
        MongoColumns.DIRECTION: direction,
        MongoColumns.EVENT_TYPE: event_type,
        MongoColumns.EVENT_SOURCE: event_source,
        MongoColumns.CONTENT: content,
        MongoColumns.CONTENT_FORMAT: content_format,
        MongoColumns.TURN_INDEX: turn_index,
        MongoColumns.VISIBLE_TO_USER: visible_to_user,
        MongoColumns.PERSISTED_AT: now,
        MongoColumns.UPDATED_AT: now,
    }
    await maybe_await(self._db[MongoColumns.SESSION_EVENTS].insert_one(doc))
    await maybe_await(
        self._db[MongoColumns.SESSIONS].update_one(
            {MongoColumns.SESSION_ID: session_id},
            {
                "$set": {
                    MongoColumns.LAST_SEQ: next_seq,
                    MongoColumns.UPDATED_AT: now,
                }
            },
        )
    )
    return _to_session_event_record(doc)
clear_session_event_feedback(*, session_id, player_id, event_id) async

Remove feedback from one persisted NPC message event owned by the player.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
async def clear_session_event_feedback(
    self,
    *,
    session_id: str,
    player_id: str | None,
    event_id: str,
) -> bool:
    """Remove feedback from one persisted NPC message event owned by the player."""
    session_doc = await maybe_await(
        self._db[MongoColumns.SESSIONS].find_one(
            {
                MongoColumns.SESSION_ID: session_id,
                MongoColumns.PLAYER_ID: player_id,
            },
            projection={MongoColumns.SESSION_ID: 1},
        )
    )
    if not session_doc:
        return False

    now = utc_now()
    result = await maybe_await(
        self._db[MongoColumns.SESSION_EVENTS].update_one(
            {
                MongoColumns.SESSION_ID: session_id,
                MongoColumns.EVENT_ID: event_id,
                MongoColumns.DIRECTION: "outbound",
                MongoColumns.EVENT_TYPE: "message",
                MongoColumns.EVENT_SOURCE: "npc",
            },
            {
                "$unset": {
                    MongoColumns.FEEDBACK: "",
                },
                "$set": {
                    MongoColumns.UPDATED_AT: now,
                },
            },
        )
    )
    if getattr(result, "matched_count", 0) == 0:
        return False

    await maybe_await(
        self._db[MongoColumns.SESSIONS].update_one(
            {MongoColumns.SESSION_ID: session_id},
            {"$set": {MongoColumns.UPDATED_AT: now}},
        )
    )
    return True
create_assignment(*, assignment_doc, allow_concurrent=False) async

Persist a new experiment assignment row.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
async def create_assignment(self, *, assignment_doc: dict[str, Any], allow_concurrent: bool = False) -> AssignmentRecord:
    """Persist a new experiment assignment row."""
    experiment_name = str(assignment_doc.get(MongoColumns.EXPERIMENT_NAME) or "")
    player_id = str(assignment_doc.get(MongoColumns.PLAYER_ID) or "")
    if not experiment_name or not player_id:
        raise ValueError("assignment_doc must include experiment_name and player_id")

    if not allow_concurrent:
        existing = await self.get_active_assignment(experiment_name=experiment_name, player_id=player_id)
        if existing is not None:
            raise ValueError("Player already has an active assignment for this experiment")

    now = utc_now()
    doc = dict(assignment_doc)
    doc.setdefault(MongoColumns.ASSIGNMENT_ID, str(uuid4()))
    doc.setdefault(MongoColumns.STATUS, "assigned")
    doc.setdefault("assigned_at", now)
    doc.setdefault(MongoColumns.CREATED_AT, now)
    doc[MongoColumns.UPDATED_AT] = now
    await maybe_await(self._db[MongoColumns.ASSIGNMENTS].insert_one(doc))
    record = await self.get_assignment(assignment_id=doc[MongoColumns.ASSIGNMENT_ID])
    if record is None:
        raise ValueError("Assignment insert did not persist")
    return record
create_player(*, player_data, player_id=None, issue_access_key=False, access_key=None) async

Create or update a player, optionally issuing them a new access key.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
async def create_player(
    self,
    *,
    player_data: dict[str, Any],
    player_id: str | None = None,
    issue_access_key: bool = False,
    access_key: str | None = None,
) -> tuple[PlayerRecord, str | None]:
    """Create or update a player, optionally issuing them a new access key."""
    sanitized = sanitize_player_data(player_data)
    raw_key: str | None = None

    if issue_access_key and access_key is not None:
        raise ValueError("Use either issue_access_key=True or an explicit access_key, not both.")

    if access_key is not None:
        raw_key = validate_access_key(access_key)
        sanitized.update(
            {
                "access_key": raw_key,
                "access_key_revoked": False,
                "last_key_issued_at": utc_now(),
            }
        )
    elif issue_access_key:
        raw_key = generate_access_key()
        sanitized.update(
            {
                "access_key": raw_key,
                "access_key_revoked": False,
                "last_key_issued_at": utc_now(),
            }
        )

    # SECURITY BEST PRACTICE: Split PII (Personally Identifiable Information like emails/names)
    # away from standard gameplay data. This makes GDPR compliance and data deletion much easier.
    non_pii_data, pii_fields = split_pii(sanitized)
    coll = self._db[MongoColumns.PLAYERS]

    if player_id is not None:
        # upsert=True means "Update this document if it exists. If it doesn't, create it."
        # $set ensures we only update the fields provided, leaving other existing fields alone.
        await maybe_await(coll.update_one({"_id": player_id}, {"$set": non_pii_data}, upsert=True))
        created_id = str(player_id)
    else:
        # If no ID was provided, just insert it and let Mongo generate a new ObjectId.
        created_id = str((await maybe_await(coll.insert_one(non_pii_data))).inserted_id)

    # Store the sensitive PII data in an entirely different database collection.
    if pii_fields:
        await maybe_await(
            self._db[MongoColumns.PII].update_one(
                {"player_id": created_id},
                {
                    "$set": {
                        "player_id": created_id,
                        "fields": pii_fields,
                        "updated_at": utc_now(),
                    },
                    # $setOnInsert is a cool Mongo feature: this field is ONLY written
                    # if the document is being created for the first time, ignored on updates.
                    "$setOnInsert": {"created_at": utc_now()},
                },
                upsert=True,
            )
        )

    # Reconstruct a complete domain model to return to the application.
    doc = dict(non_pii_data)
    doc["id"] = created_id
    return player_doc_to_record(doc), raw_key
create_session(session_doc) async

Log the start of a new game/app session.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
319
320
321
async def create_session(self, session_doc: dict[str, Any]) -> None:
    """Log the start of a new game/app session."""
    await maybe_await(self._db[MongoColumns.SESSIONS].insert_one(session_doc))
delete_character(character_id) async

Remove a character by ID.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
308
309
310
async def delete_character(self, character_id: str) -> None:
    """Remove a character by ID."""
    await maybe_await(self._db[MongoColumns.CHARACTERS].delete_one({"hid": character_id}))
delete_player(player_id) async

Remove a player by ID, checking all variant ID types.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
312
313
314
315
316
317
async def delete_player(self, player_id: str) -> None:
    """Remove a player by ID, checking all variant ID types."""
    ids = player_id_variants(player_id)
    if not ids:
        return
    await maybe_await(self._db[MongoColumns.PLAYERS].delete_one({"$or": [{"_id": pid} for pid in ids]}))
finalize_session(*, session_id, termination_reason, status, session_ended_at, turns_completed, last_seq) async

Update a session record with final metrics when it ends.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
async def finalize_session(
    self,
    *,
    session_id: str,
    termination_reason: str,
    status: str,
    session_ended_at: datetime,
    turns_completed: int,
    last_seq: int,
) -> None:
    """Update a session record with final metrics when it ends."""
    await maybe_await(
        self._db[MongoColumns.SESSIONS].update_one(
            {"session_id": session_id},
            {
                "$set": {
                    "termination_reason": termination_reason,
                    "status": status,
                    "session_ended_at": session_ended_at,
                    "turns_completed": turns_completed,
                    "last_seq": last_seq,
                    "updated_at": utc_now(),
                }
            },
        )
    )
get_active_assignment(*, experiment_name, player_id) async

Return the current active assignment for one player in one experiment.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
async def get_active_assignment(self, *, experiment_name: str, player_id: str) -> AssignmentRecord | None:
    """Return the current active assignment for one player in one experiment."""
    cursor = self._db[MongoColumns.ASSIGNMENTS].find(
        {
            MongoColumns.EXPERIMENT_NAME: experiment_name,
            MongoColumns.PLAYER_ID: player_id,
            MongoColumns.STATUS: {"$in": sorted(ACTIVE_ASSIGNMENT_STATUSES)},
        }
    )
    sorter = getattr(cursor, "sort", None)
    if callable(sorter):
        cursor = sorter(MongoColumns.UPDATED_AT, -1)
    docs = await _cursor_to_docs(cursor)
    if not docs:
        return None
    return _to_assignment_record(docs[0])
get_assignment(*, assignment_id) async

Return one assignment row by assignment_id.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
651
652
653
654
655
656
async def get_assignment(self, *, assignment_id: str) -> AssignmentRecord | None:
    """Return one assignment row by assignment_id."""
    doc = await maybe_await(self._db[MongoColumns.ASSIGNMENTS].find_one({MongoColumns.ASSIGNMENT_ID: assignment_id}))
    if not doc:
        return None
    return _to_assignment_record(doc)
get_assignment_for_session_id(*, session_id) async

Return the assignment that has this session as its active session.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
675
676
677
678
679
680
async def get_assignment_for_session_id(self, *, session_id: str) -> AssignmentRecord | None:
    """Return the assignment that has this session as its active session."""
    doc = await maybe_await(self._db[MongoColumns.ASSIGNMENTS].find_one({MongoColumns.ACTIVE_SESSION_ID: session_id}))
    if not doc:
        return None
    return _to_assignment_record(doc)
get_character(*, hid) async

Strict version of get_characters that guarantees a single record is returned.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
159
160
161
162
163
164
async def get_character(self, *, hid: str) -> CharacterRecord:
    """Strict version of get_characters that guarantees a single record is returned."""
    result = await self.get_characters(hid=hid)
    if not isinstance(result, CharacterRecord):
        raise ValueError(f"Character with hid='{hid}' not found")
    return result
get_characters(*, hid=None) async

Fetch a specific character by ID, or list all of them if no ID is provided.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
146
147
148
149
150
151
152
153
154
155
156
157
async def get_characters(self, *, hid: str | None = None) -> list[CharacterRecord] | CharacterRecord:
    """Fetch a specific character by ID, or list all of them if no ID is provided."""
    if hid is not None:
        # projection={"_id": 0} tells Mongo to NOT return its internal ObjectId.
        # We do this because ObjectIds often aren't JSON serializable by default.
        doc = await maybe_await(self._db[MongoColumns.CHARACTERS].find_one({"hid": hid}, projection={"_id": 0}))
        if not doc:
            raise ValueError(f"Character with hid='{hid}' not found")
        return _to_character_record(doc)

    # If no 'hid' was provided, fall back to fetching everything.
    return await self.list_characters()
get_experiment(*, experiment_name) async

Return one persisted experiment record.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
567
568
569
570
571
572
async def get_experiment(self, *, experiment_name: str) -> ExperimentRecord | None:
    """Return one persisted experiment record."""
    doc = await maybe_await(self._db[MongoColumns.EXPERIMENTS].find_one({MongoColumns.NAME: experiment_name}))
    if not doc:
        return None
    return _to_experiment_record(doc)
get_latest_experiment_assignment_for_player(*, player_id) async

Return the newest experiment assignment for one player.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
682
683
684
685
686
687
688
689
690
691
async def get_latest_experiment_assignment_for_player(self, *, player_id: str) -> AssignmentRecord | None:
    """Return the newest experiment assignment for one player."""
    cursor = self._db[MongoColumns.ASSIGNMENTS].find({MongoColumns.PLAYER_ID: player_id})
    sorter = getattr(cursor, "sort", None)
    if callable(sorter):
        cursor = sorter(MongoColumns.UPDATED_AT, -1)
    docs = await _cursor_to_docs(cursor)
    if not docs:
        return None
    return _to_assignment_record(docs[0])
get_player(*, player_id) async

Look up a player by their ID.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
async def get_player(self, *, player_id: str) -> PlayerRecord | None:
    """Look up a player by their ID."""
    # player_id_variants likely handles the fact that an ID could be stored
    # as a raw string or an ObjectId in the database.
    ids = player_id_variants(player_id)
    if not ids:
        return None

    # $or is a Mongo operator: "Find a document where _id matches ANY of the IDs in this list."
    doc = await maybe_await(self._db[MongoColumns.PLAYERS].find_one({"$or": [{"_id": pid} for pid in ids]}))
    if not doc:
        return None

    # Rename the internal Mongo '_id' to a standard 'id' for the application to use.
    # .pop() removes it from the dict and returns the value at the same time.
    doc["id"] = str(doc.pop("_id"))
    return player_doc_to_record(doc)
get_player_forms(*, player_id, experiment_name) async

Return the before-play form responses for a player in an experiment.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
async def get_player_forms(
    self,
    *,
    player_id: str,
    experiment_name: str,
) -> PlayerFormsRecord | None:
    """Return the before-play form responses for a player in an experiment."""
    doc = await maybe_await(
        self._db[MongoColumns.FORMS].find_one({MongoColumns.PLAYER_ID: player_id, MongoColumns.EXPERIMENT_NAME: experiment_name})
    )
    if not doc:
        return None
    return PlayerFormsRecord(
        player_id=doc[MongoColumns.PLAYER_ID],
        experiment_name=doc[MongoColumns.EXPERIMENT_NAME],
        data=doc.get("data", {}),
        created_at=doc.get(MongoColumns.CREATED_AT),
        updated_at=doc.get(MongoColumns.UPDATED_AT),
    )
get_players(*, access_key=None) async

Fetch all players, or specifically authenticate and fetch one by access_key.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
async def get_players(self, *, access_key: str | None = None) -> list[PlayerRecord] | PlayerRecord | None:
    """Fetch all players, or specifically authenticate and fetch one by access_key."""
    if access_key is not None:
        key = access_key.strip()
        if not key:
            return None

        # $ne means "Not Equal". Find the user with this key, where revoked is NOT True.
        doc = await maybe_await(
            self._db[MongoColumns.PLAYERS].find_one(
                {"access_key": key, "access_key_revoked": {"$ne": True}},
                projection={"access_key": 0},  # Never return the key back out in the results
            )
        )
        if not doc:
            return None
        doc["id"] = str(doc.pop("_id"))
        return player_doc_to_record(doc)

    out: list[PlayerRecord] = []
    cursor = self._db[MongoColumns.PLAYERS].find({}, projection={"access_key": 0})
    for doc in await _cursor_to_docs(cursor):
        doc["id"] = str(doc.pop("_id"))
        out.append(player_doc_to_record(doc))
    return out
get_session(*, session_id, player_id) async

Return a single persisted session record for the player.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
381
382
383
384
385
386
387
388
389
390
391
392
393
async def get_session(self, *, session_id: str, player_id: str | None) -> SessionRecord | None:
    """Return a single persisted session record for the player."""
    doc = await maybe_await(
        self._db[MongoColumns.SESSIONS].find_one(
            {
                MongoColumns.SESSION_ID: session_id,
                MongoColumns.PLAYER_ID: player_id,
            }
        )
    )
    if not doc:
        return None
    return _to_session_record(doc)
get_session_reconstruction(*, session_id, player_id) async

Return session metadata and ordered event stream for replay.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
async def get_session_reconstruction(
    self,
    *,
    session_id: str,
    player_id: str,
) -> dict[str, Any] | None:
    """Return session metadata and ordered event stream for replay."""
    # 1. Fetch the parent session record
    session_doc = await maybe_await(
        self._db[MongoColumns.SESSIONS].find_one(
            {"session_id": session_id, "player_id": player_id},
            projection={"_id": 0},
        )
    )
    if not session_doc:
        return None

    # 2. Fetch all individual events tied to this session
    events: list[dict[str, Any]] = []
    cursor = self._db[MongoColumns.SESSION_EVENTS].find(
        {"session_id": session_id},
        projection={"_id": 0},
    )

    # 3. Ensure the events are sorted by their sequence number ('seq') in ascending order (1).
    # This guarantees they are replayed in the exact order they occurred.
    sorter = getattr(cursor, "sort", None)
    if callable(sorter):
        cursor = sorter("seq", 1)

    events.extend(await _cursor_to_docs(cursor))

    # Return both parts together
    return {"session": session_doc, "events": events}
list_assignments(*, experiment_name, player_id=None, statuses=None, game_name=None) async

List assignment rows matching the requested filters.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
async def list_assignments(
    self,
    *,
    experiment_name: str,
    player_id: str | None = None,
    statuses: list[str] | None = None,
    game_name: str | None = None,
) -> list[AssignmentRecord]:
    """List assignment rows matching the requested filters."""
    query: dict[str, Any] = {MongoColumns.EXPERIMENT_NAME: experiment_name}
    if player_id is not None:
        query[MongoColumns.PLAYER_ID] = player_id
    if statuses:
        query[MongoColumns.STATUS] = {"$in": list(statuses)}
    if game_name is not None:
        query[MongoColumns.GAME_NAME] = game_name

    cursor = self._db[MongoColumns.ASSIGNMENTS].find(query)
    sorter = getattr(cursor, "sort", None)
    if callable(sorter):
        cursor = sorter("assigned_at", 1)
    docs = await _cursor_to_docs(cursor)
    return [_to_assignment_record(doc) for doc in docs]
list_characters() async

Fetch all character records from the database.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
166
167
168
169
170
171
async def list_characters(self) -> list[CharacterRecord]:
    """Fetch all character records from the database."""
    # Find with an empty query `{}` means "get everything".
    cursor = self._db[MongoColumns.CHARACTERS].find({}, projection={"_id": 0})
    docs = await _cursor_to_docs(cursor)
    return [_to_character_record(doc) for doc in docs]
list_session_events(*, session_id) async

Return all persisted session events in sequence order.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
395
396
397
398
399
400
401
402
async def list_session_events(self, *, session_id: str) -> list[SessionEventRecord]:
    """Return all persisted session events in sequence order."""
    cursor = self._db[MongoColumns.SESSION_EVENTS].find({MongoColumns.SESSION_ID: session_id})
    sorter = getattr(cursor, "sort", None)
    if callable(sorter):
        cursor = sorter(MongoColumns.SEQ, 1)
    docs = await _cursor_to_docs(cursor)
    return [_to_session_event_record(doc) for doc in docs]
pause_session(*, session_id, paused_at) async

Update a session record to reflect it is paused and awaiting resume.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
350
351
352
353
354
355
356
357
358
359
360
361
362
363
async def pause_session(self, *, session_id: str, paused_at: datetime) -> None:
    """Update a session record to reflect it is paused and awaiting resume."""
    await maybe_await(
        self._db[MongoColumns.SESSIONS].update_one(
            {"session_id": session_id},
            {
                "$set": {
                    "status": "paused",
                    "paused_at": paused_at,
                    "updated_at": utc_now(),
                }
            },
        )
    )
resume_session(*, session_id, resumed_at) async

Update a session record to reflect it has been resumed.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
async def resume_session(self, *, session_id: str, resumed_at: datetime) -> None:
    """Update a session record to reflect it has been resumed."""
    await maybe_await(
        self._db[MongoColumns.SESSIONS].update_one(
            {"session_id": session_id},
            {
                "$set": {
                    "status": "active",
                    "resumed_at": resumed_at,
                    "updated_at": utc_now(),
                },
                "$unset": {"paused_at": ""},
            },
        )
    )
set_assignment_form_response(*, assignment_id, form_key, response) async

Store one form response payload on an assignment row.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
async def set_assignment_form_response(
    self,
    *,
    assignment_id: str,
    form_key: str,
    response: dict[str, Any],
) -> AssignmentRecord | None:
    """Store one form response payload on an assignment row."""
    await maybe_await(
        self._db[MongoColumns.ASSIGNMENTS].update_one(
            {MongoColumns.ASSIGNMENT_ID: assignment_id},
            {
                "$set": {
                    f"{MongoColumns.FORM_RESPONSES}.{form_key}": response,
                    MongoColumns.UPDATED_AT: utc_now(),
                }
            },
        )
    )
    return await self.get_assignment(assignment_id=assignment_id)
set_experiment_progress(*, experiment_name, progress) async

Persist the latest experiment progress snapshot.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
async def set_experiment_progress(
    self,
    *,
    experiment_name: str,
    progress: dict[str, Any],
) -> ExperimentRecord | None:
    """Persist the latest experiment progress snapshot."""
    now = utc_now()
    await maybe_await(
        self._db[MongoColumns.EXPERIMENTS].update_one(
            {MongoColumns.NAME: experiment_name},
            {
                "$set": {
                    MongoColumns.PROGRESS: progress,
                    MongoColumns.UPDATED_AT: now,
                }
            },
        )
    )
    return await self.get_experiment(experiment_name=experiment_name)
set_player_form_response(*, player_id, experiment_name, form_key, response) async

Upsert one before-play form response into the forms collection.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
async def set_player_form_response(
    self,
    *,
    player_id: str,
    experiment_name: str,
    form_key: str,
    response: dict[str, Any],
) -> PlayerFormsRecord | None:
    """Upsert one before-play form response into the forms collection."""
    now = utc_now()
    await maybe_await(
        self._db[MongoColumns.FORMS].update_one(
            {MongoColumns.PLAYER_ID: player_id, MongoColumns.EXPERIMENT_NAME: experiment_name},
            {
                "$set": {f"data.{form_key}": response, MongoColumns.UPDATED_AT: now},
                "$setOnInsert": {
                    MongoColumns.PLAYER_ID: player_id,
                    MongoColumns.EXPERIMENT_NAME: experiment_name,
                    MongoColumns.CREATED_AT: now,
                },
            },
            upsert=True,
        )
    )
    return await self.get_player_forms(player_id=player_id, experiment_name=experiment_name)
set_session_event_feedback(*, session_id, player_id, event_id, feedback) async

Store feedback on one persisted NPC message event owned by the player.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
async def set_session_event_feedback(
    self,
    *,
    session_id: str,
    player_id: str | None,
    event_id: str,
    feedback: dict[str, Any],
) -> dict[str, Any] | None:
    """Store feedback on one persisted NPC message event owned by the player."""
    session_doc = await maybe_await(
        self._db[MongoColumns.SESSIONS].find_one(
            {
                MongoColumns.SESSION_ID: session_id,
                MongoColumns.PLAYER_ID: player_id,
            },
            projection={MongoColumns.SESSION_ID: 1},
        )
    )
    if not session_doc:
        return None

    now = utc_now()
    result = await maybe_await(
        self._db[MongoColumns.SESSION_EVENTS].update_one(
            {
                MongoColumns.SESSION_ID: session_id,
                MongoColumns.EVENT_ID: event_id,
                MongoColumns.DIRECTION: "outbound",
                MongoColumns.EVENT_TYPE: "message",
                MongoColumns.EVENT_SOURCE: "npc",
            },
            {
                "$set": {
                    MongoColumns.FEEDBACK: dict(feedback),
                    MongoColumns.UPDATED_AT: now,
                }
            },
        )
    )
    if getattr(result, "matched_count", 0) == 0:
        return None

    await maybe_await(
        self._db[MongoColumns.SESSIONS].update_one(
            {MongoColumns.SESSION_ID: session_id},
            {"$set": {MongoColumns.UPDATED_AT: now}},
        )
    )
    return dict(feedback)
update_assignment_status(*, assignment_id, status, active_session_id=None) async

Update assignment status and lifecycle timestamps.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
async def update_assignment_status(
    self,
    *,
    assignment_id: str,
    status: str,
    active_session_id: str | None = None,
) -> AssignmentRecord | None:
    """Update assignment status and lifecycle timestamps."""
    now = utc_now()
    updates: dict[str, Any] = {
        MongoColumns.STATUS: status,
        MongoColumns.UPDATED_AT: now,
    }
    if status == "assigned":
        updates.setdefault("assigned_at", now)
        updates[MongoColumns.ACTIVE_SESSION_ID] = None
    elif status == "in_progress":
        updates["started_at"] = now
        updates[MongoColumns.ACTIVE_SESSION_ID] = active_session_id
    elif status == "completed":
        updates["completed_at"] = now
        updates[MongoColumns.ACTIVE_SESSION_ID] = None
    elif status == "interrupted":
        updates["interrupted_at"] = now
        updates[MongoColumns.ACTIVE_SESSION_ID] = None

    await maybe_await(
        self._db[MongoColumns.ASSIGNMENTS].update_one(
            {MongoColumns.ASSIGNMENT_ID: assignment_id},
            {"$set": updates},
        )
    )
    return await self.get_assignment(assignment_id=assignment_id)
upsert_character(data, *, character_id=None) async

Create a new character or update an existing one.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
async def upsert_character(self, data: dict[str, Any], *, character_id: str | None = None) -> str:
    """Create a new character or update an existing one."""
    if not isinstance(data, dict):
        raise ValueError("data must be a dict")

    doc = dict(data)
    # setdefault only applies the timestamp if 'created_at' isn't already in the dict.
    doc.setdefault(MongoColumns.CREATED_AT, utc_now())

    coll = self._db[MongoColumns.CHARACTERS]
    hid = character_id or doc.get("hid")

    if hid:
        await maybe_await(coll.update_one({"hid": hid}, {"$set": doc}, upsert=True))
        return str(hid)

    result = await maybe_await(coll.insert_one(doc))
    return str(result.inserted_id)
upsert_experiment(*, experiment_name, description, config_snapshot, progress) async

Create or update an experiment metadata row.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
async def upsert_experiment(
    self,
    *,
    experiment_name: str,
    description: str,
    config_snapshot: dict[str, Any],
    progress: dict[str, Any],
) -> ExperimentRecord:
    """Create or update an experiment metadata row."""
    now = utc_now()
    await maybe_await(
        self._db[MongoColumns.EXPERIMENTS].update_one(
            {MongoColumns.NAME: experiment_name},
            {
                "$set": {
                    MongoColumns.NAME: experiment_name,
                    "description": description,
                    MongoColumns.CONFIG_SNAPSHOT: config_snapshot,
                    MongoColumns.PROGRESS: progress,
                    MongoColumns.UPDATED_AT: now,
                },
                "$setOnInsert": {MongoColumns.CREATED_AT: now},
            },
            upsert=True,
        )
    )
    record = await self.get_experiment(experiment_name=experiment_name)
    if record is None:
        raise ValueError(f"Experiment {experiment_name!r} was not persisted")
    return record
async_writer

Generic async buffered writer for Mongo collections.

AsyncMongoWriter

Bases: Generic[TDoc]

Buffer writes and periodically flush batched inserts to Mongo.

Source code in dcs_simulation_engine/dal/mongo/async_writer.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
class AsyncMongoWriter(Generic[TDoc]):
    """Buffer writes and periodically flush batched inserts to Mongo."""

    def __init__(
        self,
        *,
        collection: Any,
        batch_size: int = 20,
        flush_interval_ms: int = 200,
        max_queue_size: int = 1000,
        persisted_at_field: str | None = "persisted_at",
        ignore_duplicate_key_errors: bool = True,
    ) -> None:
        # Fail early if configured incorrectly. This prevents silent bugs later.
        if batch_size <= 0:
            raise ValueError("batch_size must be > 0")
        if flush_interval_ms <= 0:
            raise ValueError("flush_interval_ms must be > 0")
        if max_queue_size <= 0:
            raise ValueError("max_queue_size must be > 0")

        self._collection = collection
        self._batch_size = batch_size
        self._flush_interval_s = flush_interval_ms / 1000.0
        self._persisted_at_field = persisted_at_field
        self._ignore_duplicate_key_errors = ignore_duplicate_key_errors

        # The actual in-memory list where we store documents before writing them.
        self._buffer: list[TDoc] = []

        # A Lock ensures only one async task can modify `self._buffer` at a time.
        # This prevents race conditions (e.g., two tasks appending exactly at the same time).
        self._buffer_lock = asyncio.Lock()

        # A Lock to ensure we don't have multiple network calls writing to Mongo simultaneously.
        self._flush_lock = asyncio.Lock()

        # A Semaphore is like a bouncer at a club with a strict capacity limit.
        # It creates "backpressure". If we have 1000 items in the queue, `enqueue`
        # will pause (await) until some items are saved to the DB and slots open up.
        self._slots = asyncio.Semaphore(max_queue_size)

        # This holds the background task that continuously checks if we need to flush.
        self._ticker_task: asyncio.Task[None] | None = None

        # State flags so we know if the writer is currently active or shut down.
        self._closed = False
        self._started = False

        # Events act like traffic lights for async tasks.
        # _stop_event tells the background loop "time to shut down".
        self._stop_event = asyncio.Event()

        # _flush_requested is a signal that says "Hey, the buffer reached batch_size, flush now!"
        self._flush_requested = asyncio.Event()

    # __aenter__ and __aexit__ allow this class to be used as an "async context manager".
    # e.g., `async with AsyncMongoWriter(...) as writer:`
    async def __aenter__(self) -> "AsyncMongoWriter[TDoc]":
        await self.start()
        return self

    async def __aexit__(self, exc_type, exc, tb) -> None:
        # Automatically clean up and flush remaining items when exiting the `async with` block.
        await self.close()

    async def start(self) -> None:
        """Start periodic background flushing."""
        if self._started:
            return  # Prevent starting multiple background tasks if called twice

        self._started = True
        self._stop_event.clear()

        # Kick off the infinite background loop without pausing the current code execution.
        self._ticker_task = asyncio.create_task(self._ticker_loop())

    async def enqueue(self, doc: TDoc) -> None:
        """Queue one document. Backpressure applies when queue is full.

        This method never performs inline DB writes.
        """
        if self._closed:
            raise RuntimeError("writer is closed")
        if not self._started:
            raise RuntimeError("writer not started")

        # Claim 1 spot in the queue. If the queue is at max_queue_size,
        # this line will yield control back to the event loop until a spot frees up.
        await self._slots.acquire()

        should_request_flush = False

        # We use the buffer lock because we are modifying the shared `self._buffer` list.
        async with self._buffer_lock:
            self._buffer.append(doc)
            # If our buffer has reached the target batch size, flag that we need to flush.
            if len(self._buffer) >= self._batch_size:
                should_request_flush = True

        # If we hit the limit, flip the event "traffic light" to green.
        # The background _ticker_loop will see this and immediately trigger a flush.
        if should_request_flush:
            self._flush_requested.set()

    async def flush(self) -> None:
        """Flush all currently buffered writes."""
        batch: list[TDoc]

        # Safely grab all the items currently in the buffer and empty the buffer.
        async with self._buffer_lock:
            batch = self._drain_locked()

        # If there's actually anything to write, send it to Mongo.
        if batch:
            await self._flush_batch(batch)

    async def close(self) -> None:
        """Flush pending docs and stop background flushing."""
        if self._closed:
            return

        self._closed = True
        # Tell the background loop to stop running.
        self._stop_event.set()
        # Wake up the background loop in case it's currently sleeping.
        self._flush_requested.set()

        # Wait gracefully for the background task to finish its current loop.
        if self._ticker_task is not None:
            await self._ticker_task
            self._ticker_task = None

        # One final flush to make sure any stragglers in the buffer are written to the DB.
        await self.flush()

    def _drain_locked(self) -> list[TDoc]:
        """Helper to empty the buffer. MUST be called with _buffer_lock acquired."""
        batch = self._buffer
        self._buffer = []  # Reset the buffer to a fresh empty list
        return batch

    async def _ticker_loop(self) -> None:
        """The background worker that sleeps and wakes up to flush data."""
        # Keep running until someone calls close() and sets the stop event.
        while not self._stop_event.is_set():
            try:
                # Wait for EITHER the event to be set (buffer got full)
                # OR for the timeout to pass (flush interval time elapsed).
                await asyncio.wait_for(self._flush_requested.wait(), timeout=self._flush_interval_s)
            except asyncio.TimeoutError:
                # The timeout passed before the buffer filled up. That's fine!
                # We catch the error and move on to flush whatever is in there anyway.
                pass

            # Reset the flush event light back to "red" for the next cycle.
            self._flush_requested.clear()

            # Execute the database write.
            await self.flush()

    async def _flush_batch(self, batch: list[TDoc]) -> None:
        """The actual logic to interact with the database."""
        # Ensure we only have one active database write going on from this writer.
        async with self._flush_lock:
            # Inject a timestamp into every document right before saving it.
            if self._persisted_at_field:
                ts = utc_now()
                for doc in batch:
                    # setdefault ensures we don't overwrite if the doc already has this field
                    doc.setdefault(self._persisted_at_field, ts)

            try:
                # Attempt to write the whole chunk to MongoDB efficiently in one network roundtrip.
                await self._collection.insert_many(batch, ordered=True)
            except BulkWriteError as exc:
                # If a Mongo error happens, check if it's just a duplicate key error (which we might want to ignore).
                if not self._ignore_duplicate_key_errors or not _all_duplicate_key_errors(exc):
                    raise  # It's a real error, crash loudly!
                logger.debug("Ignored duplicate key write errors while flushing {} docs", len(batch))
            finally:
                # REGARDLESS of success or failure, we MUST release the semaphore slots.
                # If we don't, the queue will permanently shrink and eventually freeze the app.
                for _ in batch:
                    self._slots.release()
close() async

Flush pending docs and stop background flushing.

Source code in dcs_simulation_engine/dal/mongo/async_writer.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
async def close(self) -> None:
    """Flush pending docs and stop background flushing."""
    if self._closed:
        return

    self._closed = True
    # Tell the background loop to stop running.
    self._stop_event.set()
    # Wake up the background loop in case it's currently sleeping.
    self._flush_requested.set()

    # Wait gracefully for the background task to finish its current loop.
    if self._ticker_task is not None:
        await self._ticker_task
        self._ticker_task = None

    # One final flush to make sure any stragglers in the buffer are written to the DB.
    await self.flush()
enqueue(doc) async

Queue one document. Backpressure applies when queue is full.

This method never performs inline DB writes.

Source code in dcs_simulation_engine/dal/mongo/async_writer.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
async def enqueue(self, doc: TDoc) -> None:
    """Queue one document. Backpressure applies when queue is full.

    This method never performs inline DB writes.
    """
    if self._closed:
        raise RuntimeError("writer is closed")
    if not self._started:
        raise RuntimeError("writer not started")

    # Claim 1 spot in the queue. If the queue is at max_queue_size,
    # this line will yield control back to the event loop until a spot frees up.
    await self._slots.acquire()

    should_request_flush = False

    # We use the buffer lock because we are modifying the shared `self._buffer` list.
    async with self._buffer_lock:
        self._buffer.append(doc)
        # If our buffer has reached the target batch size, flag that we need to flush.
        if len(self._buffer) >= self._batch_size:
            should_request_flush = True

    # If we hit the limit, flip the event "traffic light" to green.
    # The background _ticker_loop will see this and immediately trigger a flush.
    if should_request_flush:
        self._flush_requested.set()
flush() async

Flush all currently buffered writes.

Source code in dcs_simulation_engine/dal/mongo/async_writer.py
121
122
123
124
125
126
127
128
129
130
131
async def flush(self) -> None:
    """Flush all currently buffered writes."""
    batch: list[TDoc]

    # Safely grab all the items currently in the buffer and empty the buffer.
    async with self._buffer_lock:
        batch = self._drain_locked()

    # If there's actually anything to write, send it to Mongo.
    if batch:
        await self._flush_batch(batch)
start() async

Start periodic background flushing.

Source code in dcs_simulation_engine/dal/mongo/async_writer.py
82
83
84
85
86
87
88
89
90
91
async def start(self) -> None:
    """Start periodic background flushing."""
    if self._started:
        return  # Prevent starting multiple background tasks if called twice

    self._started = True
    self._stop_event.clear()

    # Kick off the infinite background loop without pausing the current code execution.
    self._ticker_task = asyncio.create_task(self._ticker_loop())
const

MongoDB constants: collection names, column names, and index definitions.

MongoColumns

Namespace for MongoDB collection and field name constants.

Source code in dcs_simulation_engine/dal/mongo/const.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class MongoColumns:
    """Namespace for MongoDB collection and field name constants."""

    CHARACTERS = "characters"
    PLAYERS = "players"
    PII = "pii"
    SESSIONS = "sessions"
    SESSION_EVENTS = "session_events"
    EXPERIMENTS = "experiments"
    ASSIGNMENTS = "assignments"
    FORMS = "forms"

    ID = "_id"
    ASSIGNMENT_ID = "assignment_id"
    PLAYER_ID = "player_id"
    SESSION_ID = "session_id"
    EVENT_ID = "event_id"
    GAME_NAME = "game_name"
    EXPERIMENT_NAME = "experiment_name"
    CHARACTER_HID = "character_hid"
    ACTIVE_SESSION_ID = "active_session_id"
    FORM_RESPONSES = "form_responses"
    CONFIG_SNAPSHOT = "config_snapshot"
    PROGRESS = "progress"
    NAME = "name"
    STATUS = "status"
    SOURCE = "source"
    PC_HID = "pc_hid"
    NPC_HID = "npc_hid"
    SESSION_STARTED_AT = "session_started_at"
    SESSION_ENDED_AT = "session_ended_at"
    TERMINATION_REASON = "termination_reason"
    TURNS_COMPLETED = "turns_completed"
    MODEL_PROFILE = "model_profile"
    GAME_CONFIG_SNAPSHOT = "game_config_snapshot"
    LAST_SEQ = "last_seq"
    SEQ = "seq"
    EVENT_TS = "event_ts"
    DIRECTION = "direction"
    EVENT_TYPE = "event_type"
    EVENT_SOURCE = "event_source"
    CONTENT = "content"
    FEEDBACK = "feedback"
    CONTENT_FORMAT = "content_format"
    TURN_INDEX = "turn_index"
    COMMAND_NAME = "command_name"
    COMMAND_ARGS = "command_args"
    VISIBLE_TO_USER = "visible_to_user"
    METADATA = "metadata"
    PERSISTED_AT = "persisted_at"
    FIELDS = "fields"
    CREATED_AT = "created_at"
    UPDATED_AT = "updated_at"

    PII_KEYS = {
        "full_name",
        "name",
        "first_name",
        "last_name",
        "email",
        "phone",
        "phone_number",
    }

    PII_META_KEYS = {
        "access_key",
        "access_key_revoked",
        "created_at",
        "last_key_issued_at",
    }
util

Mongo DAL utility helpers.

This module is intentionally stateless. Connection ownership is handled by bootstrap/runtime wiring and passed into DAL objects explicitly.

connect_db(*, uri, db_name=DEFAULT_DB_NAME, client_factory=None)

Create a MongoDB DB handle from an explicit URI.

Source code in dcs_simulation_engine/dal/mongo/util.py
143
144
145
146
147
148
149
150
151
152
153
154
155
def connect_db(
    *,
    uri: str,
    db_name: str = DEFAULT_DB_NAME,
    client_factory: Any | None = None,
) -> Database[Any]:
    """Create a MongoDB DB handle from an explicit URI."""
    factory = client_factory or MongoClient
    client = factory(uri, tz_aware=True)
    client.admin.command("ping")
    db = client[db_name]
    ensure_default_indexes(db)
    return db
connect_db_async(*, uri, db_name=DEFAULT_DB_NAME, client_factory=None) async

Create an async MongoDB DB handle from an explicit URI.

Source code in dcs_simulation_engine/dal/mongo/util.py
158
159
160
161
162
163
164
165
166
167
168
169
170
async def connect_db_async(
    *,
    uri: str,
    db_name: str = DEFAULT_DB_NAME,
    client_factory: Any | None = None,
) -> AsyncDatabase[Any]:
    """Create an async MongoDB DB handle from an explicit URI."""
    factory = client_factory or AsyncMongoClient
    client = factory(uri, tz_aware=True)
    await client.admin.command("ping")
    db = client[db_name]
    await ensure_default_indexes_async(db)
    return db
dump_all_collections_to_json(db, path)

Dump every collection to JSON plus backup metadata files.

Source code in dcs_simulation_engine/dal/mongo/util.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
def dump_all_collections_to_json(db: Database[Any], path: str | Path) -> Path:
    """Dump every collection to JSON plus backup metadata files."""
    root = _make_dump_root(path)
    collection_names = sorted(db.list_collection_names())

    for collection_name in collection_names:
        out_path = root / f"{collection_name}.json"
        cursor = db[collection_name].find({})
        with out_path.open("w", encoding="utf-8") as f:
            f.write("[\n")
            first = True
            try:
                for doc in cursor:
                    if not first:
                        f.write(",\n")
                    f.write(json_util.dumps(doc))
                    first = False
            finally:
                cursor.close()
            f.write("\n]\n")
        _write_collection_indexes(root, collection_name=collection_name, index_info=db[collection_name].index_information())

    _write_dump_manifest(root, db_name=db.name, collections=collection_names)

    return root
dump_all_collections_to_json_async(db, path) async

Dump every collection to JSON plus backup metadata files.

Source code in dcs_simulation_engine/dal/mongo/util.py
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
async def dump_all_collections_to_json_async(db: AsyncDatabase[Any] | Database[Any] | Any, path: str | Path) -> Path:
    """Dump every collection to JSON plus backup metadata files."""
    root = _make_dump_root(path)
    collection_names = sorted(await maybe_await(db.list_collection_names()))

    for collection_name in collection_names:
        out_path = root / f"{collection_name}.json"
        cursor = db[collection_name].find({})
        with out_path.open("w", encoding="utf-8") as f:
            f.write("[\n")
            first = True
            try:
                if hasattr(cursor, "__aiter__"):
                    async for doc in cursor:
                        if not first:
                            f.write(",\n")
                        f.write(json_util.dumps(doc))
                        first = False
                else:
                    for doc in cursor:
                        if not first:
                            f.write(",\n")
                        f.write(json_util.dumps(doc))
                        first = False
            finally:
                await maybe_await(cursor.close())
            f.write("\n]\n")
        _write_collection_indexes(
            root,
            collection_name=collection_name,
            index_info=await maybe_await(db[collection_name].index_information()),
        )

    db_name = getattr(db, "name", "")
    _write_dump_manifest(root, db_name=db_name, collections=collection_names)

    return root
ensure_default_indexes(db)

Create baseline indexes used by runtime and tests.

Source code in dcs_simulation_engine/dal/mongo/util.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def ensure_default_indexes(db: Database[Any]) -> None:
    """Create baseline indexes used by runtime and tests."""
    db[MongoColumns.PLAYERS].create_index("access_key", unique=True, sparse=True)
    db[MongoColumns.PII].create_index(MongoColumns.PLAYER_ID, unique=True)
    db[MongoColumns.SESSIONS].create_index(MongoColumns.SESSION_ID, unique=True)
    db[MongoColumns.SESSIONS].create_index([(MongoColumns.PLAYER_ID, ASCENDING), (MongoColumns.SESSION_STARTED_AT, DESCENDING)])
    db[MongoColumns.SESSIONS].create_index([(MongoColumns.STATUS, ASCENDING), (MongoColumns.UPDATED_AT, DESCENDING)])
    db[MongoColumns.SESSION_EVENTS].create_index(
        [(MongoColumns.SESSION_ID, ASCENDING), (MongoColumns.SEQ, ASCENDING)],
        unique=True,
    )
    db[MongoColumns.SESSION_EVENTS].create_index(MongoColumns.EVENT_ID, unique=True)
    db[MongoColumns.SESSION_EVENTS].create_index([(MongoColumns.SESSION_ID, ASCENDING), (MongoColumns.EVENT_TS, ASCENDING)])
    db[MongoColumns.EXPERIMENTS].create_index(MongoColumns.NAME, unique=True)
    db[MongoColumns.ASSIGNMENTS].create_index(MongoColumns.ASSIGNMENT_ID, unique=True)
    db[MongoColumns.ASSIGNMENTS].create_index(
        [
            (MongoColumns.EXPERIMENT_NAME, ASCENDING),
            (MongoColumns.PLAYER_ID, ASCENDING),
            (MongoColumns.UPDATED_AT, DESCENDING),
        ]
    )
    db[MongoColumns.ASSIGNMENTS].create_index(
        [
            (MongoColumns.EXPERIMENT_NAME, ASCENDING),
            (MongoColumns.STATUS, ASCENDING),
            (MongoColumns.UPDATED_AT, DESCENDING),
        ]
    )
    db[MongoColumns.ASSIGNMENTS].create_index(
        [
            (MongoColumns.EXPERIMENT_NAME, ASCENDING),
            (MongoColumns.GAME_NAME, ASCENDING),
            (MongoColumns.STATUS, ASCENDING),
        ]
    )
    db[MongoColumns.ASSIGNMENTS].create_index(MongoColumns.ACTIVE_SESSION_ID, sparse=True)
    db[MongoColumns.FORMS].create_index(
        [(MongoColumns.PLAYER_ID, ASCENDING), (MongoColumns.EXPERIMENT_NAME, ASCENDING)],
        unique=True,
    )

    for collection_name, defs in INDEX_DEFS.items():
        coll = db[collection_name]
        for spec in defs:
            coll.create_index(spec["fields"], unique=spec.get("unique", False))
ensure_default_indexes_async(db) async

Create baseline indexes used by async runtime paths.

Source code in dcs_simulation_engine/dal/mongo/util.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
async def ensure_default_indexes_async(db: AsyncDatabase[Any]) -> None:
    """Create baseline indexes used by async runtime paths."""
    await db[MongoColumns.PLAYERS].create_index("access_key", unique=True, sparse=True)
    await db[MongoColumns.PII].create_index(MongoColumns.PLAYER_ID, unique=True)
    await db[MongoColumns.SESSIONS].create_index(MongoColumns.SESSION_ID, unique=True)
    await db[MongoColumns.SESSIONS].create_index([(MongoColumns.PLAYER_ID, ASCENDING), (MongoColumns.SESSION_STARTED_AT, DESCENDING)])
    await db[MongoColumns.SESSIONS].create_index([(MongoColumns.STATUS, ASCENDING), (MongoColumns.UPDATED_AT, DESCENDING)])
    await db[MongoColumns.SESSION_EVENTS].create_index(
        [(MongoColumns.SESSION_ID, ASCENDING), (MongoColumns.SEQ, ASCENDING)],
        unique=True,
    )
    await db[MongoColumns.SESSION_EVENTS].create_index(MongoColumns.EVENT_ID, unique=True)
    await db[MongoColumns.SESSION_EVENTS].create_index([(MongoColumns.SESSION_ID, ASCENDING), (MongoColumns.EVENT_TS, ASCENDING)])
    await db[MongoColumns.EXPERIMENTS].create_index(MongoColumns.NAME, unique=True)
    await db[MongoColumns.ASSIGNMENTS].create_index(MongoColumns.ASSIGNMENT_ID, unique=True)
    await db[MongoColumns.ASSIGNMENTS].create_index(
        [
            (MongoColumns.EXPERIMENT_NAME, ASCENDING),
            (MongoColumns.PLAYER_ID, ASCENDING),
            (MongoColumns.UPDATED_AT, DESCENDING),
        ]
    )
    await db[MongoColumns.ASSIGNMENTS].create_index(
        [
            (MongoColumns.EXPERIMENT_NAME, ASCENDING),
            (MongoColumns.STATUS, ASCENDING),
            (MongoColumns.UPDATED_AT, DESCENDING),
        ]
    )
    await db[MongoColumns.ASSIGNMENTS].create_index(
        [
            (MongoColumns.EXPERIMENT_NAME, ASCENDING),
            (MongoColumns.GAME_NAME, ASCENDING),
            (MongoColumns.STATUS, ASCENDING),
        ]
    )
    await db[MongoColumns.ASSIGNMENTS].create_index(MongoColumns.ACTIVE_SESSION_ID, sparse=True)
    await db[MongoColumns.FORMS].create_index(
        [(MongoColumns.PLAYER_ID, ASCENDING), (MongoColumns.EXPERIMENT_NAME, ASCENDING)],
        unique=True,
    )

    for collection_name, defs in INDEX_DEFS.items():
        coll = db[collection_name]
        for spec in defs:
            await coll.create_index(spec["fields"], unique=spec.get("unique", False))
player_doc_to_record(doc)

Convert a raw MongoDB player document to a PlayerRecord.

Source code in dcs_simulation_engine/dal/mongo/util.py
322
323
324
325
326
327
328
329
330
def player_doc_to_record(doc: dict[str, Any]) -> PlayerRecord:
    """Convert a raw MongoDB player document to a PlayerRecord."""
    known = {"id", "_id", "created_at", "access_key"}
    return PlayerRecord(
        id=doc.get("id") or str(doc.get("_id", "")),
        created_at=doc.get("created_at"),
        access_key=doc.get("access_key"),
        data={k: v for k, v in doc.items() if k not in known},
    )
player_id_variants(player_id)

Return equivalent player id values (string and ObjectId variants).

Source code in dcs_simulation_engine/dal/mongo/util.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
def player_id_variants(player_id: str | Any | None) -> list[Any]:
    """Return equivalent player id values (string and ObjectId variants)."""
    if player_id is None:
        return []

    variants: list[Any] = [player_id]
    if isinstance(player_id, str):
        try:
            variants.append(ObjectId(player_id))
        except Exception:
            pass

    out: list[Any] = []
    seen: set[str] = set()
    for value in variants:
        key = repr(value)
        if key not in seen:
            out.append(value)
            seen.add(key)
    return out
sanitize_player_data(player_data)

Remove access-key fields from player_data and set a default created_at.

Source code in dcs_simulation_engine/dal/mongo/util.py
273
274
275
276
277
278
279
280
281
282
283
284
def sanitize_player_data(player_data: dict[str, Any]) -> dict[str, Any]:
    """Remove access-key fields from player_data and set a default created_at."""
    data = dict(player_data)

    for k in (
        "access_key",
        "access_key_revoked",
    ):
        data.pop(k, None)

    data.setdefault(MongoColumns.CREATED_AT, utc_now())
    return data
split_pii(player_data)

Split player_data into (non_pii, pii) dicts based on PII field definitions.

Source code in dcs_simulation_engine/dal/mongo/util.py
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def split_pii(player_data: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
    """Split player_data into (non_pii, pii) dicts based on PII field definitions."""
    non_pii: dict[str, Any] = {}
    pii: dict[str, Any] = {}

    for key, value in player_data.items():
        if key in MongoColumns.PII_META_KEYS or key == MongoColumns.CREATED_AT:
            non_pii[key] = value
            continue

        if isinstance(value, dict):
            field_key = value.get("key", key)
            answer = value.get("answer")

            is_pii = bool(value.get("pii")) or field_key in MongoColumns.PII_KEYS or key in MongoColumns.PII_KEYS

            if not is_pii:
                non_pii[key] = value
            else:
                v_clean = dict(value)
                v_clean.pop("answer", None)
                non_pii[key] = v_clean

                if answer not in (None, "", [], {}):
                    pii[field_key] = answer
        else:
            if key in MongoColumns.PII_KEYS:
                if value not in (None, "", [], {}):
                    pii[key] = value
            else:
                non_pii[key] = value

    return non_pii, pii

deployments

Deployment assets used by the DCS CLI.

templates

Jinja templates for generated deployment configuration files.

errors

Errors for DCS Simulation Engine.

APIRequestError

Bases: RuntimeError

Base class for all domain errors.

Source code in dcs_simulation_engine/errors.py
4
5
class APIRequestError(RuntimeError):
    """Base class for all domain errors."""

GameValidationError

Bases: APIRequestError

Raised when a game config fails validation.

Source code in dcs_simulation_engine/errors.py
8
9
class GameValidationError(APIRequestError):
    """Raised when a game config fails validation."""

games

Built-in game class definitions.

ai_client

Async AI client for new-style games.

Provides two client types: - UpdaterClient: stateful, maintains conversation history for multi-turn chat. - ValidatorClient: stateless, sends only the system prompt + current action each call.

Both call OpenRouter's OpenAI-compatible chat completions endpoint.

ScorerClient

One-shot stateless client that scores a player's goal inference against the NPC profile.

Source code in dcs_simulation_engine/games/ai_client.py
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
class ScorerClient:
    """One-shot stateless client that scores a player's goal inference against the NPC profile."""

    def __init__(self, npc: CharacterRecord, model: str = DEFAULT_MODEL) -> None:
        """Initialise with NPC character record and model identifier."""
        self._npc = npc
        self._model = model

    async def score(self, transcript: str, guess: str) -> ScorerResult:
        """Score the player's goal inference and return parsed + raw JSON results."""
        prompt = (
            SandboxedEnvironment()
            .from_string(_INFERENCE_SCORER_TEMPLATE)
            .render(
                npc_long_description=self._npc.data.get("long_description", ""),
                npc_abilities=self._npc.data.get("abilities", ""),
                transcript=transcript,
                guess=guess,
            )
        )
        raw = await _call_openrouter([{"role": "user", "content": prompt}], self._model)
        stripped = _strip_json_fences(raw)
        result = _normalize_inference_evaluation(_parse_json_response(raw))
        logger.debug(f"ScorerClient result: {result}")
        return ScorerResult(evaluation=result, raw_json=stripped)
__init__(npc, model=DEFAULT_MODEL)

Initialise with NPC character record and model identifier.

Source code in dcs_simulation_engine/games/ai_client.py
233
234
235
236
def __init__(self, npc: CharacterRecord, model: str = DEFAULT_MODEL) -> None:
    """Initialise with NPC character record and model identifier."""
    self._npc = npc
    self._model = model
score(transcript, guess) async

Score the player's goal inference and return parsed + raw JSON results.

Source code in dcs_simulation_engine/games/ai_client.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
async def score(self, transcript: str, guess: str) -> ScorerResult:
    """Score the player's goal inference and return parsed + raw JSON results."""
    prompt = (
        SandboxedEnvironment()
        .from_string(_INFERENCE_SCORER_TEMPLATE)
        .render(
            npc_long_description=self._npc.data.get("long_description", ""),
            npc_abilities=self._npc.data.get("abilities", ""),
            transcript=transcript,
            guess=guess,
        )
    )
    raw = await _call_openrouter([{"role": "user", "content": prompt}], self._model)
    stripped = _strip_json_fences(raw)
    result = _normalize_inference_evaluation(_parse_json_response(raw))
    logger.debug(f"ScorerClient result: {result}")
    return ScorerResult(evaluation=result, raw_json=stripped)
ScorerResult

Bases: NamedTuple

Parsed evaluation payload plus the raw JSON text returned by the scorer.

Source code in dcs_simulation_engine/games/ai_client.py
223
224
225
226
227
class ScorerResult(NamedTuple):
    """Parsed evaluation payload plus the raw JSON text returned by the scorer."""

    evaluation: dict[str, Any]
    raw_json: str
UpdaterClient

Stateful async client for the scene-advancing (updater) LLM.

Maintains the full conversation history so the model has context for each new turn. The system prompt is injected once at the start of each history.

Source code in dcs_simulation_engine/games/ai_client.py
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
class UpdaterClient:
    """Stateful async client for the scene-advancing (updater) LLM.

    Maintains the full conversation history so the model has context for each
    new turn. The system prompt is injected once at the start of each history.
    """

    def __init__(self, system_prompt: str, model: str = DEFAULT_MODEL) -> None:
        """Initialise with a system prompt and model identifier."""
        self._system_prompt = system_prompt
        self._model = model
        self._history: list[dict[str, str]] = []

    @property
    def history(self) -> list[dict[str, str]]:
        """Read-only copy of the conversation history."""
        return list(self._history)

    def reset(self) -> None:
        """Clear conversation history (e.g. on game reset)."""
        self._history = []

    async def chat(self, user_input: str | None) -> str:
        """Send the user's action and return the NPC's response content string."""
        # On the very first call (no history, no input), prompt the model to open the scene.
        content = user_input or "Begin."
        self._history.append({"role": "user", "content": content})

        messages = [{"role": "system", "content": self._system_prompt}] + self._history
        raw = await _call_openrouter(messages, self._model)

        # Parse the JSON wrapper the updater prompt requests.
        parsed = _parse_json_response(raw)
        reply = parsed.get("content", raw)

        self._history.append({"role": "assistant", "content": reply})
        logger.debug(f"UpdaterClient reply ({len(reply)} chars)")
        return reply
history property

Read-only copy of the conversation history.

__init__(system_prompt, model=DEFAULT_MODEL)

Initialise with a system prompt and model identifier.

Source code in dcs_simulation_engine/games/ai_client.py
164
165
166
167
168
def __init__(self, system_prompt: str, model: str = DEFAULT_MODEL) -> None:
    """Initialise with a system prompt and model identifier."""
    self._system_prompt = system_prompt
    self._model = model
    self._history: list[dict[str, str]] = []
chat(user_input) async

Send the user's action and return the NPC's response content string.

Source code in dcs_simulation_engine/games/ai_client.py
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
async def chat(self, user_input: str | None) -> str:
    """Send the user's action and return the NPC's response content string."""
    # On the very first call (no history, no input), prompt the model to open the scene.
    content = user_input or "Begin."
    self._history.append({"role": "user", "content": content})

    messages = [{"role": "system", "content": self._system_prompt}] + self._history
    raw = await _call_openrouter(messages, self._model)

    # Parse the JSON wrapper the updater prompt requests.
    parsed = _parse_json_response(raw)
    reply = parsed.get("content", raw)

    self._history.append({"role": "assistant", "content": reply})
    logger.debug(f"UpdaterClient reply ({len(reply)} chars)")
    return reply
reset()

Clear conversation history (e.g. on game reset).

Source code in dcs_simulation_engine/games/ai_client.py
175
176
177
def reset(self) -> None:
    """Clear conversation history (e.g. on game reset)."""
    self._history = []
ValidatorClient

Stateless async client for the input-validation LLM.

Sends a fresh context each call: just the pre-rendered system prompt (which already includes pc abilities) plus the user's proposed action. No history is maintained between calls.

Source code in dcs_simulation_engine/games/ai_client.py
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
class ValidatorClient:
    """Stateless async client for the input-validation LLM.

    Sends a fresh context each call: just the pre-rendered system prompt
    (which already includes pc abilities) plus the user's proposed action.
    No history is maintained between calls.
    """

    def __init__(self, system_prompt_template: str, model: str = DEFAULT_MODEL) -> None:
        """Initialise with the Jinja2 template string from build_validator_prompt()."""
        # The template has a {{ user_input }} placeholder filled per-call via Jinja2.
        # Character data with literal { } braces is safe because Jinja2 only
        # expands {{ var }} syntax, not arbitrary brace sequences.
        self._system_prompt_template = system_prompt_template
        self._model = model

    async def validate(self, user_input: str) -> dict[str, Any]:
        """Validate a user action. Returns {"type": "info"|"error", "content": str}."""
        system_prompt = SandboxedEnvironment().from_string(self._system_prompt_template).render(user_input=user_input)
        messages = [{"role": "system", "content": system_prompt}]
        raw = await _call_openrouter(messages, self._model)
        result = _parse_json_response(raw)
        logger.debug(f"ValidatorClient result: {result}")
        return result
__init__(system_prompt_template, model=DEFAULT_MODEL)

Initialise with the Jinja2 template string from build_validator_prompt().

Source code in dcs_simulation_engine/games/ai_client.py
205
206
207
208
209
210
211
def __init__(self, system_prompt_template: str, model: str = DEFAULT_MODEL) -> None:
    """Initialise with the Jinja2 template string from build_validator_prompt()."""
    # The template has a {{ user_input }} placeholder filled per-call via Jinja2.
    # Character data with literal { } braces is safe because Jinja2 only
    # expands {{ var }} syntax, not arbitrary brace sequences.
    self._system_prompt_template = system_prompt_template
    self._model = model
validate(user_input) async

Validate a user action. Returns {"type": "info"|"error", "content": str}.

Source code in dcs_simulation_engine/games/ai_client.py
213
214
215
216
217
218
219
220
async def validate(self, user_input: str) -> dict[str, Any]:
    """Validate a user action. Returns {"type": "info"|"error", "content": str}."""
    system_prompt = SandboxedEnvironment().from_string(self._system_prompt_template).render(user_input=user_input)
    messages = [{"role": "system", "content": system_prompt}]
    raw = await _call_openrouter(messages, self._model)
    result = _parse_json_response(raw)
    logger.debug(f"ValidatorClient result: {result}")
    return result
set_fake_ai_response(value)

Set a process-local override returned by _call_openrouter when configured.

Source code in dcs_simulation_engine/games/ai_client.py
28
29
30
31
def set_fake_ai_response(value: str | None) -> None:
    """Set a process-local override returned by _call_openrouter when configured."""
    global _FAKE_AI_RESPONSE
    _FAKE_AI_RESPONSE = value
validate_openrouter_configuration()

Validate runtime configuration needed for live OpenRouter requests.

Source code in dcs_simulation_engine/games/ai_client.py
34
35
36
37
38
39
40
41
42
43
def validate_openrouter_configuration() -> None:
    """Validate runtime configuration needed for live OpenRouter requests."""
    if _FAKE_AI_RESPONSE is not None:
        return

    key = os.getenv("OPENROUTER_API_KEY", "").strip()
    if not key:
        raise RuntimeError(
            "OPENROUTER_API_KEY is required to start the server. Set it in the environment, or use --fake-ai-response for local mock mode."
        )

const

Explore

String constants for the Explore game.

Source code in dcs_simulation_engine/games/const.py
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class Explore:
    """String constants for the Explore game."""

    HELP_CONTENT = """\
**Player Character (PC, you):** {pc_hid} ({pc_short_description})

**Non-Player Character (NPC, the simulator):** {npc_hid} ({npc_short_description})

---

**Player Objective:** No objective; open-ended.

**How to Play:** Describe your character’s next action.

**How to Finish:** Type `/finish`.

---

- Type `/abilities` for character abilities.
- Type `/help` at any time to see this message again.\
"""

    ABILITIES_CONTENT = """\
## Player Character (PC, you): {pc_hid} 

### Description
{pc_short_description}

### Abilities
{pc_abilities}

--- 

## Non-Player Character (NPC, the simulator): {npc_hid}

### Description
{npc_short_description})

### Abilities
{npc_abilities}\
"""

    FINISH_CONTENT = "Game finished (reason: {finish_reason})"
Foresight

String constants for the Foresight game.

Source code in dcs_simulation_engine/games/const.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
class Foresight:
    """String constants for the Foresight game."""

    HELP_CONTENT = """\
**Player Character (PC, you):** {pc_hid} ({pc_short_description})

**Non-Player Character (NPC, the simulator):** {npc_hid}  (*details hidden*)

---

**Player Objective:** Predict the NPC’s response to your next action for each turn.

**How to Play:** Describe your character’s next action and how you think the NPC will respond next.

**How to finish:** When you’re ready to finish, use `/finish`.

---

- Type `/abilities` for character abilities.
- Type `/help` at any time to see this message again.\
"""

    ABILITIES_CONTENT = """\
## Player Character (PC, you): {pc_hid} 

### Description
{pc_short_description}

### Abilities
{pc_abilities}

--- 

## Non-Player Character (NPC, the simulator): {npc_hid}

*NPC details are hidden.*\
"""

    ADDITIONAL_VALIDATOR_RULES = """\
- ALLOW PREDICTIONS: The user's input IS ALLOWED to include a prediction about what the other character's response will be. For example, "I wave my hand and predict they will wave back."\
"""

    ADDITIONAL_UPDATER_RULES = """\
- IGNORE PREDICTIONS:
The user's input MAY include a prediction about what the simulator character's response will be. IGNORE ANY PREDICTIONS ENTIRELY. DO NOT ADJUDICATE THEM OR RESPOND TO THEM IN ANY WAY. ONLY RESPOND TO THE USER'S ACTION.\
"""

    FINISH_CONTENT = "Game finished (reason: {finish_reason})"
GoalHorizon

String constants for the Goal Horizon game.

Source code in dcs_simulation_engine/games/const.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
class GoalHorizon:
    """String constants for the Goal Horizon game."""

    ENTER_CONTENT = """\
**Player Character (PC, you):** {pc_hid} ({pc_short_description})

**Non-Player Character (NPC, the simulator):** {npc_hid}  (*details hidden*)

---

**Player Objective:** Determine the NPC’s capacities and limitations.

**How to Play:** Describe your character’s next action.

**How to finish:** When you’re ready to answer, use `/predict-capabilities` to submit your prediction about the NPC’s capabilities and finish the game.

---

- Type `/abilities` for character abilities.
- Type `/help` at any time to see this message again.\
"""

    HELP_CONTENT = """\
**Player Character (PC, you):** {pc_hid} ({pc_short_description})

**Non-Player Character (NPC, the simulator):** {npc_hid} (*details hidden*)

---

**Player Objective:** Determine the NPC’s capabilities and limitations through interaction.

**How to Play:** Describe your character’s next action.

**How to finish:** Type `/predict-capabilities` to submit your answer about the NPC’s capabilities.

---

- Type `/abilities` for character abilities.
- Type `/help` at any time to see this message again.\
"""

    ABILITIES_CONTENT = """\
## Player Character (PC, you): {pc_hid}

### Description
{pc_short_description}

### Abilities
{pc_abilities}

---

## Non-Player Character (NPC, the simulator): {npc_hid}

*NPC details are hidden.*\
"""

    CAPABILITY_PREDICTION_QUESTION = """\
What do you think are the largest types of goals that {npc_hid} is capable of pursuing? ("Goals" are things like maintaining internal health or stability, Describe in a few sentences.\
"""

    CAPABILITY_PREDICTION_CONFIDENCE = """\
How confident are you in your prediction and why?\
"""

    FINISH_CONTENT = "Game finished (reason: {finish_reason})"
InferIntent

String constants for the Infer Intent game.

Source code in dcs_simulation_engine/games/const.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
class InferIntent:
    """String constants for the Infer Intent game."""

    HELP_CONTENT = """\
**Player Character (PC, you):** {pc_hid} ({pc_short_description})

**Non-Player Character (NPC, the simulator):** {npc_hid} (*details hidden*)

---

**Player Objective:** Determine the NPC’s intention through interaction.

**How to Play:** Describe your character’s next action.

**How to finish:** Type `/predict-intent` to submit your answer about the NPC’s intention.

---

- Type `/abilities` for character abilities.
- Type `/help` at any time to see this message again.\
"""

    ABILITIES_CONTENT = """\
## Player Character (PC, you): {pc_hid} 

### Description
{pc_short_description}

### Abilities
{pc_abilities}

--- 

## Non-Player Character (NPC, the simulator): {npc_hid}

*NPC details are hidden.*\
"""

    GOAL_INFERENCE_QUESTION = """\
What do you think the character's goal or intention was during this interaction? Please describe in a few sentences.\
"""

    GOAL_INFERENCE_CONFIDENCE = """\
How confident are you in your prediction and why?\
"""

    ADDITIONAL_UPDATER_RULES = """\
- Goal Aligned Response: The simulator character's response should be in-line with a specific goal or intention that s/he/it/they are trying to communicate with the user character.\
"""

    FINISH_CONTENT = "Game finished (reason: {finish_reason})"
Teamwork

String constants for the Teamwork game.

Source code in dcs_simulation_engine/games/const.py
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
class Teamwork:
    """String constants for the Teamwork game."""

    HELP_CONTENT = """\
**Player Character (PC, you):** {pc_hid} ({pc_short_description})

**Non-Player Character (NPC, the simulator):** {npc_hid} (*details hidden*)

---

**Player Objective:** Determine the NPC’s capabilities and limitations through interaction.

**How to Play:** Describe your character’s next action.

**How to finish:** Type `/predict-capabilities` to submit your answer about the NPC’s capabilities. Or type `/finish` to leave the game.

---

- Type `/abilities` for character abilities.
- Type `/help` at any time to see this message again.\
"""

    ABILITIES_CONTENT = """\
## Player Character (PC, you): {pc_hid}

### Description
{pc_short_description}

### Abilities
{pc_abilities}

---

## Non-Player Character (NPC, the simulator): {npc_hid}

*NPC details are hidden.*\
"""

    CHALLENGES_QUESTION = """\
Which parts of this process were challenging, and why?
Which parts were easier, and why?\
"""

    ADDITIONAL_UPDATER_RULES = """\
- Goal Aligned Response: The simulator character's response should be in-line with a specific goal or intention that s/he/it/they are trying to communicate with the user character.\
"""

    FINISH_CONTENT = "Game finished (reason: {finish_reason})"

explore

Explore game.

Command

Bases: StrEnum

Game-level slash commands recognised by ExploreGame.

Source code in dcs_simulation_engine/games/explore.py
21
22
23
24
25
26
class Command(StrEnum):
    """Game-level slash commands recognised by ExploreGame."""

    HELP = "help"
    ABILITIES = "abilities"
    FINISH = "finish"
ExploreGame

Bases: Game

Free-form exploration game: player describes actions, NPC reacts, no predefined goals.

Source code in dcs_simulation_engine/games/explore.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
class ExploreGame(Game):
    """Free-form exploration game: player describes actions, NPC reacts, no predefined goals."""

    DEFAULT_RETRY_BUDGET = 10
    DEFAULT_MAX_INPUT_LENGTH = 350

    def __init__(
        self,
        pc: CharacterRecord,
        npc: CharacterRecord,
        updater: UpdaterClient,
        validator: ValidatorClient,
        retry_budget: int = DEFAULT_RETRY_BUDGET,
        max_input_length: int = DEFAULT_MAX_INPUT_LENGTH,
    ) -> None:
        """Initialise the game. Use create_from_context() as the public entry point."""
        self._pc = pc
        self._npc = npc
        self._updater = updater
        self._validator = validator
        self._retry_budget = retry_budget
        self._max_input_length = max_input_length
        self._entered = False
        self._exited = False
        self._exit_reason = ""

    @classmethod
    def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "ExploreGame":
        """Factory called by SessionManager. Builds clients from character dicts.

        Accepted kwargs:
            retry_budget (int): overrides DEFAULT_RETRY_BUDGET
            max_input_length (int): overrides DEFAULT_MAX_INPUT_LENGTH
        """
        updater = UpdaterClient(system_prompt=build_updater_prompt(pc, npc))
        validator = ValidatorClient(system_prompt_template=build_validator_prompt(pc, npc))
        return cls(
            pc=pc,
            npc=npc,
            updater=updater,
            validator=validator,
            retry_budget=kwargs.get("retry_budget", cls.DEFAULT_RETRY_BUDGET),
            max_input_length=kwargs.get("max_input_length", cls.DEFAULT_MAX_INPUT_LENGTH),
        )

    def exit(self, reason: str) -> None:
        """Mark the game as ended."""
        if self._exited:
            return
        self._exited = True
        self._exit_reason = reason
        logger.info(f"ExploreGame exited: {reason}")

    @property
    def exited(self) -> bool:
        """True if the game has ended."""
        return self._exited

    @property
    def exit_reason(self) -> str:
        """Reason the game ended, or empty string."""
        return self._exit_reason

    async def step(self, user_input: str | None = None) -> AsyncIterator[GameEvent]:
        """Advance the game one turn, yielding one or more GameEvents."""
        if self._exited:
            return

        # ENTER: first call — emit welcome message then generate the opening scene.
        if not self._entered:
            self._entered = True
            yield GameEvent.now(
                type="info",
                content=C.HELP_CONTENT.format(
                    pc_hid=self._pc.hid,
                    pc_short_description=self._pc.short_description,
                    npc_hid=self._npc.hid,
                    npc_short_description=self._npc.short_description,
                ),
            )
            opening = await self._updater.chat(None)
            yield GameEvent.now(type="ai", content=opening)
            return

        if not user_input:
            return

        # Game-level commands (/help, /abilities). Session-level exit commands
        # are already handled by SessionManager.
        command_event = self._handle_command(user_input)
        if command_event is not None:
            yield command_event
            return

        if len(user_input) > self._max_input_length:
            yield GameEvent.now(
                type="error",
                content=f"Input exceeds maximum length of {self._max_input_length} characters.",
            )
            return

        # Validate before advancing the scene.
        validation = await self._validator.validate(user_input)
        if validation.get("type") == "error":
            self._retry_budget -= 1
            logger.debug(f"Validation failed. Retry budget remaining: {self._retry_budget}")
            if self._retry_budget <= 0:
                self.exit("retry budget exhausted")
                yield GameEvent.now(type="error", content=validation.get("content", "Invalid action."))
                yield GameEvent.now(type="info", content="You have used all your allowed retries. The game is ending.")
                return
            yield GameEvent.now(type="error", content=validation.get("content", "Invalid action."))
            return

        reply = await self._updater.chat(user_input)
        yield GameEvent.now(type="ai", content=reply)

    def _handle_command(self, user_input: str) -> GameEvent | None:
        """Return a GameEvent for recognised game-level commands, or None to continue."""
        stripped = user_input.strip()
        if not stripped.startswith("/"):
            return None

        command_body = stripped[1:].strip()
        if not command_body:
            return None
        cmd = command_body.split()[0].lower()

        if cmd == Command.HELP:
            return GameEvent.now(
                type="info",
                content=C.HELP_CONTENT.format(
                    pc_hid=self._pc.hid,
                    pc_short_description=self._pc.short_description,
                    npc_hid=self._npc.hid,
                    npc_short_description=self._npc.short_description,
                ),
                command_response=True,
            )

        if cmd == Command.ABILITIES:
            return GameEvent.now(
                type="info",
                content=C.ABILITIES_CONTENT.format(
                    pc_hid=self._pc.hid,
                    pc_short_description=self._pc.short_description,
                    pc_abilities=format_abilities_markdown(self._pc.data.get("abilities", "")),
                    npc_hid=self._npc.hid,
                    npc_short_description=self._npc.short_description,
                    npc_abilities=format_abilities_markdown(self._npc.data.get("abilities", "")),
                ),
                command_response=True,
            )

        if cmd == Command.FINISH:
            self.exit("player finished")
            return GameEvent.now(
                type="info",
                content=C.FINISH_CONTENT.format(finish_reason="player finished"),
                command_response=True,
            )

        # Unrecognised — return None so SessionManager can handle it.
        return None
exit_reason property

Reason the game ended, or empty string.

exited property

True if the game has ended.

__init__(pc, npc, updater, validator, retry_budget=DEFAULT_RETRY_BUDGET, max_input_length=DEFAULT_MAX_INPUT_LENGTH)

Initialise the game. Use create_from_context() as the public entry point.

Source code in dcs_simulation_engine/games/explore.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def __init__(
    self,
    pc: CharacterRecord,
    npc: CharacterRecord,
    updater: UpdaterClient,
    validator: ValidatorClient,
    retry_budget: int = DEFAULT_RETRY_BUDGET,
    max_input_length: int = DEFAULT_MAX_INPUT_LENGTH,
) -> None:
    """Initialise the game. Use create_from_context() as the public entry point."""
    self._pc = pc
    self._npc = npc
    self._updater = updater
    self._validator = validator
    self._retry_budget = retry_budget
    self._max_input_length = max_input_length
    self._entered = False
    self._exited = False
    self._exit_reason = ""
create_from_context(pc, npc, **kwargs) classmethod

Factory called by SessionManager. Builds clients from character dicts.

Accepted kwargs

retry_budget (int): overrides DEFAULT_RETRY_BUDGET max_input_length (int): overrides DEFAULT_MAX_INPUT_LENGTH

Source code in dcs_simulation_engine/games/explore.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
@classmethod
def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "ExploreGame":
    """Factory called by SessionManager. Builds clients from character dicts.

    Accepted kwargs:
        retry_budget (int): overrides DEFAULT_RETRY_BUDGET
        max_input_length (int): overrides DEFAULT_MAX_INPUT_LENGTH
    """
    updater = UpdaterClient(system_prompt=build_updater_prompt(pc, npc))
    validator = ValidatorClient(system_prompt_template=build_validator_prompt(pc, npc))
    return cls(
        pc=pc,
        npc=npc,
        updater=updater,
        validator=validator,
        retry_budget=kwargs.get("retry_budget", cls.DEFAULT_RETRY_BUDGET),
        max_input_length=kwargs.get("max_input_length", cls.DEFAULT_MAX_INPUT_LENGTH),
    )
exit(reason)

Mark the game as ended.

Source code in dcs_simulation_engine/games/explore.py
74
75
76
77
78
79
80
def exit(self, reason: str) -> None:
    """Mark the game as ended."""
    if self._exited:
        return
    self._exited = True
    self._exit_reason = reason
    logger.info(f"ExploreGame exited: {reason}")
step(user_input=None) async

Advance the game one turn, yielding one or more GameEvents.

Source code in dcs_simulation_engine/games/explore.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
async def step(self, user_input: str | None = None) -> AsyncIterator[GameEvent]:
    """Advance the game one turn, yielding one or more GameEvents."""
    if self._exited:
        return

    # ENTER: first call — emit welcome message then generate the opening scene.
    if not self._entered:
        self._entered = True
        yield GameEvent.now(
            type="info",
            content=C.HELP_CONTENT.format(
                pc_hid=self._pc.hid,
                pc_short_description=self._pc.short_description,
                npc_hid=self._npc.hid,
                npc_short_description=self._npc.short_description,
            ),
        )
        opening = await self._updater.chat(None)
        yield GameEvent.now(type="ai", content=opening)
        return

    if not user_input:
        return

    # Game-level commands (/help, /abilities). Session-level exit commands
    # are already handled by SessionManager.
    command_event = self._handle_command(user_input)
    if command_event is not None:
        yield command_event
        return

    if len(user_input) > self._max_input_length:
        yield GameEvent.now(
            type="error",
            content=f"Input exceeds maximum length of {self._max_input_length} characters.",
        )
        return

    # Validate before advancing the scene.
    validation = await self._validator.validate(user_input)
    if validation.get("type") == "error":
        self._retry_budget -= 1
        logger.debug(f"Validation failed. Retry budget remaining: {self._retry_budget}")
        if self._retry_budget <= 0:
            self.exit("retry budget exhausted")
            yield GameEvent.now(type="error", content=validation.get("content", "Invalid action."))
            yield GameEvent.now(type="info", content="You have used all your allowed retries. The game is ending.")
            return
        yield GameEvent.now(type="error", content=validation.get("content", "Invalid action."))
        return

    reply = await self._updater.chat(user_input)
    yield GameEvent.now(type="ai", content=reply)

foresight

Foresight game.

Command

Bases: StrEnum

Game-level slash commands recognized by ForesightGame.

Source code in dcs_simulation_engine/games/foresight.py
23
24
25
26
27
28
class Command(StrEnum):
    """Game-level slash commands recognized by ForesightGame."""

    HELP = "help"
    ABILITIES = "abilities"
    FINISH = "finish"
ForesightGame

Bases: Game

Foresight game: player interacts with NPC and makes predictions embedded in their actions.

Source code in dcs_simulation_engine/games/foresight.py
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
class ForesightGame(Game):
    """Foresight game: player interacts with NPC and makes predictions embedded in their actions."""

    DEFAULT_RETRY_BUDGET = 10
    DEFAULT_MAX_INPUT_LENGTH = 350

    def __init__(
        self,
        pc: CharacterRecord,
        npc: CharacterRecord,
        updater: UpdaterClient,
        validator: ValidatorClient,
        retry_budget: int = DEFAULT_RETRY_BUDGET,
        max_input_length: int = DEFAULT_MAX_INPUT_LENGTH,
    ) -> None:
        """Initialise the game. Use create_from_context() as the public entry point."""
        self._pc = pc
        self._npc = npc
        self._updater = updater
        self._validator = validator
        self._retry_budget = retry_budget
        self._max_input_length = max_input_length
        self._entered = False
        self._exited = False
        self._exit_reason = ""

    @classmethod
    def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "ForesightGame":
        """Factory called by SessionManager. Builds clients from character dicts.

        Accepted kwargs:
            retry_budget (int): overrides DEFAULT_RETRY_BUDGET
            max_input_length (int): overrides DEFAULT_MAX_INPUT_LENGTH
        """
        updater = UpdaterClient(system_prompt=build_updater_prompt(pc, npc, additional_rules=C.ADDITIONAL_UPDATER_RULES))
        validator = ValidatorClient(system_prompt_template=build_validator_prompt(pc, npc, additional_rules=C.ADDITIONAL_VALIDATOR_RULES))
        return cls(
            pc=pc,
            npc=npc,
            updater=updater,
            validator=validator,
            retry_budget=kwargs.get("retry_budget", cls.DEFAULT_RETRY_BUDGET),
            max_input_length=kwargs.get("max_input_length", cls.DEFAULT_MAX_INPUT_LENGTH),
        )

    def exit(self, reason: str) -> None:
        """Mark the game as ended."""
        if self._exited:
            return
        self._exited = True
        self._exit_reason = reason
        logger.info(f"ForesightGame exited: {reason}")

    @property
    def exited(self) -> bool:
        """True if the game has ended."""
        return self._exited

    @property
    def exit_reason(self) -> str:
        """Reason the game ended, or empty string."""
        return self._exit_reason

    def _help_content(self) -> str:
        return C.HELP_CONTENT.format(
            pc_hid=self._pc.hid,
            pc_short_description=self._pc.short_description,
            npc_hid=self._npc.hid,
        )

    async def step(self, user_input: str | None = None) -> AsyncIterator[GameEvent]:
        """Advance the game one turn, yielding one or more GameEvents."""
        if self._exited:
            return

        if not self._entered:
            self._entered = True
            yield GameEvent.now(type="info", content=self._help_content())
            opening = await self._updater.chat(None)
            yield GameEvent.now(type="ai", content=opening)
            return

        if not user_input:
            return

        command_event = self._handle_command(user_input)
        if command_event is not None:
            yield command_event
            return

        if len(user_input) > self._max_input_length:
            yield GameEvent.now(
                type="error",
                content=f"Input exceeds maximum length of {self._max_input_length} characters.",
            )
            return

        validation = await self._validator.validate(user_input)
        if validation.get("type") == "error":
            self._retry_budget -= 1
            logger.debug(f"Validation failed. Retry budget remaining: {self._retry_budget}")
            if self._retry_budget <= 0:
                self.exit("retry budget exhausted")
                yield GameEvent.now(type="error", content=validation.get("content", "Invalid action."))
                yield GameEvent.now(type="info", content="You have used all your allowed retries. The game is ending.")
                return
            yield GameEvent.now(type="error", content=validation.get("content", "Invalid action."))
            return

        reply = await self._updater.chat(user_input)
        yield GameEvent.now(type="ai", content=reply)

    def _handle_command(self, user_input: str) -> GameEvent | None:
        """Return a GameEvent for recognised game-level commands, or None to continue."""
        stripped = user_input.strip()
        if not stripped.startswith("/"):
            return None

        command_body = stripped[1:].strip()
        if not command_body:
            return None
        cmd = command_body.split()[0].lower()

        if cmd == Command.HELP:
            return GameEvent.now(type="info", content=self._help_content(), command_response=True)

        if cmd == Command.ABILITIES:
            return GameEvent.now(
                type="info",
                content=C.ABILITIES_CONTENT.format(
                    pc_hid=self._pc.hid,
                    pc_short_description=self._pc.short_description,
                    pc_abilities=format_abilities_markdown(self._pc.data.get("abilities", "")),
                    npc_hid=self._npc.hid,
                ),
                command_response=True,
            )

        if cmd == Command.FINISH:
            self.exit("player finished")
            return GameEvent.now(
                type="info",
                content=C.FINISH_CONTENT.format(finish_reason="player finished"),
                command_response=True,
            )

        return None
exit_reason property

Reason the game ended, or empty string.

exited property

True if the game has ended.

__init__(pc, npc, updater, validator, retry_budget=DEFAULT_RETRY_BUDGET, max_input_length=DEFAULT_MAX_INPUT_LENGTH)

Initialise the game. Use create_from_context() as the public entry point.

Source code in dcs_simulation_engine/games/foresight.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
def __init__(
    self,
    pc: CharacterRecord,
    npc: CharacterRecord,
    updater: UpdaterClient,
    validator: ValidatorClient,
    retry_budget: int = DEFAULT_RETRY_BUDGET,
    max_input_length: int = DEFAULT_MAX_INPUT_LENGTH,
) -> None:
    """Initialise the game. Use create_from_context() as the public entry point."""
    self._pc = pc
    self._npc = npc
    self._updater = updater
    self._validator = validator
    self._retry_budget = retry_budget
    self._max_input_length = max_input_length
    self._entered = False
    self._exited = False
    self._exit_reason = ""
create_from_context(pc, npc, **kwargs) classmethod

Factory called by SessionManager. Builds clients from character dicts.

Accepted kwargs

retry_budget (int): overrides DEFAULT_RETRY_BUDGET max_input_length (int): overrides DEFAULT_MAX_INPUT_LENGTH

Source code in dcs_simulation_engine/games/foresight.py
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
@classmethod
def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "ForesightGame":
    """Factory called by SessionManager. Builds clients from character dicts.

    Accepted kwargs:
        retry_budget (int): overrides DEFAULT_RETRY_BUDGET
        max_input_length (int): overrides DEFAULT_MAX_INPUT_LENGTH
    """
    updater = UpdaterClient(system_prompt=build_updater_prompt(pc, npc, additional_rules=C.ADDITIONAL_UPDATER_RULES))
    validator = ValidatorClient(system_prompt_template=build_validator_prompt(pc, npc, additional_rules=C.ADDITIONAL_VALIDATOR_RULES))
    return cls(
        pc=pc,
        npc=npc,
        updater=updater,
        validator=validator,
        retry_budget=kwargs.get("retry_budget", cls.DEFAULT_RETRY_BUDGET),
        max_input_length=kwargs.get("max_input_length", cls.DEFAULT_MAX_INPUT_LENGTH),
    )
exit(reason)

Mark the game as ended.

Source code in dcs_simulation_engine/games/foresight.py
76
77
78
79
80
81
82
def exit(self, reason: str) -> None:
    """Mark the game as ended."""
    if self._exited:
        return
    self._exited = True
    self._exit_reason = reason
    logger.info(f"ForesightGame exited: {reason}")
step(user_input=None) async

Advance the game one turn, yielding one or more GameEvents.

Source code in dcs_simulation_engine/games/foresight.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
async def step(self, user_input: str | None = None) -> AsyncIterator[GameEvent]:
    """Advance the game one turn, yielding one or more GameEvents."""
    if self._exited:
        return

    if not self._entered:
        self._entered = True
        yield GameEvent.now(type="info", content=self._help_content())
        opening = await self._updater.chat(None)
        yield GameEvent.now(type="ai", content=opening)
        return

    if not user_input:
        return

    command_event = self._handle_command(user_input)
    if command_event is not None:
        yield command_event
        return

    if len(user_input) > self._max_input_length:
        yield GameEvent.now(
            type="error",
            content=f"Input exceeds maximum length of {self._max_input_length} characters.",
        )
        return

    validation = await self._validator.validate(user_input)
    if validation.get("type") == "error":
        self._retry_budget -= 1
        logger.debug(f"Validation failed. Retry budget remaining: {self._retry_budget}")
        if self._retry_budget <= 0:
            self.exit("retry budget exhausted")
            yield GameEvent.now(type="error", content=validation.get("content", "Invalid action."))
            yield GameEvent.now(type="info", content="You have used all your allowed retries. The game is ending.")
            return
        yield GameEvent.now(type="error", content=validation.get("content", "Invalid action."))
        return

    reply = await self._updater.chat(user_input)
    yield GameEvent.now(type="ai", content=reply)

goal_horizon

Goal Horizon game.

Command

Bases: StrEnum

Game-level slash commands recognised by GoalHorizonGame.

Source code in dcs_simulation_engine/games/goal_horizon.py
23
24
25
26
27
28
class Command(StrEnum):
    """Game-level slash commands recognised by GoalHorizonGame."""

    HELP = "help"
    ABILITIES = "abilities"
    PREDICT_CAPABILITIES = "predict-capabilities"
GoalHorizonGame

Bases: Game

Goal Horizon game: player interacts with NPC across scenes to understand their limits.

Source code in dcs_simulation_engine/games/goal_horizon.py
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
class GoalHorizonGame(Game):
    """Goal Horizon game: player interacts with NPC across scenes to understand their limits."""

    DEFAULT_RETRY_BUDGET = 10
    DEFAULT_MAX_INPUT_LENGTH = 350

    def __init__(
        self,
        pc: CharacterRecord,
        npc: CharacterRecord,
        updater: UpdaterClient,
        validator: ValidatorClient,
        retry_budget: int = DEFAULT_RETRY_BUDGET,
        max_input_length: int = DEFAULT_MAX_INPUT_LENGTH,
    ) -> None:
        """Initialise the game. Use create_from_context() as the public entry point."""
        self._pc = pc
        self._npc = npc
        self._updater = updater
        self._validator = validator
        self._retry_budget = retry_budget
        self._max_input_length = max_input_length
        self._entered = False
        self._exited = False
        self._exit_reason = ""
        self._awaiting_capability_prediction = False
        self._awaiting_capability_confidence = False
        self._capability_prediction = ""
        self._capability_prediction_confidence = ""

    @classmethod
    def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "GoalHorizonGame":
        """Factory called by SessionManager. Builds clients from character dicts.

        Accepted kwargs:
            retry_budget (int): overrides DEFAULT_RETRY_BUDGET
            max_input_length (int): overrides DEFAULT_MAX_INPUT_LENGTH
        """
        updater = UpdaterClient(system_prompt=build_updater_prompt(pc, npc))
        validator = ValidatorClient(system_prompt_template=build_validator_prompt(pc, npc))
        return cls(
            pc=pc,
            npc=npc,
            updater=updater,
            validator=validator,
            retry_budget=kwargs.get("retry_budget", cls.DEFAULT_RETRY_BUDGET),
            max_input_length=kwargs.get("max_input_length", cls.DEFAULT_MAX_INPUT_LENGTH),
        )

    def exit(self, reason: str) -> None:
        """Mark the game as ended."""
        if self._exited:
            return
        self._exited = True
        self._exit_reason = reason
        logger.info(f"GoalHorizonGame exited: {reason}")

    @property
    def exited(self) -> bool:
        """True if the game has ended."""
        return self._exited

    @property
    def exit_reason(self) -> str:
        """Reason the game ended, or empty string."""
        return self._exit_reason

    @property
    def capability_prediction(self) -> str:
        """Player's inferred capability limits, or empty string."""
        return self._capability_prediction

    @property
    def capability_prediction_confidence(self) -> str:
        """Player's confidence in their capability prediction, or empty string."""
        return self._capability_prediction_confidence

    async def step(self, user_input: str | None = None) -> AsyncIterator[GameEvent]:
        """Advance the game one turn, yielding one or more GameEvents."""
        if self._exited:
            return

        if not self._entered:
            self._entered = True
            yield GameEvent.now(
                type="info",
                content=C.ENTER_CONTENT.format(
                    pc_hid=self._pc.hid,
                    pc_short_description=self._pc.short_description,
                    npc_hid=self._npc.hid,
                    npc_short_description=self._npc.short_description,
                ),
            )
            opening = await self._updater.chat(None)
            yield GameEvent.now(type="ai", content=opening)
            return

        if not user_input:
            return

        if self._awaiting_capability_prediction:
            self._capability_prediction = user_input
            self._awaiting_capability_prediction = False
            self._awaiting_capability_confidence = True
            yield GameEvent.now(type="info", content=C.CAPABILITY_PREDICTION_CONFIDENCE)
            return

        if self._awaiting_capability_confidence:
            self._capability_prediction_confidence = user_input
            self._awaiting_capability_confidence = False
            self.exit("player finished")
            yield GameEvent.now(type="info", content=C.FINISH_CONTENT.format(finish_reason="player finished"))
            return

        command_event = self._handle_command(user_input)
        if command_event is not None:
            yield command_event
            return

        if len(user_input) > self._max_input_length:
            yield GameEvent.now(
                type="error",
                content=f"Input exceeds maximum length of {self._max_input_length} characters.",
            )
            return

        validation = await self._validator.validate(user_input)
        if validation.get("type") == "error":
            self._retry_budget -= 1
            logger.debug(f"Validation failed. Retry budget remaining: {self._retry_budget}")
            if self._retry_budget <= 0:
                self.exit("retry budget exhausted")
                yield GameEvent.now(type="error", content=validation.get("content", "Invalid action."))
                yield GameEvent.now(type="info", content="You have used all your allowed retries. The game is ending.")
                return
            yield GameEvent.now(type="error", content=validation.get("content", "Invalid action."))
            return

        reply = await self._updater.chat(user_input)
        yield GameEvent.now(type="ai", content=reply)

    def _handle_command(self, user_input: str) -> GameEvent | None:
        """Return a GameEvent for recognised game-level commands, or None to continue."""
        stripped = user_input.strip()
        if not stripped.startswith("/"):
            return None

        command_body = stripped[1:].strip()
        if not command_body:
            return None
        parts = command_body.split(maxsplit=1)
        cmd = parts[0].lower()

        if cmd == Command.HELP:
            return GameEvent.now(
                type="info",
                content=C.HELP_CONTENT.format(
                    pc_hid=self._pc.hid,
                    pc_short_description=self._pc.short_description,
                    npc_hid=self._npc.hid,
                ),
                command_response=True,
            )

        if cmd == Command.ABILITIES:
            return GameEvent.now(
                type="info",
                content=C.ABILITIES_CONTENT.format(
                    pc_hid=self._pc.hid,
                    pc_short_description=self._pc.short_description,
                    pc_abilities=format_abilities_markdown(self._pc.data.get("abilities", "")),
                    npc_hid=self._npc.hid,
                    npc_short_description=self._npc.short_description,
                    npc_abilities=format_abilities_markdown(self._npc.data.get("abilities", "")),
                ),
                command_response=True,
            )

        if cmd == Command.PREDICT_CAPABILITIES:
            self._awaiting_capability_prediction = True
            return GameEvent.now(
                type="info",
                content=C.CAPABILITY_PREDICTION_QUESTION.format(npc_hid=self._npc.hid),
                command_response=True,
            )

        return None
capability_prediction property

Player's inferred capability limits, or empty string.

capability_prediction_confidence property

Player's confidence in their capability prediction, or empty string.

exit_reason property

Reason the game ended, or empty string.

exited property

True if the game has ended.

__init__(pc, npc, updater, validator, retry_budget=DEFAULT_RETRY_BUDGET, max_input_length=DEFAULT_MAX_INPUT_LENGTH)

Initialise the game. Use create_from_context() as the public entry point.

Source code in dcs_simulation_engine/games/goal_horizon.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def __init__(
    self,
    pc: CharacterRecord,
    npc: CharacterRecord,
    updater: UpdaterClient,
    validator: ValidatorClient,
    retry_budget: int = DEFAULT_RETRY_BUDGET,
    max_input_length: int = DEFAULT_MAX_INPUT_LENGTH,
) -> None:
    """Initialise the game. Use create_from_context() as the public entry point."""
    self._pc = pc
    self._npc = npc
    self._updater = updater
    self._validator = validator
    self._retry_budget = retry_budget
    self._max_input_length = max_input_length
    self._entered = False
    self._exited = False
    self._exit_reason = ""
    self._awaiting_capability_prediction = False
    self._awaiting_capability_confidence = False
    self._capability_prediction = ""
    self._capability_prediction_confidence = ""
create_from_context(pc, npc, **kwargs) classmethod

Factory called by SessionManager. Builds clients from character dicts.

Accepted kwargs

retry_budget (int): overrides DEFAULT_RETRY_BUDGET max_input_length (int): overrides DEFAULT_MAX_INPUT_LENGTH

Source code in dcs_simulation_engine/games/goal_horizon.py
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
@classmethod
def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "GoalHorizonGame":
    """Factory called by SessionManager. Builds clients from character dicts.

    Accepted kwargs:
        retry_budget (int): overrides DEFAULT_RETRY_BUDGET
        max_input_length (int): overrides DEFAULT_MAX_INPUT_LENGTH
    """
    updater = UpdaterClient(system_prompt=build_updater_prompt(pc, npc))
    validator = ValidatorClient(system_prompt_template=build_validator_prompt(pc, npc))
    return cls(
        pc=pc,
        npc=npc,
        updater=updater,
        validator=validator,
        retry_budget=kwargs.get("retry_budget", cls.DEFAULT_RETRY_BUDGET),
        max_input_length=kwargs.get("max_input_length", cls.DEFAULT_MAX_INPUT_LENGTH),
    )
exit(reason)

Mark the game as ended.

Source code in dcs_simulation_engine/games/goal_horizon.py
80
81
82
83
84
85
86
def exit(self, reason: str) -> None:
    """Mark the game as ended."""
    if self._exited:
        return
    self._exited = True
    self._exit_reason = reason
    logger.info(f"GoalHorizonGame exited: {reason}")
step(user_input=None) async

Advance the game one turn, yielding one or more GameEvents.

Source code in dcs_simulation_engine/games/goal_horizon.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
async def step(self, user_input: str | None = None) -> AsyncIterator[GameEvent]:
    """Advance the game one turn, yielding one or more GameEvents."""
    if self._exited:
        return

    if not self._entered:
        self._entered = True
        yield GameEvent.now(
            type="info",
            content=C.ENTER_CONTENT.format(
                pc_hid=self._pc.hid,
                pc_short_description=self._pc.short_description,
                npc_hid=self._npc.hid,
                npc_short_description=self._npc.short_description,
            ),
        )
        opening = await self._updater.chat(None)
        yield GameEvent.now(type="ai", content=opening)
        return

    if not user_input:
        return

    if self._awaiting_capability_prediction:
        self._capability_prediction = user_input
        self._awaiting_capability_prediction = False
        self._awaiting_capability_confidence = True
        yield GameEvent.now(type="info", content=C.CAPABILITY_PREDICTION_CONFIDENCE)
        return

    if self._awaiting_capability_confidence:
        self._capability_prediction_confidence = user_input
        self._awaiting_capability_confidence = False
        self.exit("player finished")
        yield GameEvent.now(type="info", content=C.FINISH_CONTENT.format(finish_reason="player finished"))
        return

    command_event = self._handle_command(user_input)
    if command_event is not None:
        yield command_event
        return

    if len(user_input) > self._max_input_length:
        yield GameEvent.now(
            type="error",
            content=f"Input exceeds maximum length of {self._max_input_length} characters.",
        )
        return

    validation = await self._validator.validate(user_input)
    if validation.get("type") == "error":
        self._retry_budget -= 1
        logger.debug(f"Validation failed. Retry budget remaining: {self._retry_budget}")
        if self._retry_budget <= 0:
            self.exit("retry budget exhausted")
            yield GameEvent.now(type="error", content=validation.get("content", "Invalid action."))
            yield GameEvent.now(type="info", content="You have used all your allowed retries. The game is ending.")
            return
        yield GameEvent.now(type="error", content=validation.get("content", "Invalid action."))
        return

    reply = await self._updater.chat(user_input)
    yield GameEvent.now(type="ai", content=reply)

infer_intent

Infer Intent game.

Command

Bases: StrEnum

Game-level slash commands recognised by InferIntentGame.

Source code in dcs_simulation_engine/games/infer_intent.py
23
24
25
26
27
28
class Command(StrEnum):
    """Game-level slash commands recognised by InferIntentGame."""

    HELP = "help"
    ABILITIES = "abilities"
    PREDICT_INTENT = "predict-intent"
InferIntentGame

Bases: Game

Infer Intent game: player interacts with NPC and infers their hidden goal.

Source code in dcs_simulation_engine/games/infer_intent.py
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
class InferIntentGame(Game):
    """Infer Intent game: player interacts with NPC and infers their hidden goal."""

    DEFAULT_RETRY_BUDGET = 3
    DEFAULT_MAX_INPUT_LENGTH = 350

    def __init__(
        self,
        pc: CharacterRecord,
        npc: CharacterRecord,
        updater: UpdaterClient,
        validator: ValidatorClient,
        retry_budget: int = DEFAULT_RETRY_BUDGET,
        max_input_length: int = DEFAULT_MAX_INPUT_LENGTH,
    ) -> None:
        """Initialise the game. Use create_from_context() as the public entry point."""
        self._pc = pc
        self._npc = npc
        self._updater = updater
        self._validator = validator
        self._retry_budget = retry_budget
        self._max_input_length = max_input_length
        self._entered = False
        self._exited = False
        self._exit_reason = ""

        self._awaiting_goal_inference = False
        self._awaiting_goal_inference_confidence = False
        self._goal_inference = ""
        self._goal_inference_confidence = ""
        self._evaluation: dict[str, Any] = {}

    @classmethod
    def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "InferIntentGame":
        """Factory called by SessionManager. Builds clients from character dicts.

        Accepted kwargs:
            retry_budget (int): overrides DEFAULT_RETRY_BUDGET
            max_input_length (int): overrides DEFAULT_MAX_INPUT_LENGTH
        """
        updater = UpdaterClient(system_prompt=build_updater_prompt(pc, npc, additional_rules=C.ADDITIONAL_UPDATER_RULES))
        validator = ValidatorClient(system_prompt_template=build_validator_prompt(pc, npc))
        return cls(
            pc=pc,
            npc=npc,
            updater=updater,
            validator=validator,
            retry_budget=kwargs.get("retry_budget", cls.DEFAULT_RETRY_BUDGET),
            max_input_length=kwargs.get("max_input_length", cls.DEFAULT_MAX_INPUT_LENGTH),
        )

    def exit(self, reason: str) -> None:
        """Mark the game as ended."""
        if self._exited:
            return
        self._exited = True
        self._exit_reason = reason
        logger.info(f"InferIntentGame exited: {reason}")

    @property
    def exited(self) -> bool:
        """True if the game has ended."""
        return self._exited

    @property
    def exit_reason(self) -> str:
        """Reason the game ended, or empty string."""
        return self._exit_reason

    @property
    def goal_inference(self) -> str:
        """Player's goal inference, or empty string."""
        return self._goal_inference

    @property
    def goal_inference_confidence(self) -> str:
        """Player's confidence in their goal inference, or empty string."""
        return self._goal_inference_confidence

    @property
    def evaluation(self) -> dict[str, Any]:
        """LLM scoring result, or empty dict."""
        return self._evaluation

    async def step(self, user_input: str | None = None) -> AsyncIterator[GameEvent]:
        """Advance the game one turn, yielding one or more GameEvents."""
        if self._exited:
            return

        if not self._entered:
            self._entered = True
            yield GameEvent.now(
                type="info",
                content=C.HELP_CONTENT.format(
                    pc_hid=self._pc.hid,
                    pc_short_description=self._pc.short_description,
                    npc_hid=self._npc.hid,
                ),
            )
            opening = await self._updater.chat(None)
            yield GameEvent.now(type="ai", content=opening)
            return

        if not user_input:
            return

        if self._awaiting_goal_inference:
            self._goal_inference = user_input
            self._awaiting_goal_inference = False
            self._awaiting_goal_inference_confidence = True
            yield GameEvent.now(type="info", content=C.GOAL_INFERENCE_CONFIDENCE)
            return

        if self._awaiting_goal_inference_confidence:
            self._goal_inference_confidence = user_input
            self._awaiting_goal_inference_confidence = False
            self.exit("player finished")
            yield GameEvent.now(type="info", content=C.FINISH_CONTENT.format(finish_reason="player finished"))

            return

        command_event = self._handle_command(user_input)
        if command_event is not None:
            yield command_event
            return

        if len(user_input) > self._max_input_length:
            yield GameEvent.now(
                type="error",
                content=f"Input exceeds maximum length of {self._max_input_length} characters.",
            )
            return

        validation = await self._validator.validate(user_input)
        if validation.get("type") == "error":
            self._retry_budget -= 1
            logger.debug(f"Validation failed. Retry budget remaining: {self._retry_budget}")
            if self._retry_budget <= 0:
                self.exit("retry budget exhausted")
                yield GameEvent.now(type="error", content=validation.get("content", "Invalid action."))
                yield GameEvent.now(type="info", content="You have used all your allowed retries. The game is closing.")
                return
            yield GameEvent.now(type="error", content=validation.get("content", "Invalid action."))
            return

        reply = await self._updater.chat(user_input)
        yield GameEvent.now(type="ai", content=reply)

    def _handle_command(self, user_input: str) -> GameEvent | None:
        """Return a GameEvent for recognised game-level commands, or None to continue."""
        stripped = user_input.strip()
        if not stripped.startswith("/"):
            return None

        command_body = stripped[1:].strip()
        if not command_body:
            return None
        cmd = command_body.split()[0].lower()

        if cmd == Command.HELP:
            return GameEvent.now(
                type="info",
                content=C.HELP_CONTENT.format(
                    pc_hid=self._pc.hid,
                    pc_short_description=self._pc.short_description,
                    npc_hid=self._npc.hid,
                ),
                command_response=True,
            )

        if cmd == Command.ABILITIES:
            return GameEvent.now(
                type="info",
                content=C.ABILITIES_CONTENT.format(
                    pc_hid=self._pc.hid,
                    pc_short_description=self._pc.short_description,
                    pc_abilities=format_abilities_markdown(self._pc.data.get("abilities", "")),
                    npc_hid=self._npc.hid,
                ),
                command_response=True,
            )

        if cmd == Command.PREDICT_INTENT:
            self._awaiting_goal_inference = True
            return GameEvent.now(type="info", content=C.GOAL_INFERENCE_QUESTION, command_response=True)

        return None
evaluation property

LLM scoring result, or empty dict.

exit_reason property

Reason the game ended, or empty string.

exited property

True if the game has ended.

goal_inference property

Player's goal inference, or empty string.

goal_inference_confidence property

Player's confidence in their goal inference, or empty string.

__init__(pc, npc, updater, validator, retry_budget=DEFAULT_RETRY_BUDGET, max_input_length=DEFAULT_MAX_INPUT_LENGTH)

Initialise the game. Use create_from_context() as the public entry point.

Source code in dcs_simulation_engine/games/infer_intent.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
def __init__(
    self,
    pc: CharacterRecord,
    npc: CharacterRecord,
    updater: UpdaterClient,
    validator: ValidatorClient,
    retry_budget: int = DEFAULT_RETRY_BUDGET,
    max_input_length: int = DEFAULT_MAX_INPUT_LENGTH,
) -> None:
    """Initialise the game. Use create_from_context() as the public entry point."""
    self._pc = pc
    self._npc = npc
    self._updater = updater
    self._validator = validator
    self._retry_budget = retry_budget
    self._max_input_length = max_input_length
    self._entered = False
    self._exited = False
    self._exit_reason = ""

    self._awaiting_goal_inference = False
    self._awaiting_goal_inference_confidence = False
    self._goal_inference = ""
    self._goal_inference_confidence = ""
    self._evaluation: dict[str, Any] = {}
create_from_context(pc, npc, **kwargs) classmethod

Factory called by SessionManager. Builds clients from character dicts.

Accepted kwargs

retry_budget (int): overrides DEFAULT_RETRY_BUDGET max_input_length (int): overrides DEFAULT_MAX_INPUT_LENGTH

Source code in dcs_simulation_engine/games/infer_intent.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
@classmethod
def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "InferIntentGame":
    """Factory called by SessionManager. Builds clients from character dicts.

    Accepted kwargs:
        retry_budget (int): overrides DEFAULT_RETRY_BUDGET
        max_input_length (int): overrides DEFAULT_MAX_INPUT_LENGTH
    """
    updater = UpdaterClient(system_prompt=build_updater_prompt(pc, npc, additional_rules=C.ADDITIONAL_UPDATER_RULES))
    validator = ValidatorClient(system_prompt_template=build_validator_prompt(pc, npc))
    return cls(
        pc=pc,
        npc=npc,
        updater=updater,
        validator=validator,
        retry_budget=kwargs.get("retry_budget", cls.DEFAULT_RETRY_BUDGET),
        max_input_length=kwargs.get("max_input_length", cls.DEFAULT_MAX_INPUT_LENGTH),
    )
exit(reason)

Mark the game as ended.

Source code in dcs_simulation_engine/games/infer_intent.py
82
83
84
85
86
87
88
def exit(self, reason: str) -> None:
    """Mark the game as ended."""
    if self._exited:
        return
    self._exited = True
    self._exit_reason = reason
    logger.info(f"InferIntentGame exited: {reason}")
step(user_input=None) async

Advance the game one turn, yielding one or more GameEvents.

Source code in dcs_simulation_engine/games/infer_intent.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
async def step(self, user_input: str | None = None) -> AsyncIterator[GameEvent]:
    """Advance the game one turn, yielding one or more GameEvents."""
    if self._exited:
        return

    if not self._entered:
        self._entered = True
        yield GameEvent.now(
            type="info",
            content=C.HELP_CONTENT.format(
                pc_hid=self._pc.hid,
                pc_short_description=self._pc.short_description,
                npc_hid=self._npc.hid,
            ),
        )
        opening = await self._updater.chat(None)
        yield GameEvent.now(type="ai", content=opening)
        return

    if not user_input:
        return

    if self._awaiting_goal_inference:
        self._goal_inference = user_input
        self._awaiting_goal_inference = False
        self._awaiting_goal_inference_confidence = True
        yield GameEvent.now(type="info", content=C.GOAL_INFERENCE_CONFIDENCE)
        return

    if self._awaiting_goal_inference_confidence:
        self._goal_inference_confidence = user_input
        self._awaiting_goal_inference_confidence = False
        self.exit("player finished")
        yield GameEvent.now(type="info", content=C.FINISH_CONTENT.format(finish_reason="player finished"))

        return

    command_event = self._handle_command(user_input)
    if command_event is not None:
        yield command_event
        return

    if len(user_input) > self._max_input_length:
        yield GameEvent.now(
            type="error",
            content=f"Input exceeds maximum length of {self._max_input_length} characters.",
        )
        return

    validation = await self._validator.validate(user_input)
    if validation.get("type") == "error":
        self._retry_budget -= 1
        logger.debug(f"Validation failed. Retry budget remaining: {self._retry_budget}")
        if self._retry_budget <= 0:
            self.exit("retry budget exhausted")
            yield GameEvent.now(type="error", content=validation.get("content", "Invalid action."))
            yield GameEvent.now(type="info", content="You have used all your allowed retries. The game is closing.")
            return
        yield GameEvent.now(type="error", content=validation.get("content", "Invalid action."))
        return

    reply = await self._updater.chat(user_input)
    yield GameEvent.now(type="ai", content=reply)

markdown_helpers

Helpers for rendering game content as markdown.

format_abilities_markdown(abilities, *, section_heading_level=3)

Render a character's ability payload as readable markdown.

Source code in dcs_simulation_engine/games/markdown_helpers.py
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def format_abilities_markdown(abilities: Any, *, section_heading_level: int = 3) -> str:
    """Render a character's ability payload as readable markdown."""
    heading_prefix = "#" * max(1, min(section_heading_level, 6))

    if isinstance(abilities, str):
        return abilities

    if isinstance(abilities, list):
        return "\n".join(f"- {str(item).strip()}" for item in abilities if str(item).strip())

    if isinstance(abilities, dict):
        sections: list[str] = []
        for section_name, section_items in abilities.items():
            heading = f"{heading_prefix} {section_name}"
            if isinstance(section_items, list):
                bullets = "\n".join(f"- {str(item).strip()}" for item in section_items if str(item).strip())
                sections.append(f"{heading}\n{bullets}" if bullets else heading)
                continue

            text = str(section_items).strip()
            sections.append(f"{heading}\n{text}" if text else heading)
        return "\n\n".join(section for section in sections if section.strip())

    return str(abilities)

prompts

Shared system prompt templates and builders for new-style games.

Templates use Jinja2 (consistent with the rest of the engine). Character data containing literal braces is safe because Jinja2 only expands {{ var }} syntax, not arbitrary brace sequences.

build_updater_prompt(pc, npc, additional_rules='')

Render the updater system prompt from PC/NPC character records.

Source code in dcs_simulation_engine/games/prompts.py
101
102
103
104
105
106
107
108
109
110
111
def build_updater_prompt(pc: CharacterRecord, npc: CharacterRecord, additional_rules: str = "") -> str:
    """Render the updater system prompt from PC/NPC character records."""
    return _jinja_env.from_string(UPDATER_SYSTEM_TEMPLATE).render(
        pc_hid=pc.hid,
        pc_short_description=pc.short_description,
        npc_hid=npc.hid,
        npc_short_description=npc.short_description,
        npc_long_description=npc.data.get("long_description", ""),
        npc_abilities=npc.data.get("abilities", ""),
        additional_updater_rules=additional_rules,
    )
build_validator_prompt(pc, npc, additional_rules='')

Return the validator system prompt template string.

The returned string still contains a {{ user_input }} Jinja2 placeholder that ValidatorClient renders per-call. pc_abilities and additional_rules are pre-rendered here; user_input is left as a literal template variable so ValidatorClient can fill it in safely without brace-collision issues.

Source code in dcs_simulation_engine/games/prompts.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
def build_validator_prompt(pc: CharacterRecord, npc: CharacterRecord, additional_rules: str = "") -> str:  # noqa: ARG001
    """Return the validator system prompt template string.

    The returned string still contains a {{ user_input }} Jinja2 placeholder
    that ValidatorClient renders per-call. pc_abilities and additional_rules
    are pre-rendered here; user_input is left as a literal template variable
    so ValidatorClient can fill it in safely without brace-collision issues.
    """
    # Pre-render everything except user_input. We do this by rendering the
    # template with a sentinel for user_input that Jinja2 will leave alone,
    # then return the partially-rendered template for per-call completion.
    # Since Jinja2 variables are not brace-based, character data with literal
    # { } characters is passed through safely.
    partial = _jinja_env.from_string(_VALIDATOR_SYSTEM_TEMPLATE).render(
        pc_abilities=pc.data.get("abilities", ""),
        additional_validator_rules=additional_rules,
        # Pass user_input as a Jinja2 expression that re-emits itself so the
        # returned string still has a {{ user_input }} token for ValidatorClient.
        user_input="{{ user_input }}",
    )
    # Jinja2 auto-escapes nothing in SandboxedEnvironment with default settings,
    # so the literal string "{{ user_input }}" is inserted as-is. Return it as
    # a new template string for ValidatorClient to render per-call.
    return partial

teamwork

Teamwork game.

Command

Bases: StrEnum

Game-level slash commands recognised by TeamworkGame.

Source code in dcs_simulation_engine/games/teamwork.py
21
22
23
24
25
26
class Command(StrEnum):
    """Game-level slash commands recognised by TeamworkGame."""

    HELP = "help"
    ABILITIES = "abilities"
    FINISH = "finish"
TeamworkGame

Bases: Game

Teamwork game: player collaborates with NPC toward a shared goal.

Source code in dcs_simulation_engine/games/teamwork.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
class TeamworkGame(Game):
    """Teamwork game: player collaborates with NPC toward a shared goal."""

    DEFAULT_RETRY_BUDGET = 10
    DEFAULT_MAX_INPUT_LENGTH = 350

    def __init__(
        self,
        pc: CharacterRecord,
        npc: CharacterRecord,
        updater: UpdaterClient,
        validator: ValidatorClient,
        retry_budget: int = DEFAULT_RETRY_BUDGET,
        max_input_length: int = DEFAULT_MAX_INPUT_LENGTH,
    ) -> None:
        """Initialise the game. Use create_from_context() as the public entry point."""
        self._pc = pc
        self._npc = npc
        self._updater = updater
        self._validator = validator
        self._retry_budget = retry_budget
        self._max_input_length = max_input_length
        self._entered = False
        self._exited = False
        self._exit_reason = ""
        self._awaiting_challenges = False
        self._challenges = ""

    @classmethod
    def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "TeamworkGame":
        """Factory called by SessionManager. Builds clients from character dicts.

        Accepted kwargs:
            retry_budget (int): overrides DEFAULT_RETRY_BUDGET
            max_input_length (int): overrides DEFAULT_MAX_INPUT_LENGTH
        """
        updater = UpdaterClient(system_prompt=build_updater_prompt(pc, npc, additional_rules=C.ADDITIONAL_UPDATER_RULES))
        validator = ValidatorClient(system_prompt_template=build_validator_prompt(pc, npc))
        return cls(
            pc=pc,
            npc=npc,
            updater=updater,
            validator=validator,
            retry_budget=kwargs.get("retry_budget", cls.DEFAULT_RETRY_BUDGET),
            max_input_length=kwargs.get("max_input_length", cls.DEFAULT_MAX_INPUT_LENGTH),
        )

    def exit(self, reason: str) -> None:
        """Mark the game as ended."""
        if self._exited:
            return
        self._exited = True
        self._exit_reason = reason
        logger.info(f"TeamworkGame exited: {reason}")

    @property
    def exited(self) -> bool:
        """True if the game has ended."""
        return self._exited

    @property
    def exit_reason(self) -> str:
        """Reason the game ended, or empty string."""
        return self._exit_reason

    @property
    def challenges(self) -> str:
        """Player's reflection on challenges, or empty string."""
        return self._challenges

    async def step(self, user_input: str | None = None) -> AsyncIterator[GameEvent]:
        """Advance the game one turn, yielding one or more GameEvents."""
        if self._exited:
            return

        if not self._entered:
            self._entered = True
            yield GameEvent.now(
                type="info",
                content=C.HELP_CONTENT.format(
                    pc_hid=self._pc.hid,
                    pc_short_description=self._pc.short_description,
                    npc_hid=self._npc.hid,
                ),
            )
            opening = await self._updater.chat(None)
            yield GameEvent.now(type="ai", content=opening)
            return

        if not user_input:
            return

        if self._awaiting_challenges:
            self._challenges = user_input
            self._awaiting_challenges = False
            self.exit("player finished")
            yield GameEvent.now(type="info", content=C.FINISH_CONTENT.format(finish_reason="player finished"))

            return

        command_event = self._handle_command(user_input)
        if command_event is not None:
            yield command_event
            return

        if len(user_input) > self._max_input_length:
            yield GameEvent.now(
                type="error",
                content=f"Input exceeds maximum length of {self._max_input_length} characters.",
            )
            return

        validation = await self._validator.validate(user_input)
        if validation.get("type") == "error":
            self._retry_budget -= 1
            logger.debug(f"Validation failed. Retry budget remaining: {self._retry_budget}")
            if self._retry_budget <= 0:
                self.exit("retry budget exhausted")
                yield GameEvent.now(type="error", content=validation.get("content", "Invalid action."))
                yield GameEvent.now(type="info", content="You have used all your allowed retries. The game is ending.")
                return
            yield GameEvent.now(type="error", content=validation.get("content", "Invalid action."))
            return

        reply = await self._updater.chat(user_input)
        yield GameEvent.now(type="ai", content=reply)

    def _handle_command(self, user_input: str) -> GameEvent | None:
        """Return a GameEvent for recognised game-level commands, or None to continue."""
        stripped = user_input.strip()
        if not stripped.startswith("/"):
            return None

        command_body = stripped[1:].strip()
        if not command_body:
            return None
        cmd = command_body.split()[0].lower()

        if cmd == Command.HELP:
            return GameEvent.now(
                type="info",
                content=C.HELP_CONTENT.format(
                    pc_hid=self._pc.hid,
                    pc_short_description=self._pc.short_description,
                    npc_hid=self._npc.hid,
                ),
                command_response=True,
            )

        if cmd == Command.ABILITIES:
            return GameEvent.now(
                type="info",
                content=C.ABILITIES_CONTENT.format(
                    pc_hid=self._pc.hid,
                    pc_short_description=self._pc.short_description,
                    pc_abilities=format_abilities_markdown(self._pc.data.get("abilities", "")),
                    npc_hid=self._npc.hid,
                ),
                command_response=True,
            )

        if cmd == Command.FINISH:
            self._awaiting_challenges = True
            return GameEvent.now(type="info", content=C.CHALLENGES_QUESTION, command_response=True)

        return None
challenges property

Player's reflection on challenges, or empty string.

exit_reason property

Reason the game ended, or empty string.

exited property

True if the game has ended.

__init__(pc, npc, updater, validator, retry_budget=DEFAULT_RETRY_BUDGET, max_input_length=DEFAULT_MAX_INPUT_LENGTH)

Initialise the game. Use create_from_context() as the public entry point.

Source code in dcs_simulation_engine/games/teamwork.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
def __init__(
    self,
    pc: CharacterRecord,
    npc: CharacterRecord,
    updater: UpdaterClient,
    validator: ValidatorClient,
    retry_budget: int = DEFAULT_RETRY_BUDGET,
    max_input_length: int = DEFAULT_MAX_INPUT_LENGTH,
) -> None:
    """Initialise the game. Use create_from_context() as the public entry point."""
    self._pc = pc
    self._npc = npc
    self._updater = updater
    self._validator = validator
    self._retry_budget = retry_budget
    self._max_input_length = max_input_length
    self._entered = False
    self._exited = False
    self._exit_reason = ""
    self._awaiting_challenges = False
    self._challenges = ""
create_from_context(pc, npc, **kwargs) classmethod

Factory called by SessionManager. Builds clients from character dicts.

Accepted kwargs

retry_budget (int): overrides DEFAULT_RETRY_BUDGET max_input_length (int): overrides DEFAULT_MAX_INPUT_LENGTH

Source code in dcs_simulation_engine/games/teamwork.py
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
@classmethod
def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "TeamworkGame":
    """Factory called by SessionManager. Builds clients from character dicts.

    Accepted kwargs:
        retry_budget (int): overrides DEFAULT_RETRY_BUDGET
        max_input_length (int): overrides DEFAULT_MAX_INPUT_LENGTH
    """
    updater = UpdaterClient(system_prompt=build_updater_prompt(pc, npc, additional_rules=C.ADDITIONAL_UPDATER_RULES))
    validator = ValidatorClient(system_prompt_template=build_validator_prompt(pc, npc))
    return cls(
        pc=pc,
        npc=npc,
        updater=updater,
        validator=validator,
        retry_budget=kwargs.get("retry_budget", cls.DEFAULT_RETRY_BUDGET),
        max_input_length=kwargs.get("max_input_length", cls.DEFAULT_MAX_INPUT_LENGTH),
    )
exit(reason)

Mark the game as ended.

Source code in dcs_simulation_engine/games/teamwork.py
76
77
78
79
80
81
82
def exit(self, reason: str) -> None:
    """Mark the game as ended."""
    if self._exited:
        return
    self._exited = True
    self._exit_reason = reason
    logger.info(f"TeamworkGame exited: {reason}")
step(user_input=None) async

Advance the game one turn, yielding one or more GameEvents.

Source code in dcs_simulation_engine/games/teamwork.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
async def step(self, user_input: str | None = None) -> AsyncIterator[GameEvent]:
    """Advance the game one turn, yielding one or more GameEvents."""
    if self._exited:
        return

    if not self._entered:
        self._entered = True
        yield GameEvent.now(
            type="info",
            content=C.HELP_CONTENT.format(
                pc_hid=self._pc.hid,
                pc_short_description=self._pc.short_description,
                npc_hid=self._npc.hid,
            ),
        )
        opening = await self._updater.chat(None)
        yield GameEvent.now(type="ai", content=opening)
        return

    if not user_input:
        return

    if self._awaiting_challenges:
        self._challenges = user_input
        self._awaiting_challenges = False
        self.exit("player finished")
        yield GameEvent.now(type="info", content=C.FINISH_CONTENT.format(finish_reason="player finished"))

        return

    command_event = self._handle_command(user_input)
    if command_event is not None:
        yield command_event
        return

    if len(user_input) > self._max_input_length:
        yield GameEvent.now(
            type="error",
            content=f"Input exceeds maximum length of {self._max_input_length} characters.",
        )
        return

    validation = await self._validator.validate(user_input)
    if validation.get("type") == "error":
        self._retry_budget -= 1
        logger.debug(f"Validation failed. Retry budget remaining: {self._retry_budget}")
        if self._retry_budget <= 0:
            self.exit("retry budget exhausted")
            yield GameEvent.now(type="error", content=validation.get("content", "Invalid action."))
            yield GameEvent.now(type="info", content="You have used all your allowed retries. The game is ending.")
            return
        yield GameEvent.now(type="error", content=validation.get("content", "Invalid action."))
        return

    reply = await self._updater.chat(user_input)
    yield GameEvent.now(type="ai", content=reply)

helpers

Helpers: domain-aware convenience.

Contents should: - Know about the app's domain or feature - Wraps multiple steps into a higher-level action - Is opinionated about data shape, formatting, or behavior - Might change if the business logic changes

experiment_helpers

Helpers for experiment config discovery.

get_experiment_config(experiment)

Return the path to an experiment YAML config by name or explicit file path.

Source code in dcs_simulation_engine/helpers/experiment_helpers.py
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def get_experiment_config(experiment: str) -> str:
    """Return the path to an experiment YAML config by name or explicit file path."""
    possible_path = Path(experiment).expanduser()
    if possible_path.is_file() and possible_path.suffix.lower() in {".yaml", ".yml"}:
        return str(possible_path)

    experiments_dir = Path(__file__).resolve().parents[2] / "experiments"
    if not experiments_dir.exists():
        raise FileNotFoundError(f"Experiments directory not found: {experiments_dir}")

    for path in experiments_dir.glob("*.y*ml"):
        if path.stem.lower() == experiment.strip().lower():
            return str(path)

    deployments_dir = Path(__file__).resolve().parents[2] / "deployments"
    if deployments_dir.exists():
        for path in deployments_dir.glob("*/experiments/*.y*ml"):
            if path.stem.lower() == experiment.strip().lower():
                return str(path)

    raise FileNotFoundError(f"No experiment config matching {experiment!r} found in {experiments_dir}")

game_helpers

Helpers for games.

create_game_from_template(name, template=None)

Copy a game into ./games from a template game file.

Source code in dcs_simulation_engine/helpers/game_helpers.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
def create_game_from_template(name: str, template: str | Path | None = None) -> Path:
    """Copy a game into ./games from a template game file."""
    games_dir = Path.cwd() / "games"
    games_dir.mkdir(parents=True, exist_ok=True)

    dest = games_dir / f"{name}.yaml"

    if dest.exists():
        raise FileExistsError(f"{dest} already exists.")

    if template is None:
        template_path = Path(get_game_config("Explore"))
    else:
        t = Path(template).expanduser()
        template_path = t if t.is_file() else Path(get_game_config(str(template)))

    dest.write_text(
        template_path.read_text(encoding="utf-8"),
        encoding="utf-8",
    )

    logger.info("Copied game template %s -> %s", template_path, dest)
    return dest
get_game_config(game, version='latest')

Return the path to a YAML game config.

Accepts either
  • A game name (matched against built-in configs in games/)
  • A filesystem path to a custom YAML config

The optional version argument controls which config is selected when multiple configs share the same name field:

  • "latest" (default): pick the latest stable release by the version field in the YAML (using PEP 440 / semantic version ordering and preferring non-prerelease, non-dev versions).
  • Any other string: pick the config whose version field exactly matches that string.

If version is not "latest" and no matching version is found, a FileNotFoundError is raised listing available versions.

Source code in dcs_simulation_engine/helpers/game_helpers.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
def get_game_config(game: str, version: str = "latest") -> str:
    """Return the path to a YAML game config.

    Accepts either:
      - A game name (matched against built-in configs in games/)
      - A filesystem path to a custom YAML config

    The optional `version` argument controls which config is selected when
    multiple configs share the same `name` field:

      - "latest" (default): pick the latest stable release by the `version`
        field in the YAML (using PEP 440 / semantic version ordering and
        preferring non-prerelease, non-dev versions).
      - Any other string: pick the config whose `version` field exactly
        matches that string.

    If `version` is not "latest" and no matching version is found, a
    FileNotFoundError is raised listing available versions.
    """
    # First: treat `game` as a path
    possible_path = Path(game).expanduser()
    if possible_path.is_file() and possible_path.suffix.lower() in {".yaml", ".yaml"}:
        return str(possible_path)

    # Otherwise: treat it as a built-in game name
    games_dir = Path(__file__).parent.parent.parent / "games"

    names_found = []
    matches = []  # List[tuple[Path, dict]]

    for path in games_dir.glob("*.y*ml"):
        try:
            with path.open("r", encoding="utf-8") as f:
                doc = yaml.safe_load(f) or {}
            doc_name = doc.get("name")

            if not doc_name:
                logger.warning(f"Game config {path} has no top-level 'name' field. Skipping.")
                continue

            names_found.append(doc_name)

            if doc_name.strip().lower() == game.strip().lower():
                matches.append((path, doc))

        except Exception:
            logger.warning(f"Failed to load game config from {path}. Maybe syntax error? Skipping.")
            continue

    if not matches:
        raise FileNotFoundError(f"No game config matching {game!r} found. Found built-ins: {names_found}")

    # If a specific version was requested, honor it exactly.
    if version != "latest":
        matching_versions = [p for p, doc in matches if str(doc.get("version", "")).strip() == version]
        if matching_versions:
            chosen = str(matching_versions[0])
            logger.debug(f"Selected game config {chosen} for game={game!r}, version={version!r}")
            return chosen

        available_versions = sorted({str(doc.get("version", "")).strip() or "<none>" for _, doc in matches})
        raise FileNotFoundError(
            f"No game config for {game!r} with version {version!r} found. Available versions for this game: {available_versions}"
        )

    # version == "latest": pick the latest stable release.
    stable_candidates = []  # list[(Version, Path)]
    other_candidates = []  # list[(Version | None, Path)]

    for path, doc in matches:
        raw_version = str(doc.get("version", "")).strip()
        if not raw_version:
            other_candidates.append((None, path))
            continue

        try:
            v = Version(raw_version)
        except InvalidVersion:
            logger.warning(f"Game config {path} has invalid version {raw_version!r}. Treating as unversioned.")
            other_candidates.append((None, path))
            continue

        if not v.is_prerelease and not v.is_devrelease:
            stable_candidates.append((v, path))
        else:
            other_candidates.append((v, path))

    if stable_candidates:
        v, chosen_path = max(stable_candidates, key=lambda x: x[0])
        chosen = str(chosen_path)
        logger.debug(f"Selected latest stable game config {chosen} for game={game!r}, version={v}")
        return chosen

    # No stable versions; fall back to highest version among others, if any.
    versioned_others = [(v, p) for v, p in other_candidates if v is not None]
    if versioned_others:
        v, chosen_path = max(versioned_others, key=lambda x: x[0])
        chosen = str(chosen_path)
        logger.debug(f"No stable versions for {game!r}. Selected latest non-stable game config {chosen} with version={v}.")
        return chosen

    # No parseable versions at all: fall back to latest by modification time.
    if other_candidates:
        chosen_path = max((p for _, p in other_candidates), key=lambda p: p.stat().st_mtime)
        chosen = str(chosen_path)
        logger.debug(f"No version info for {game!r}. Selected most recently modified game config {chosen}.")
        return chosen

    # This should be unreachable, but keep a defensive error.
    raise FileNotFoundError(f"Could not determine a suitable config for game {game!r}.")
list_characters()

Return available characters from seed data.

Useful for checking available characters when db is not live.

Source code in dcs_simulation_engine/helpers/game_helpers.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
def list_characters() -> list[dict]:
    """Return available characters from seed data.

    Useful for checking available characters when db is not live.
    """
    import json

    pkg_root = package_root()
    subfolder = "prod" if IS_PROD else "dev"
    seeds_path = pkg_root.parent / "database_seeds" / subfolder / "characters.json"

    if not seeds_path.exists():
        raise FileNotFoundError(f"Character seed file not found: {seeds_path}")

    data = json.loads(seeds_path.read_text(encoding="utf-8"))

    if not isinstance(data, list):
        raise ValueError("characters.json must contain a list of character objects")

    return [c for c in data if isinstance(c, dict)]
list_games(directory=None)

Return available games.

Source code in dcs_simulation_engine/helpers/game_helpers.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
def list_games(
    directory: str | Path | None = None,
) -> list[tuple[str, str, Path, str | None, str | None]]:
    """Return available games."""
    pkg_dir = package_games_dir()
    results: list[tuple[str, str, Path, str | None, str | None]] = []

    def add_from_dir(dir_path: Path) -> None:
        for path in dir_path.glob("*.y*ml"):
            try:
                with path.open("r", encoding="utf-8") as f:
                    doc = yaml.safe_load(f) or {}
            except Exception:
                logger.warning(f"Failed to parse {path}, skipping.")
                continue

            raw_name = doc.get("name")
            if not raw_name:
                logger.warning(f"Game config {path} missing 'name', skipping.")
                continue
            name = str(raw_name).strip()

            raw_version = doc.get("version")
            version = str(raw_version).strip() if raw_version else None

            authors = doc.get("authors")
            if isinstance(authors, list):
                author_str = ", ".join(str(a).strip() for a in authors if str(a).strip())
            elif isinstance(authors, str):
                author_str = authors.strip()
            else:
                author_str = ""

            raw_description = doc.get("description")
            description = str(raw_description).strip() if raw_description else None

            results.append((name, author_str, path, version, description))

    # List package games first
    if pkg_dir and pkg_dir.exists() and pkg_dir.is_dir():
        add_from_dir(pkg_dir)

    return results

logging_helpers

Logging helpers for DI Simulation Engine.

configure_logger(source, quiet=False, verbose=0)

Configure Loguru logging.

Source code in dcs_simulation_engine/helpers/logging_helpers.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def configure_logger(source: str, quiet: bool = False, verbose: int = 0) -> None:
    """Configure Loguru logging."""
    # Clear any previously added handlers
    logger.remove()

    if quiet:
        console_level = "ERROR"
    elif verbose == 1:
        console_level = "INFO"
    elif verbose >= 2:
        console_level = "DEBUG"
    else:
        console_level = "WARNING"

    # Console handler — ERROR and above
    logger.add(
        sink=sys.stderr,
        level=console_level,
        format=("{time:YYYY-MM-DD HH:mm:ss} | {level:^7} | {file.name}:{line} | {message}"),
    )

    # File handler — DEBUG+, rotated daily, keep 7 days, zipped
    logs_dir = Path("logs")
    logs_dir.mkdir(exist_ok=True)

    log_path = logs_dir / f"{source}_{'{time:YYYYMMDD}'}.log"

    logger.add(
        sink=str(log_path),
        level="DEBUG",
        format=("{time:YYYY-MM-DD HH:mm:ss} | {level:^7} | {file.name}:{line} | {message}"),
        rotation="00:00",
        retention="7 days",
        compression="zip",
    )

    logger.info(
        f"Logger configured for source '{source}'. "
        f"Sinks: stderr (level=ERROR+), file (level=DEBUG+) at '{log_path}'. "
        f"Rotation daily at midnight, retention 7 days, zipped."
    )

infra

Operational / infrastructure helpers (Fly, Docker, etc.).

deploy

Deployment management.

deploy_app(*, game, deployment, version='latest', fly_toml=Path('fly.toml'), env_file=Path('.env'), region=None)

Deploy a game.

Source code in dcs_simulation_engine/infra/deploy.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
def deploy_app(
    *,
    game: str,
    deployment: str,
    version: str = "latest",
    fly_toml: Path = Path("fly.toml"),
    env_file: Optional[Path] = Path(".env"),
    region: Optional[str] = None,
) -> provider.DeployResult:
    """Deploy a game."""
    return provider.deploy_app(
        game=game,
        app_name=deployment,
        version=version,
        fly_toml=fly_toml,
        env_file=env_file,
        region=region,
    )
destroy_deployment(app_name)

Destroy a deployment.

Source code in dcs_simulation_engine/infra/deploy.py
35
36
37
def destroy_deployment(app_name: str) -> None:
    """Destroy a deployment."""
    provider.destroy_app(app_name)
list_deployments()

List deployments.

Source code in dcs_simulation_engine/infra/deploy.py
 9
10
11
12
def list_deployments() -> list[dict]:
    """List deployments."""
    # this might raise FlyError
    return provider.list_apps()
stop_deployment(*, deployment, logs_out=None, logs_no_tail=True, db_remote=None, db_out=None)

Stop a deployment and optionally download logs + DB.

Source code in dcs_simulation_engine/infra/deploy.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def stop_deployment(
    *,
    deployment: str,
    logs_out: Optional[Path] = None,
    logs_no_tail: bool = True,
    db_remote: Optional[str] = None,
    db_out: Optional[Path] = None,
) -> list[str]:
    """Stop a deployment and optionally download logs + DB."""
    # best-effort logs
    if logs_out:
        logs = provider.download_logs_jsonl(app_name=deployment, no_tail=logs_no_tail, out_path=logs_out)
        logs_out.parent.mkdir(parents=True, exist_ok=True)
        logs_out.write_text(logs)

    # best-effort db
    if db_remote:
        if db_out is None:
            db_out = Path(f"{deployment}-db.sqlite3")
        provider.sftp_get(app_name=deployment, remote_path=db_remote, local_path=db_out)

    # stop machines
    return provider.stop_all_machines(deployment)

fly

Fly.io management.

DeployResult dataclass

Result of a deployment.

Source code in dcs_simulation_engine/infra/fly.py
20
21
22
23
24
25
26
@dataclass(frozen=True)
class DeployResult:
    """Result of a deployment."""

    app_name: str
    process_cmd: str
    forwarded_env_keys: list[str]
FlyError

Bases: RuntimeError

Raised for Fly-related operational failures.

Source code in dcs_simulation_engine/infra/fly.py
16
17
class FlyError(RuntimeError):
    """Raised for Fly-related operational failures."""
LoadedEnv dataclass

Merged env + captured dotenv key/values (for forwarding to flyctl --env).

Source code in dcs_simulation_engine/infra/fly.py
29
30
31
32
33
@dataclass(frozen=True)
class LoadedEnv:
    """Merged env + captured dotenv key/values (for forwarding to flyctl --env)."""

    dotenv_vars: Dict[str, str]
build_deploy_cmd(config_path, app_name, dotenv_vars)

Build the flyctl deploy command, injecting env vars from .env.

Source code in dcs_simulation_engine/infra/fly.py
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
def build_deploy_cmd(config_path: Path, app_name: str, dotenv_vars: Dict[str, str]) -> list[str]:
    """Build the flyctl deploy command, injecting env vars from .env."""
    cmd: list[str] = [
        "fly",
        "deploy",
        "--config",
        str(config_path),
        "--app",
        app_name,
        "--ha=false",
    ]
    for key, value in dotenv_vars.items():
        if key == "FLY_API_TOKEN":
            continue
        cmd.extend(["--env", f"{key}={value}"])
    return cmd
build_process_command(interface, *, game, version, tag)

Build the command string that becomes the Fly [processes].web command.

Source code in dcs_simulation_engine/infra/fly.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
def build_process_command(
    interface: str,
    *,
    game: Optional[str],
    version: str,
    tag: Optional[str],
) -> str:
    """Build the command string that becomes the Fly [processes].web command."""
    if interface not in {"widget", "api"}:
        raise ValueError("interface must be 'widget' or 'api'.")

    if interface == "widget":
        if not game:
            raise ValueError("--game is required for widget deployments.")

        cmd_parts: list[str] = [
            "uv",
            "run",
            "dcs",
            "run",
        ]
        if tag:
            cmd_parts.extend(["--banner", tag])
        _ = version
        return " ".join(cmd_parts)

    cmd_parts = [
        "uv",
        "run",
        "python",
        "-m",
        "scripts.run_api",
        "--port",
        "8080",
        "--host",
        "0.0.0.0",
    ]
    _ = version
    return " ".join(cmd_parts)
check_flyctl()

Verify that flyctl is installed and accessible on PATH.

Source code in dcs_simulation_engine/infra/fly.py
136
137
138
139
def check_flyctl() -> None:
    """Verify that `flyctl` is installed and accessible on PATH."""
    if shutil.which("flyctl") is None:
        raise RuntimeError("flyctl not installed or not on PATH.")
deploy_app(*, game, app_name, version='latest', fly_toml=Path('fly.toml'), env_file=Path('.env'), region=None)

Deploy the widget process for a game to Fly.

Source code in dcs_simulation_engine/infra/fly.py
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
def deploy_app(
    *,
    game: str,
    app_name: str,
    version: str = "latest",
    fly_toml: Path = Path("fly.toml"),
    env_file: Optional[Path] = Path(".env"),
    region: Optional[str] = None,
) -> DeployResult:
    """Deploy the widget process for a game to Fly."""
    ensure_fly_available()

    try:
        loaded = load_env(env_file=env_file)
    except Exception as e:
        raise FlyError(f"Failed to load env file: {e}") from e

    process_cmd = build_process_command("widget", game=game, version=version, tag=None)

    try:
        original = fly_toml.read_text()
        updated = update_fly_toml(
            original_toml=original,
            app_name=app_name,
            process_cmd=process_cmd,
            region=region,
        )
        fly_toml.write_text(updated)
    except FileNotFoundError as e:
        raise FlyError(f"fly.toml not found at: {fly_toml}") from e
    except Exception as e:
        raise FlyError(f"Failed updating {fly_toml}: {e}") from e

    try:
        ensure_app_exists(app_name)
    except Exception as e:
        raise FlyError(f"Failed ensuring Fly app exists ({app_name}): {e}") from e

    dotenv_vars = loaded.dotenv_vars or {}
    forwarded_keys = [k for k in dotenv_vars.keys() if k != "FLY_API_TOKEN"]

    try:
        deploy_cmd = build_deploy_cmd(fly_toml, app_name, dotenv_vars)
        logger.info("Deploying with: %s", " ".join(deploy_cmd))
        subprocess.run(deploy_cmd, check=True)
    except subprocess.CalledProcessError as e:
        raise FlyError(f"flyctl deploy failed (exit {e.returncode})") from e

    return DeployResult(app_name=app_name, process_cmd=process_cmd, forwarded_env_keys=forwarded_keys)
destroy_app(app_name)

Destroy a Fly app.

Source code in dcs_simulation_engine/infra/fly.py
117
118
119
120
121
122
def destroy_app(app_name: str) -> None:
    """Destroy a Fly app."""
    try:
        subprocess.run(["fly", "apps", "destroy", app_name, "--yes"], check=True)
    except subprocess.CalledProcessError as e:
        raise FlyError(f"fly apps destroy failed (exit {e.returncode})") from e
download_db(*, app_name, remote_path, local_path)

Download a file from the Fly app via SFTP.

Source code in dcs_simulation_engine/infra/fly.py
359
360
361
362
363
364
365
366
def download_db(*, app_name: str, remote_path: str, local_path: Path) -> None:
    """Download a file from the Fly app via SFTP."""
    ensure_fly_available()
    local_path.parent.mkdir(parents=True, exist_ok=True)
    try:
        sftp_get(app_name=app_name, remote_path=remote_path, local_path=local_path)
    except Exception as e:
        raise FlyError(f"Failed to download DB: {e}") from e
download_logs_jsonl(*, app_name, out_path, no_tail=True)

Download Fly logs in JSONL format and write to out_path.

Source code in dcs_simulation_engine/infra/fly.py
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
def download_logs_jsonl(*, app_name: str, out_path: Path, no_tail: bool = True) -> None:
    """Download Fly logs in JSONL format and write to out_path."""
    ensure_fly_available()
    out_path.parent.mkdir(parents=True, exist_ok=True)

    cmd = ["fly", "logs", "--app", app_name, "--json"]
    if no_tail:
        cmd.append("--no-tail")

    try:
        proc = subprocess.run(cmd, check=True, capture_output=True, text=True)
    except subprocess.CalledProcessError as e:
        err = (e.stderr or "").strip() or (e.stdout or "").strip() or str(e)
        raise FlyError(f"Failed to download logs: {err}") from e

    # fly logs --json outputs newline-delimited JSON objects (JSONL)
    out_path.write_text(proc.stdout, encoding="utf-8")
ensure_app_exists(app_name)

Ensure the Fly app exists. If not, create it.

Source code in dcs_simulation_engine/infra/fly.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
def ensure_app_exists(app_name: str) -> None:
    """Ensure the Fly app exists. If not, create it."""
    result = subprocess.run(["flyctl", "apps", "list"], capture_output=True, text=True)
    if result.returncode != 0:
        logger.warning(
            "Failed to list apps (exit %s), proceeding to deploy anyway.",
            result.returncode,
        )
        return

    for line in result.stdout.splitlines()[1:]:
        if not line.strip():
            continue
        name = line.split()[0]
        if name == app_name:
            logger.info("App %r already exists.", app_name)
            return

    cmd = ["flyctl", "apps", "create", app_name]
    logger.info("App %r not found. Creating via: %s", app_name, " ".join(cmd))
    subprocess.run(cmd, check=True)
ensure_fly_auth()

Verify flyctl is authenticated (i.e. fly auth whoami works and returns an email).

Source code in dcs_simulation_engine/infra/fly.py
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
def ensure_fly_auth() -> None:
    """Verify flyctl is authenticated (i.e. `fly auth whoami` works and returns an email)."""
    try:
        res = subprocess.run(
            ["fly", "auth", "whoami", "--json"],
            check=True,
            capture_output=True,
            text=True,
        )
        data = json.loads(res.stdout or "{}")
        email = data.get("email") or data.get("Email") or data.get("user") or data.get("User")
        if not email:
            raise RuntimeError("not logged in")
    except FileNotFoundError:
        raise FlyError("flyctl not found. Please install flyctl and ensure it's on your PATH.")
    except Exception as e:
        raise FlyError("Failed to verify Fly authentication. Please ensure you're logged in via `fly auth login`.") from e
ensure_fly_available()

Verify flyctl is installed and usable.

Source code in dcs_simulation_engine/infra/fly.py
142
143
144
145
146
147
def ensure_fly_available() -> None:
    """Verify flyctl is installed and usable."""
    try:
        check_flyctl()
    except Exception as e:
        raise FlyError(str(e)) from e
flyctl_json(args)

Run flyctl with --json and return parsed JSON.

Source code in dcs_simulation_engine/infra/fly.py
125
126
127
128
129
130
131
132
133
def flyctl_json(args: List[str]) -> object:
    """Run flyctl with --json and return parsed JSON."""
    proc = subprocess.run(
        ["flyctl", *args, "--json"],
        check=True,
        capture_output=True,
        text=True,
    )
    return json.loads(proc.stdout)
list_apps()

List Fly apps in the current account.

Source code in dcs_simulation_engine/infra/fly.py
319
320
321
322
323
324
325
326
def list_apps() -> list[dict[str, Any]]:
    """List Fly apps in the current account."""
    ensure_fly_available()
    try:
        apps = flyctl_json(["apps", "list"])
        return apps if isinstance(apps, list) else []
    except Exception as e:
        raise FlyError(f"Failed to list Fly apps: {e}") from e
list_machines(app_name)

List machines for a Fly app.

Source code in dcs_simulation_engine/infra/fly.py
329
330
331
332
333
334
335
336
def list_machines(app_name: str) -> list[dict[str, Any]]:
    """List machines for a Fly app."""
    ensure_fly_available()
    try:
        machines = flyctl_json(["machine", "list", "--app", app_name])
        return machines if isinstance(machines, list) else []
    except Exception as e:
        raise FlyError(f"Failed to list machines for {app_name}: {e}") from e
load_env(env_file=Path('.env'))

Load env vars from .env and ensure FLY_API_TOKEN exists in environment.

Source code in dcs_simulation_engine/infra/fly.py
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
def load_env(env_file: Optional[Path] = Path(".env")) -> LoadedEnv:
    """Load env vars from .env and ensure FLY_API_TOKEN exists in environment."""
    if env_file is None or not env_file.exists():
        if env_file is not None:
            logger.warning("%s not found — skipping env file load.", env_file)
        dotenv_vars: Dict[str, str] = {}
    else:
        raw = dotenv_values(env_file)
        dotenv_vars = {k: v for k, v in raw.items() if v is not None}
        load_dotenv(env_file, override=True)

    if not os.environ.get("FLY_API_TOKEN"):
        raise RuntimeError("FLY_API_TOKEN missing in environment.")

    return LoadedEnv(dotenv_vars=dotenv_vars)
sftp_get(*, app_name, remote_path, local_path)

Get a file from the Fly app via SFTP.

Source code in dcs_simulation_engine/infra/fly.py
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
def sftp_get(*, app_name: str, remote_path: str, local_path: Path) -> None:
    """Get a file from the Fly app via SFTP."""
    ensure_fly_available()
    try:
        subprocess.run(
            [
                "flyctl",
                "ssh",
                "sftp",
                "get",
                remote_path,
                str(local_path),
                "--app",
                app_name,
            ],
            check=True,
        )
    except subprocess.CalledProcessError as e:
        raise FlyError(f"flyctl sftp get failed (exit {e.returncode})") from e
stop_all_machines(app_name)

Stop all machines for a Fly app.

Source code in dcs_simulation_engine/infra/fly.py
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
def stop_all_machines(app_name: str) -> list[str]:
    """Stop all machines for a Fly app."""
    machines = list_machines(app_name)
    machine_ids: list[str] = []
    for m in machines:
        mid = m.get("id") or m.get("ID") or m.get("Id")
        if mid:
            machine_ids.append(str(mid))

    if not machine_ids:
        return []

    try:
        subprocess.run(["flyctl", "machine", "stop", *machine_ids, "--app", app_name], check=True)
    except subprocess.CalledProcessError as e:
        raise FlyError(f"flyctl machine stop failed (exit {e.returncode})") from e

    return machine_ids
update_fly_toml(*, original_toml, app_name, process_cmd, region=None)

Update app/process settings in fly.toml using TOML parsing and serialization.

Source code in dcs_simulation_engine/infra/fly.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
def update_fly_toml(*, original_toml: str, app_name: str, process_cmd: str, region: Optional[str] = None) -> str:
    """Update app/process settings in fly.toml using TOML parsing and serialization."""
    data = tomllib.loads(original_toml)
    if not isinstance(data, dict):
        raise RuntimeError("fly.toml root must be a table.")

    data["app"] = app_name
    if region is not None:
        data["primary_region"] = region

    processes = data.get("processes")
    if processes is None:
        data["processes"] = {"web": process_cmd}
    elif isinstance(processes, dict):
        processes["web"] = process_cmd
    else:
        raise RuntimeError("[processes] must be a table in fly.toml.")

    lines: list[str] = []
    _write_table(lines, [], data)
    while lines and lines[-1] == "":
        lines.pop()
    return "\n".join(lines) + "\n"

remote

High-level remote Fly deployment lifecycle helpers.

ApiFlyTemplateContext dataclass

Bases: BaseFlyTemplateContext

Template context for the API Fly config.

Source code in dcs_simulation_engine/infra/remote.py
89
90
91
92
93
94
@dataclass(frozen=True)
class ApiFlyTemplateContext(BaseFlyTemplateContext):
    """Template context for the API Fly config."""

    process_cmd_json: str
    api_port: int
BaseFlyTemplateContext dataclass

Shared context fields for generated Fly config templates.

Source code in dcs_simulation_engine/infra/remote.py
80
81
82
83
84
85
86
@dataclass(frozen=True)
class BaseFlyTemplateContext:
    """Shared context fields for generated Fly config templates."""

    app_name: str
    region: str | None
    docker_dir: str
DbFlyTemplateContext dataclass

Bases: BaseFlyTemplateContext

Template context for the DB Fly config.

Source code in dcs_simulation_engine/infra/remote.py
104
105
106
107
108
@dataclass(frozen=True)
class DbFlyTemplateContext(BaseFlyTemplateContext):
    """Template context for the DB Fly config."""

    db_volume_name: str
RemoteAppNames dataclass

Concrete Fly app names for one remote experiment deployment.

Source code in dcs_simulation_engine/infra/remote.py
50
51
52
53
54
55
56
@dataclass(frozen=True)
class RemoteAppNames:
    """Concrete Fly app names for one remote experiment deployment."""

    api_app: str
    ui_app: str
    db_app: str
RemoteDeploymentResult dataclass

Structured output returned after a successful remote deployment.

Source code in dcs_simulation_engine/infra/remote.py
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@dataclass(frozen=True)
class RemoteDeploymentResult:
    """Structured output returned after a successful remote deployment."""

    experiment_name: str
    deployed_apps: list[str]
    api_app: str
    ui_app: str
    db_app: str
    api_url: str
    ui_url: str
    admin_api_key: str | None
    status_command: str
    save_command: str | None
    stop_command: str | None

    def model_dump(self) -> dict[str, Any]:
        """Return a JSON-serializable dict payload."""
        return asdict(self)
model_dump()

Return a JSON-serializable dict payload.

Source code in dcs_simulation_engine/infra/remote.py
75
76
77
def model_dump(self) -> dict[str, Any]:
    """Return a JSON-serializable dict payload."""
    return asdict(self)
RemoteFlyConfigPaths dataclass

Concrete file paths for generated Fly TOML configs.

Source code in dcs_simulation_engine/infra/remote.py
120
121
122
123
124
125
126
@dataclass(frozen=True)
class RemoteFlyConfigPaths:
    """Concrete file paths for generated Fly TOML configs."""

    api_path: Path
    ui_path: Path
    db_path: Path
RemoteLifecycleError

Bases: RuntimeError

Raised when a remote Fly lifecycle operation fails.

Source code in dcs_simulation_engine/infra/remote.py
46
47
class RemoteLifecycleError(RuntimeError):
    """Raised when a remote Fly lifecycle operation fails."""
RemoteRenderedFlyConfigs dataclass

Rendered Fly TOML contents for one remote experiment deployment.

Source code in dcs_simulation_engine/infra/remote.py
111
112
113
114
115
116
117
@dataclass(frozen=True)
class RemoteRenderedFlyConfigs:
    """Rendered Fly TOML contents for one remote experiment deployment."""

    api_toml: str
    ui_toml: str
    db_toml: str
RemoteStatusResult dataclass

Authenticated experiment status returned for CLI presentation.

Source code in dcs_simulation_engine/infra/remote.py
129
130
131
132
133
134
135
136
137
138
139
140
@dataclass(frozen=True)
class RemoteStatusResult:
    """Authenticated experiment status returned for CLI presentation."""

    api_url: str
    mode: str | None
    experiment_name: str | None
    experiment_status: dict[str, Any] | None

    def model_dump(self) -> dict[str, Any]:
        """Return a JSON-serializable dict payload."""
        return asdict(self)
model_dump()

Return a JSON-serializable dict payload.

Source code in dcs_simulation_engine/infra/remote.py
138
139
140
def model_dump(self) -> dict[str, Any]:
    """Return a JSON-serializable dict payload."""
    return asdict(self)
UiFlyTemplateContext dataclass

Bases: BaseFlyTemplateContext

Template context for the UI Fly config.

Source code in dcs_simulation_engine/infra/remote.py
 97
 98
 99
100
101
@dataclass(frozen=True)
class UiFlyTemplateContext(BaseFlyTemplateContext):
    """Template context for the UI Fly config."""

    ui_port: int
app_url(app_name)

Return the public Fly URL for an app.

Source code in dcs_simulation_engine/infra/remote.py
184
185
186
def app_url(app_name: str) -> str:
    """Return the public Fly URL for an app."""
    return f"https://{app_name}.fly.dev"
deploy_remote_experiment(*, config=None, free_play=False, openrouter_key, mongo_seed_path, admin_key=None, fly_api_token=None, region=None, api_app=None, ui_app=None, db_app=None, deploy_apps=None)

Deploy one remote-managed stack and bootstrap its remote admin key.

Source code in dcs_simulation_engine/infra/remote.py
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
def deploy_remote_experiment(
    *,
    config: str | Path | None = None,
    free_play: bool = False,
    openrouter_key: str,
    mongo_seed_path: str | Path,
    admin_key: str | None = None,
    fly_api_token: str | None = None,
    region: str | None = None,
    api_app: str | None = None,
    ui_app: str | None = None,
    db_app: str | None = None,
    deploy_apps: set[str] | None = None,
) -> RemoteDeploymentResult:
    """Deploy one remote-managed stack and bootstrap its remote admin key."""
    mongo_seed_path = _validate_mongo_seed_path(Path(mongo_seed_path))
    if admin_key is not None:
        admin_key = validate_access_key(admin_key)
    deployment_name, config_path = _resolve_remote_deployment_target(config=config, free_play=free_play)
    selected_apps = _normalize_deploy_apps(deploy_apps)
    is_full_deploy = selected_apps == list(REMOTE_DEPLOY_APP_ORDER)
    names = derive_remote_app_names(
        experiment_name=deployment_name,
        api_app=api_app,
        ui_app=ui_app,
        db_app=db_app,
    )
    api_url = app_url(names.api_app)
    ui_url = app_url(names.ui_app)
    mongo_uri = f"mongodb://{names.db_app}.internal:{REMOTE_MONGO_PORT}/"
    bootstrap_token = f"dcs-bootstrap-{slugify_experiment_name(deployment_name)}-{secrets.token_urlsafe(12)}"
    rendered_configs = RemoteRenderedFlyConfigs(
        api_toml=_render_api_fly_toml(
            app_name=names.api_app,
            region=region,
            process_cmd=_api_process_command(
                deployment_name=deployment_name,
                bootstrap_token=bootstrap_token,
                ui_url=ui_url,
                free_play=free_play,
            ),
        ),
        ui_toml=_render_ui_fly_toml(app_name=names.ui_app, region=region),
        db_toml=_render_db_fly_toml(app_name=names.db_app, region=region),
    )
    artifact_dir = _deployment_artifact_dir(experiment_name=deployment_name)
    fly_configs = _write_remote_fly_configs(
        output_dir=artifact_dir,
        names=names,
        rendered_configs=rendered_configs,
    )
    if config_path is not None:
        _write_deployment_experiment_config(
            output_dir=artifact_dir,
            experiment_name=deployment_name,
            source_path=config_path,
        )

    repo_root = _repo_root()
    if "db" in selected_apps:
        _ensure_app_exists(names.db_app, fly_api_token=fly_api_token)
        _ensure_volume(app_name=names.db_app, region=region, fly_api_token=fly_api_token)
        _deploy_from_config(
            config_path=fly_configs.db_path,
            app_name=names.db_app,
            cwd=repo_root,
            fly_api_token=fly_api_token,
        )
        _wait_for_mongo_ready(app_name=names.db_app, fly_api_token=fly_api_token)

    if "api" in selected_apps:
        _ensure_app_exists(names.api_app, fly_api_token=fly_api_token)
        _deploy_from_config(
            config_path=fly_configs.api_path,
            app_name=names.api_app,
            cwd=repo_root,
            fly_api_token=fly_api_token,
            env_vars={
                "MONGO_URI": mongo_uri,
                "OPENROUTER_API_KEY": openrouter_key,
            },
        )
        _wait_for_health(base_url=api_url)

    admin_api_key: str | None = None
    if is_full_deploy:
        admin_api_key = _bootstrap_remote_deployment(
            api_url=api_url,
            bootstrap_token=bootstrap_token,
            mongo_seed_path=mongo_seed_path,
            admin_key=admin_key,
        )

    if "ui" in selected_apps:
        _ensure_app_exists(names.ui_app, fly_api_token=fly_api_token)
        _deploy_from_config(
            config_path=fly_configs.ui_path,
            app_name=names.ui_app,
            cwd=repo_root,
            fly_api_token=fly_api_token,
            build_args={"VITE_API_ORIGIN": api_url},
        )

    status_command = f"dcs remote status --uri {shlex.quote(api_url)} --admin-key {REMOTE_ADMIN_KEY_PLACEHOLDER}"
    save_command = (
        (
            f"dcs remote save --uri {shlex.quote(api_url)} --admin-key {REMOTE_ADMIN_KEY_PLACEHOLDER} "
            f"--save-db-path {shlex.quote(f'{slugify_experiment_name(deployment_name)}.tar.gz')}"
        )
        if admin_api_key
        else None
    )
    stop_command = (
        (
            f"dcs remote stop --uri {shlex.quote(api_url)} --admin-key {REMOTE_ADMIN_KEY_PLACEHOLDER} "
            f"--save-db-path {shlex.quote(f'{slugify_experiment_name(deployment_name)}.tar.gz')} "
            f"--api-app {shlex.quote(names.api_app)} --ui-app {shlex.quote(names.ui_app)} "
            f"--db-app {shlex.quote(names.db_app)}"
        )
        if admin_api_key
        else None
    )

    return RemoteDeploymentResult(
        experiment_name=deployment_name,
        deployed_apps=selected_apps,
        api_app=names.api_app,
        ui_app=names.ui_app,
        db_app=names.db_app,
        api_url=api_url,
        ui_url=ui_url,
        admin_api_key=admin_api_key,
        status_command=status_command,
        save_command=save_command,
        stop_command=stop_command,
    )
derive_remote_app_names(*, experiment_name, api_app=None, ui_app=None, db_app=None)

Return explicit or derived app names for a remote experiment deployment.

Source code in dcs_simulation_engine/infra/remote.py
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
def derive_remote_app_names(
    *,
    experiment_name: str,
    api_app: str | None = None,
    ui_app: str | None = None,
    db_app: str | None = None,
) -> RemoteAppNames:
    """Return explicit or derived app names for a remote experiment deployment."""
    slug = slugify_experiment_name(experiment_name)
    prefix = f"dcs-{slug}"
    return RemoteAppNames(
        api_app=api_app or f"{prefix}-api",
        ui_app=ui_app or f"{prefix}-ui",
        db_app=db_app or f"{prefix}-db",
    )
fetch_remote_status(*, uri, admin_key)

Return the authenticated status payload for one remote deployment.

Source code in dcs_simulation_engine/infra/remote.py
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
def fetch_remote_status(
    *,
    uri: str,
    admin_key: str,
) -> RemoteStatusResult:
    """Return the authenticated status payload for one remote deployment."""
    try:
        with httpx.Client(base_url=uri.rstrip("/"), timeout=15.0) as client:
            remote_response = client.get("/api/remote/status")
            remote_response.raise_for_status()
            payload = remote_response.json()
            experiment_name = payload.get("experiment_name")
            experiment_status: dict[str, Any]
            if experiment_name:
                headers = {"Authorization": f"Bearer {admin_key}"}
                experiment_response = client.get(f"/api/experiments/{experiment_name}/status", headers=headers)
                experiment_response.raise_for_status()
                experiment_status = experiment_response.json()
            else:
                experiment_status = payload
    except httpx.HTTPError as exc:
        raise RemoteLifecycleError(f"Failed to fetch remote deployment status: {exc}") from exc

    return RemoteStatusResult(
        api_url=uri,
        mode=payload.get("mode"),
        experiment_name=experiment_name,
        experiment_status=experiment_status,
    )
load_experiment_config(config)

Resolve and load the experiment config selected for remote deployment.

Source code in dcs_simulation_engine/infra/remote.py
560
561
562
563
def load_experiment_config(config: str | Path) -> tuple[Path, ExperimentConfig]:
    """Resolve and load the experiment config selected for remote deployment."""
    resolved = Path(get_experiment_config(str(config))).expanduser().resolve()
    return resolved, ExperimentConfig.load(resolved)
save_remote_database(*, uri, admin_key, save_db_path)

Download the remote database export archive to the requested local path.

Source code in dcs_simulation_engine/infra/remote.py
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
def save_remote_database(*, uri: str, admin_key: str, save_db_path: Path) -> Path:
    """Download the remote database export archive to the requested local path."""
    save_db_path.parent.mkdir(parents=True, exist_ok=True)
    archive_format = _archive_format_for_save_path(save_db_path)
    with httpx.Client(base_url=uri.rstrip("/"), timeout=None) as client:
        with client.stream(
            "GET",
            "/api/remote/db-export",
            params={"format": archive_format},
            headers={"Authorization": f"Bearer {admin_key}"},
        ) as response:
            response.raise_for_status()
            with save_db_path.open("wb") as handle:
                for chunk in response.iter_bytes():
                    handle.write(chunk)
    return save_db_path
slugify_experiment_name(value)

Normalize an experiment name into a Fly-app-safe slug.

Source code in dcs_simulation_engine/infra/remote.py
146
147
148
149
150
151
152
def slugify_experiment_name(value: str) -> str:
    """Normalize an experiment name into a Fly-app-safe slug."""
    slug = re.sub(r"[^a-z0-9]+", "-", value.strip().lower())
    slug = re.sub(r"-{2,}", "-", slug).strip("-")
    if not slug:
        raise RemoteLifecycleError("Experiment name does not produce a valid Fly app slug.")
    return slug
stop_remote_experiment(*, uri, admin_key, save_db_path, api_app, ui_app, db_app, fly_api_token=None)

Save the remote DB archive, then destroy all Fly apps for the experiment.

Source code in dcs_simulation_engine/infra/remote.py
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
def stop_remote_experiment(
    *,
    uri: str,
    admin_key: str,
    save_db_path: Path,
    api_app: str,
    ui_app: str,
    db_app: str,
    fly_api_token: str | None = None,
) -> Path:
    """Save the remote DB archive, then destroy all Fly apps for the experiment."""
    saved_path = save_remote_database(uri=uri, admin_key=admin_key, save_db_path=save_db_path)
    for app_name in (ui_app, api_app, db_app):
        _destroy_app(app_name, fly_api_token=fly_api_token)
    return saved_path

utils

Utils: pure generic building blocks.

Contents should be: - Is pure and reusable anywhere - Has no knowledge of your app - Operates on basic data types

async_utils

Helpers for working with possibly-awaitable values.

maybe_await(value) async

Await value when awaitable; otherwise return as-is.

Source code in dcs_simulation_engine/utils/async_utils.py
 7
 8
 9
10
11
async def maybe_await(value: Any) -> Any:
    """Await value when awaitable; otherwise return as-is."""
    if inspect.isawaitable(value):
        return await value
    return value

auth

Access key generation and verification utilities.

generate_access_key(*, prefix=DEFAULT_KEY_PREFIX)

Generate a random access key string.

Source code in dcs_simulation_engine/utils/auth.py
12
13
14
15
def generate_access_key(*, prefix: str = DEFAULT_KEY_PREFIX) -> str:
    """Generate a random access key string."""
    token = secrets.token_urlsafe(32)
    return prefix + token
validate_access_key(raw_key)

Validate and normalize a raw access key.

Source code in dcs_simulation_engine/utils/auth.py
18
19
20
21
22
23
24
25
26
27
def validate_access_key(raw_key: str) -> str:
    """Validate and normalize a raw access key."""
    key = raw_key.strip()
    if len(key) != ACCESS_KEY_TOTAL_LENGTH:
        raise ValueError(f"Admin key must be exactly {ACCESS_KEY_TOTAL_LENGTH} characters long.")
    if not key.startswith(DEFAULT_KEY_PREFIX):
        raise ValueError(f"Admin key must start with '{DEFAULT_KEY_PREFIX}'.")
    if not ACCESS_KEY_PATTERN.fullmatch(key):
        raise ValueError("Admin key must use only URL-safe alphanumeric characters after the prefix (A-Z, a-z, 0-9, '_' or '-').")
    return key

fingerprint

Fingerprinting utilities for character evaluation QC.

compute_character_evaluation_fingerprint(character, model=DEFAULT_MODEL, updater_system_prompt_template=UPDATER_SYSTEM_TEMPLATE)

Return a SHA-256 hex fingerprint of a character + model + prompt template.

Defaults to the current UpdaterClient model and updater system prompt template, so callers typically only need to pass the character:

fingerprint = compute_character_evaluation_fingerprint(character)

The fingerprint is deterministic: same inputs always produce the same value. Any change to the character sheet, model name, or updater template changes it.

Source code in dcs_simulation_engine/utils/fingerprint.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
def compute_character_evaluation_fingerprint(
    character: CharacterRecord,
    model: str = DEFAULT_MODEL,
    updater_system_prompt_template: str = UPDATER_SYSTEM_TEMPLATE,
) -> str:
    """Return a SHA-256 hex fingerprint of a character + model + prompt template.

    Defaults to the current UpdaterClient model and updater system prompt template,
    so callers typically only need to pass the character:

        fingerprint = compute_character_evaluation_fingerprint(character)

    The fingerprint is deterministic: same inputs always produce the same value.
    Any change to the character sheet, model name, or updater template changes it.
    """
    payload = {
        "character": character._asdict(),
        "model": model,
        "updater_system_prompt_template": updater_system_prompt_template,
    }
    canonical = json.dumps(payload, sort_keys=True, ensure_ascii=True)
    return hashlib.sha256(canonical.encode()).hexdigest()

paths

Path utilities.

package_games_dir()

Returns the path to the package's games directory.

Source code in dcs_simulation_engine/utils/paths.py
12
13
14
def package_games_dir() -> Path:
    """Returns the path to the package's games directory."""
    return package_root().parent / "games"
package_root()

Returns the root path of the dcs_simulation_engine package.

Source code in dcs_simulation_engine/utils/paths.py
7
8
9
def package_root() -> Path:
    """Returns the root path of the dcs_simulation_engine package."""
    return Path(str(resources.files("dcs_simulation_engine")))

release_policy

Release policy utilities for the DCS character production pipeline.

Loads the character-release-policy.yml and uses it to compute which characters in prod/characters.json are approved for release, then writes the manifest.

compute_approved_characters(policy, evaluations, prod_chars_by_hid)

Return sorted list of character HIDs approved under policy.

Parameters

policy: Parsed policy dict (from :func:load_policy). evaluations: List of evaluation dicts from character_evaluations.json. prod_chars_by_hid: Mapping of hid → character doc for all characters in prod.

A character is approved if it has at least one evaluation that passes ALL policy criteria: - scores.icf >= criteria.min_icf_score - (if require_current_fingerprint) evaluation fingerprint matches the fingerprint computed from the current character doc, model, and updater prompt template.

Source code in dcs_simulation_engine/utils/release_policy.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
def compute_approved_characters(
    policy: dict[str, Any],
    evaluations: list[dict[str, Any]],
    prod_chars_by_hid: dict[str, dict[str, Any]],
) -> list[str]:
    """Return sorted list of character HIDs approved under *policy*.

    Parameters
    ----------
    policy:
        Parsed policy dict (from :func:`load_policy`).
    evaluations:
        List of evaluation dicts from ``character_evaluations.json``.
    prod_chars_by_hid:
        Mapping of ``hid → character doc`` for all characters in prod.

    A character is approved if it has at least one evaluation that passes
    ALL policy criteria:
    - ``scores.icf >= criteria.min_icf_score``
    - (if ``require_current_fingerprint``) evaluation fingerprint matches
      the fingerprint computed from the current character doc, model, and
      updater prompt template.
    """
    from dcs_utils.auto.publish import build_char_record_from_doc

    criteria = policy.get("criteria", {})
    min_icf: float = criteria.get("min_icf_score", 0.0)
    min_scenario_coverage: float = criteria.get("min_scenario_coverage_score", 0.0)
    require_fp: bool = criteria.get("require_current_fingerprint", False)

    # Pre-index evaluations by character_hid for fast lookup
    evals_by_hid: dict[str, list[dict]] = {}
    for ev in evaluations:
        hid = ev.get("character_hid", "")
        evals_by_hid.setdefault(hid, []).append(ev)

    approved: list[str] = []
    for hid, char_doc in prod_chars_by_hid.items():
        char_evals = evals_by_hid.get(hid, [])
        if not char_evals:
            continue

        current_fp: str | None = None
        if require_fp:
            try:
                record = build_char_record_from_doc(char_doc)
                current_fp = compute_character_evaluation_fingerprint(record)
            except Exception:
                continue  # can't fingerprint → skip

        for ev in char_evals:
            scores = ev.get("scores", {})
            icf = scores.get("icf", 0.0)
            if icf < min_icf:
                continue
            if min_scenario_coverage > 0 and scores.get("scenario_coverage", 0.0) < min_scenario_coverage:
                continue
            if require_fp and current_fp is not None:
                if ev.get("fingerprint", "") != current_fp:
                    continue
            # Passed all criteria
            approved.append(hid)
            break

    return sorted(approved)
load_policy(path)

Load and return the character release policy from a YAML file.

Source code in dcs_simulation_engine/utils/release_policy.py
20
21
22
def load_policy(path: Path) -> dict[str, Any]:
    """Load and return the character release policy from a YAML file."""
    return yaml.safe_load(path.read_text(encoding="utf-8"))
write_manifest(path, approved_hids, policy_version)

Write the release manifest JSON to path.

Creates parent directories if they don't exist.

Source code in dcs_simulation_engine/utils/release_policy.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def write_manifest(
    path: Path,
    approved_hids: list[str],
    policy_version: str,
) -> None:
    """Write the release manifest JSON to *path*.

    Creates parent directories if they don't exist.
    """
    manifest = {
        "policy_version": policy_version,
        "engine_version": _ENGINE_VERSION,
        "generated_at": datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "approved_characters": approved_hids,
    }
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")

serde

Serialization / deserialization (serde) mixin for Pydantic models.

Example Usage: x = X(uid="sys1", short_description="Test")

d = x.to_dict() js = x.to_json(indent=2) ys = x.to_yaml()

x1 = X.from_json(js) x2 = X.from_yaml(ys)

x.save_json("system.json", indent=2) x.save_yaml("system.yaml")

x4 = X.load_json("system.json") x5 = X.load_yaml("system.yaml")

SerdeMixin

Bases: BaseModel

Mixin adding serialization / deserialization methods to Pydantic models.

Source code in dcs_simulation_engine/utils/serde.py
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
class SerdeMixin(BaseModel):
    """Mixin adding serialization / deserialization methods to Pydantic models."""

    # ---------- nice exports ----------
    def to_dict(self, **dump_kwargs: Any) -> dict[str, Any]:
        """Convert model to dict. Pass model_dump kwargs if desired."""
        return self.model_dump(**dump_kwargs)

    def to_json(self, **dump_kwargs: Any) -> str:
        """Convert model to JSON string."""
        return self.model_dump_json(**dump_kwargs)

    def to_yaml(self, **dump_kwargs: Any) -> str:
        """Convert model to readable YAML.

        Pass yaml.safe_dump kwargs if desired (e.g., sort_keys=False).
        """
        # TODO: pre-v001 make export nice yaml not all the /newlines
        data = self.model_dump()
        # readable, block-style YAML; avoid single-line flow; keep key order
        return str(
            yaml.safe_dump(
                data,
                allow_unicode=True,
                sort_keys=False,
                default_flow_style=False,
                width=88,
                **dump_kwargs,
            )
        )

    # ---------- user-friendly loaders ----------
    @classmethod
    def from_json(cls: type[T], source: Union[Mapping[str, Any], str, Path], **kw: Any) -> T:
        """Instantiate model from a JSON string or a file path with friendly errors."""
        # logger.debug(f"Serde called with source type: {type(source)}")
        # logger.debug(f"Source content: {str(source)}")
        if isinstance(source, Mapping):
            logger.debug("Source is a Mapping, using model_validate")
            return cls.model_validate(source, **kw)
        if isinstance(source, Path):
            logger.debug("Source is a Path, reading text")
            text = source.read_text(encoding="utf-8")
            return cls.model_validate_json(text, **kw)
        # string: prefer JSON first; only then try file
        logger.debug("Source is a string, checking content")
        s = source.strip()
        if s.startswith("{") or s.startswith("["):
            return cls.model_validate_json(s, **kw)
        try:
            return cls.model_validate_json(Path(source).read_text(encoding="utf-8"), **kw)
        except OSError:
            # Not a file; assume it's JSON content even if not '{'/'[' prefixed
            return cls.model_validate_json(source, **kw)

    @classmethod
    def from_yaml(cls: type[T], source: Union[str, Path], **validate_kwargs: Any) -> T:
        """Instantiate model from a YAML string or a file path with friendly errors."""
        if isinstance(source, Path) or (isinstance(source, str) and Path(source).exists()):
            text = Path(source).read_text(encoding="utf-8")
        else:
            text = str(source)

        try:
            data = yaml.safe_load(text) or {}
        except yaml.YAMLError as e:
            raise ValueError(SerdeMixin._format_yaml_syntax_error(e, text)) from e

        try:
            return cls.model_validate(data, **validate_kwargs)
        except ValidationError as e:
            raise ValueError(SerdeMixin._format_validation_error(e, data=data, model=cls)) from e

    # ---------- convenience save/load ----------
    def save_json(self, path: Union[str, Path], **dump_kwargs: Any) -> Path:
        """Save model to a JSON file. Returns the Path."""
        p = Path(path)
        p.write_text(self.to_json(**dump_kwargs), encoding="utf-8")
        return p

    def save_yaml(self, path: Union[str, Path], **dump_kwargs: Any) -> Path:
        """Save model to a YAML file. Returns the Path."""
        p = Path(path)
        p.write_text(self.to_yaml(**dump_kwargs), encoding="utf-8")
        return p

    @classmethod
    def load_json(cls: type[T], path: Union[str, Path], **validate_kwargs: Any) -> T:
        """Load model from a JSON file."""
        return cls.from_json(Path(path), **validate_kwargs)  # type: ignore

    @classmethod
    def load_yaml(cls: type[T], path: Union[str, Path], **validate_kwargs: Any) -> T:
        """Load model from a YAML file."""
        return cls.from_yaml(Path(path), **validate_kwargs)  # type: ignore

    # ---------- helpers: friendly error messages ----------
    @staticmethod
    def _yaml_context_snippet(text: str, line: int, col: int, context: int = 1) -> str:
        """Build a tiny snippet pointing to the YAML error location.

        Lines are 1-based.
        """
        lines = text.splitlines()
        i = max(line - 1 - context, 0)
        j = min(line + context, len(lines))
        out = []
        for idx in range(i, j):
            prefix = ">" if idx == line - 1 else " "
            out.append(f"{prefix} {idx + 1:>4}: {lines[idx]}")
            if idx == line - 1:
                caret = " " * (col + 7) + "^"  # 7 accounts for formatting above
                out.append(caret)
        return "\n".join(out)

    @classmethod
    def _format_yaml_syntax_error(cls, e: yaml.YAMLError, text: str) -> str:
        # Try to extract line/column from PyYAML mark
        line = col = None
        problem_mark = getattr(e, "problem_mark", None)
        if problem_mark:
            line = problem_mark.line + 1
            col = problem_mark.column
        header = "Your YAML isn't valid."
        if line is not None and col is not None:
            snippet = cls._yaml_context_snippet(text, line, col)
            return f"{header}\nLine {line}, column {col + 1}.\n\n{snippet}\n\nFix the \
                 YAML formatting at the ^ marker."
        return f"{header} {str(e)}"

    @classmethod
    def _format_validation_error(
        cls,
        e: ValidationError,
        data: Any | None = None,
        model: type[BaseModel] | None = None,
    ) -> str:
        """Turn Pydantic errors into actionable, plain-English guidance."""
        lines = ["Your YAML loaded, but it doesn't match the expected structure:"]
        for err in e.errors():
            loc = ".".join(str(p) for p in err.get("loc", ()))
            typ = err.get("type", "")
            msg = err.get("msg", "")
            entry = cls._humanize_error(loc, typ, msg, model)
            lines.append(f"• {entry}")
        lines.append(
            "\nTip: keys are case-sensitive; remove unknown keys; \
                match the types shown."
        )
        return "\n".join(lines)

    @classmethod
    def _humanize_error(cls, loc: str, typ: str, msg: str, model: type[BaseModel] | None) -> str:
        # Missing required field
        if "missing" in typ or "missing" in msg.lower():
            suggestion = cls._suggest_example(loc, model)
            return f"Missing required field: `{loc}`. Add it like:\n{suggestion}"
        # Extra / unknown field
        if "extra_forbidden" in typ or "extra fields not permitted" in msg.lower():
            return f"Unknown field at `{loc}`. Remove this key or rename it to a \
                valid field."
        # Type error
        if "type_error" in typ or "input_type" in typ or "value_error" in typ:
            expected = cls._extract_expected_type_from_msg(msg)
            return f"Wrong type at `{loc}`. {expected}"
        # Fallback
        nice = msg[0].upper() + msg[1:] if msg else "Invalid value."
        return f"{nice} (at `{loc}`)."

    @staticmethod
    def _extract_expected_type_from_msg(msg: str) -> str:
        # Pydantic v2 error messages often include "Input should be <type>"
        # Keep this short and friendly.
        if msg.lower().startswith("input should be"):
            return msg
        return f"{msg}"

    @classmethod
    def _suggest_example(cls, loc: str, model: type[BaseModel] | None) -> str:
        """Build a minimal YAML example for a missing field by inspecting the model."""
        if not model:
            return f"{cls._yaml_block_for_path(loc, '<value>')}"
        # Walk model_fields using the dotted path if possible
        parts = loc.split(".") if loc else []
        current_model = model
        field_type = None
        try:
            for p in parts:
                fld: fields.FieldInfo = current_model.model_fields[p]
                field_type = fld.annotation
                origin = get_origin(field_type)
                args = get_args(field_type)
                # If nested BaseModel, descend
                if isinstance(fld.annotation, type) and issubclass(fld.annotation, BaseModel):
                    current_model = fld.annotation
                elif origin in (list, tuple) and args and isinstance(args[0], type) and issubclass(args[0], BaseModel):
                    current_model = args[0]  # item model
                # else:
                #     current_model = None  # stop
        except Exception:
            pass

        example_val = cls._example_for_type(field_type)
        return cls._yaml_block_for_path(loc, example_val)

    @staticmethod
    def _yaml_block_for_path(path: str, leaf: str) -> str:
        """Build a tiny YAML block with indentation for a dotted path.

        Return an indented YAML block like:
        parent:
          child: <example>
        """
        if not path:
            return leaf
        parts = path.split(".")
        indent = ""
        lines = []
        for i, p in enumerate(parts):
            if i == len(parts) - 1:
                lines.append(f"{indent}{p}: {leaf}")
            else:
                lines.append(f"{indent}{p}:")
            indent += "  "
        return "\n" + "\n".join(lines)

    @staticmethod
    def _example_for_type(tp: Any) -> str:
        # Heuristic examples; kept simple for lay users
        if tp is None:
            return "<value>"
        origin = get_origin(tp)
        args = get_args(tp)

        def name(t: Any) -> Any:
            """Get a friendly name for a type."""
            try:
                return t.__name__
            except Exception:
                return str(t)

        # Common primitives
        if tp in (int, float):
            return "123" if tp is int else "12.34"
        if tp is bool:
            return "true"
        if tp is str:
            return "<text>"
        # Optionals / Unions
        if origin is Union:
            return f"<{' or '.join(name(a) for a in args)}>"
        # Collections
        if origin in (list, tuple, set):
            inner = _try_example(args[0]) if args else "<item>"
            return f"\n  - {inner}"
        if origin is dict:
            k = _try_example(args[0]) if args else "<key>"
            v = _try_example(args[1]) if len(args) > 1 else "<value>"
            return f"\n  {k}: {v}"
        # Nested models
        try:
            if issubclass(tp, BaseModel):
                return "\n  <subfields…>"
        except Exception:
            pass
        return "<value>"
from_json(source, **kw) classmethod

Instantiate model from a JSON string or a file path with friendly errors.

Source code in dcs_simulation_engine/utils/serde.py
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
@classmethod
def from_json(cls: type[T], source: Union[Mapping[str, Any], str, Path], **kw: Any) -> T:
    """Instantiate model from a JSON string or a file path with friendly errors."""
    # logger.debug(f"Serde called with source type: {type(source)}")
    # logger.debug(f"Source content: {str(source)}")
    if isinstance(source, Mapping):
        logger.debug("Source is a Mapping, using model_validate")
        return cls.model_validate(source, **kw)
    if isinstance(source, Path):
        logger.debug("Source is a Path, reading text")
        text = source.read_text(encoding="utf-8")
        return cls.model_validate_json(text, **kw)
    # string: prefer JSON first; only then try file
    logger.debug("Source is a string, checking content")
    s = source.strip()
    if s.startswith("{") or s.startswith("["):
        return cls.model_validate_json(s, **kw)
    try:
        return cls.model_validate_json(Path(source).read_text(encoding="utf-8"), **kw)
    except OSError:
        # Not a file; assume it's JSON content even if not '{'/'[' prefixed
        return cls.model_validate_json(source, **kw)
from_yaml(source, **validate_kwargs) classmethod

Instantiate model from a YAML string or a file path with friendly errors.

Source code in dcs_simulation_engine/utils/serde.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
@classmethod
def from_yaml(cls: type[T], source: Union[str, Path], **validate_kwargs: Any) -> T:
    """Instantiate model from a YAML string or a file path with friendly errors."""
    if isinstance(source, Path) or (isinstance(source, str) and Path(source).exists()):
        text = Path(source).read_text(encoding="utf-8")
    else:
        text = str(source)

    try:
        data = yaml.safe_load(text) or {}
    except yaml.YAMLError as e:
        raise ValueError(SerdeMixin._format_yaml_syntax_error(e, text)) from e

    try:
        return cls.model_validate(data, **validate_kwargs)
    except ValidationError as e:
        raise ValueError(SerdeMixin._format_validation_error(e, data=data, model=cls)) from e
load_json(path, **validate_kwargs) classmethod

Load model from a JSON file.

Source code in dcs_simulation_engine/utils/serde.py
124
125
126
127
@classmethod
def load_json(cls: type[T], path: Union[str, Path], **validate_kwargs: Any) -> T:
    """Load model from a JSON file."""
    return cls.from_json(Path(path), **validate_kwargs)  # type: ignore
load_yaml(path, **validate_kwargs) classmethod

Load model from a YAML file.

Source code in dcs_simulation_engine/utils/serde.py
129
130
131
132
@classmethod
def load_yaml(cls: type[T], path: Union[str, Path], **validate_kwargs: Any) -> T:
    """Load model from a YAML file."""
    return cls.from_yaml(Path(path), **validate_kwargs)  # type: ignore
save_json(path, **dump_kwargs)

Save model to a JSON file. Returns the Path.

Source code in dcs_simulation_engine/utils/serde.py
112
113
114
115
116
def save_json(self, path: Union[str, Path], **dump_kwargs: Any) -> Path:
    """Save model to a JSON file. Returns the Path."""
    p = Path(path)
    p.write_text(self.to_json(**dump_kwargs), encoding="utf-8")
    return p
save_yaml(path, **dump_kwargs)

Save model to a YAML file. Returns the Path.

Source code in dcs_simulation_engine/utils/serde.py
118
119
120
121
122
def save_yaml(self, path: Union[str, Path], **dump_kwargs: Any) -> Path:
    """Save model to a YAML file. Returns the Path."""
    p = Path(path)
    p.write_text(self.to_yaml(**dump_kwargs), encoding="utf-8")
    return p
to_dict(**dump_kwargs)

Convert model to dict. Pass model_dump kwargs if desired.

Source code in dcs_simulation_engine/utils/serde.py
42
43
44
def to_dict(self, **dump_kwargs: Any) -> dict[str, Any]:
    """Convert model to dict. Pass model_dump kwargs if desired."""
    return self.model_dump(**dump_kwargs)
to_json(**dump_kwargs)

Convert model to JSON string.

Source code in dcs_simulation_engine/utils/serde.py
46
47
48
def to_json(self, **dump_kwargs: Any) -> str:
    """Convert model to JSON string."""
    return self.model_dump_json(**dump_kwargs)
to_yaml(**dump_kwargs)

Convert model to readable YAML.

Pass yaml.safe_dump kwargs if desired (e.g., sort_keys=False).

Source code in dcs_simulation_engine/utils/serde.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def to_yaml(self, **dump_kwargs: Any) -> str:
    """Convert model to readable YAML.

    Pass yaml.safe_dump kwargs if desired (e.g., sort_keys=False).
    """
    # TODO: pre-v001 make export nice yaml not all the /newlines
    data = self.model_dump()
    # readable, block-style YAML; avoid single-line flow; keep key order
    return str(
        yaml.safe_dump(
            data,
            allow_unicode=True,
            sort_keys=False,
            default_flow_style=False,
            width=88,
            **dump_kwargs,
        )
    )

time

UTC time helpers used across runtime and persistence layers.

utc_now()

Return timezone-aware current UTC datetime.

Source code in dcs_simulation_engine/utils/time.py
6
7
8
def utc_now() -> datetime:
    """Return timezone-aware current UTC datetime."""
    return datetime.now(timezone.utc)