Skip to content

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, run_config=None, run_config_path=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
 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
def create_app(
    *,
    provider: DataProvider | object | None = None,
    mongo_uri: str | None = None,
    shutdown_dump_dir: Path | None = None,
    run_config: RunConfig | None = None,
    run_config_path: Path | 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."""
    active_run_config = run_config or RunConfig.load(run_config_path or DEFAULT_RUN_CONFIG_PATH)
    validate_run_config_references(active_run_config)
    registry = SessionRegistry(ttl_seconds=session_ttl_seconds, sweep_interval_seconds=sweep_interval_seconds)
    engine_run_manager = EngineRunManager(run_config=active_run_config, provider=provider)
    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.log_capture = _create_log_capture(
            provider=app.state.provider,
            run_name=active_run_config.name,
        )
        if app.state.log_capture is not None:
            await app.state.log_capture.start()
            app.state.log_capture.install()
        app.state.started_at = utc_now()
        registry.set_provider(app.state.provider)
        app.state.engine_run_manager.provider = app.state.provider
        await app.state.engine_run_manager.ensure_run_async(provider=app.state.provider)
        SessionManager.configure_run_config(app.state.run_config)
        SessionManager.preload_game_configs()
        await registry.start()
        try:
            yield
        finally:
            await registry.stop()
            if app.state.log_capture is not None:
                await app.state.log_capture.close()
            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.run_config = active_run_config
    app.state.engine_run_manager = engine_run_manager
    app.state.mongo_uri = mongo_uri
    app.state.remote_management_enabled = remote_management_enabled
    app.state.bootstrap_token = bootstrap_token
    app.state.log_capture = None

    app.include_router(users_router)
    app.include_router(sessions_router)
    app.include_router(play_router)
    app.include_router(run_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 this deployment."""
        return build_server_config(
            run_name=active_run_config.name,
            registration_required=active_run_config.registration_required,
        )

    @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, run_config=None, run_config_path=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
 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
def create_app(
    *,
    provider: DataProvider | object | None = None,
    mongo_uri: str | None = None,
    shutdown_dump_dir: Path | None = None,
    run_config: RunConfig | None = None,
    run_config_path: Path | 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."""
    active_run_config = run_config or RunConfig.load(run_config_path or DEFAULT_RUN_CONFIG_PATH)
    validate_run_config_references(active_run_config)
    registry = SessionRegistry(ttl_seconds=session_ttl_seconds, sweep_interval_seconds=sweep_interval_seconds)
    engine_run_manager = EngineRunManager(run_config=active_run_config, provider=provider)
    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.log_capture = _create_log_capture(
            provider=app.state.provider,
            run_name=active_run_config.name,
        )
        if app.state.log_capture is not None:
            await app.state.log_capture.start()
            app.state.log_capture.install()
        app.state.started_at = utc_now()
        registry.set_provider(app.state.provider)
        app.state.engine_run_manager.provider = app.state.provider
        await app.state.engine_run_manager.ensure_run_async(provider=app.state.provider)
        SessionManager.configure_run_config(app.state.run_config)
        SessionManager.preload_game_configs()
        await registry.start()
        try:
            yield
        finally:
            await registry.stop()
            if app.state.log_capture is not None:
                await app.state.log_capture.close()
            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.run_config = active_run_config
    app.state.engine_run_manager = engine_run_manager
    app.state.mongo_uri = mongo_uri
    app.state.remote_management_enabled = remote_management_enabled
    app.state.bootstrap_token = bootstrap_token
    app.state.log_capture = None

    app.include_router(users_router)
    app.include_router(sessions_router)
    app.include_router(play_router)
    app.include_router(run_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 this deployment."""
        return build_server_config(
            run_name=active_run_config.name,
            registration_required=active_run_config.registration_required,
        )

    @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
86
87
88
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
91
92
93
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
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
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
119
120
121
122
123
124
125
126
127
128
129
130
131
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(*, run_name, registration_required=True)

Translate server settings into frontend-readable capability flags.

Source code in dcs_simulation_engine/api/auth.py
44
45
46
47
48
49
50
51
52
53
54
def build_server_config(
    *,
    run_name: str,
    registration_required: bool = True,
) -> ServerConfigResponse:
    """Translate server settings into frontend-readable capability flags."""
    return ServerConfigResponse(
        authentication_required=registration_required,
        registration_enabled=registration_required,
        run_name=run_name,
    )
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
14
15
16
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
24
25
26
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
19
20
21
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
29
30
31
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)
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
68
69
70
71
72
73
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
63
64
65
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 remote management is enabled for this request.

Source code in dcs_simulation_engine/api/auth.py
34
35
36
def is_remote_management_enabled_from_request(request: Request) -> bool:
    """Return whether remote management is enabled for this request."""
    return bool(getattr(request.app.state, "remote_management_enabled", False))
is_remote_management_enabled_from_websocket(websocket)

Return whether remote management is enabled for this websocket.

Source code in dcs_simulation_engine/api/auth.py
39
40
41
def is_remote_management_enabled_from_websocket(websocket: WebSocket) -> bool:
    """Return whether remote management is enabled 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
111
112
113
114
115
116
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
134
135
136
137
138
139
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
142
143
144
145
146
147
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
57
58
59
60
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)

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
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
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 anonymous_player(self) -> RegistrationResponse:
        """Create an anonymous player for runs that do not require registration."""
        return self._request("POST", "/api/player/anonymous", None, 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 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 branch_session(self, session_id: str, *, api_key: Optional[str] = None) -> SimulationRun:
        """Create a paused child branch session and return a resumed-style SimulationRun."""
        key = self._resolve_api_key(api_key, required=False)
        headers: dict[str, str] = {}
        if key:
            headers["Authorization"] = f"Bearer {key}"
        response = self._request(
            "POST",
            f"/api/sessions/{session_id}/branch",
            None,
            BranchSessionResponse,
            headers=headers,
        )
        return SimulationRun(
            client=self,
            session_id=response.session_id,
            game_name=response.game_name,
            api_key=key,
            resume_on_first_connect=True,
        )

    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
        | WSSessionMetaFrame
        | WSReplayStartFrame
        | WSReplayEventFrame
        | WSReplayEndFrame
        | 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 == "session_meta":
            return WSSessionMetaFrame.model_validate(data)
        if frame_type == "event":
            return WSEventFrame.model_validate(data)
        if frame_type == "replay_start":
            return WSReplayStartFrame.model_validate(data)
        if frame_type == "replay_event":
            return WSReplayEventFrame.model_validate(data)
        if frame_type == "replay_end":
            return WSReplayEndFrame.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_session_meta(self, ws: Any) -> WSSessionMetaFrame:
        """Receive the required session metadata frame sent at the start of each WS connection."""
        frame = self._recv_frame(ws)
        if not isinstance(frame, WSSessionMetaFrame):
            raise APIRequestError("Expected session_meta websocket frame")
        return frame

    def _drain_replay(self, ws: Any, *, replay_start_consumed: bool = False) -> None:
        """Discard the replay burst the server sends when resuming a paused session."""
        if not replay_start_consumed:
            frame = self._recv_frame(ws)
            if not isinstance(frame, WSReplayStartFrame):
                raise APIRequestError("Expected replay_start websocket frame")
        while True:
            frame = self._recv_frame(ws)
            if isinstance(frame, WSReplayEventFrame):
                continue
            if isinstance(frame, WSReplayEndFrame):
                return
            raise APIRequestError("Expected replay_event or replay_end websocket frame")

    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, WSReplayStartFrame):
                self._drain_replay(ws, replay_start_consumed=True)
                continue
            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,
        expect_replay: bool = False,
    ) -> tuple[WSSessionMetaFrame, 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:
            session_meta = self._recv_session_meta(ws)
            if expect_replay:
                self._drain_replay(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 session_meta, events, turn_end

    def _ws_status(
        self,
        *,
        session_id: str,
        api_key: str | None,
        include_opening: bool,
        expect_replay: bool = False,
    ) -> tuple[WSSessionMetaFrame, 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:
            session_meta = self._recv_session_meta(ws)
            if expect_replay:
                self._drain_replay(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())
            while True:
                frame = self._recv_frame(ws)
                if isinstance(frame, WSReplayStartFrame):
                    self._drain_replay(ws, replay_start_consumed=True)
                    continue
                break

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

        return session_meta, frame

    def _ws_close(
        self,
        *,
        session_id: str,
        api_key: str | None,
        include_opening: bool,
        expect_replay: bool = False,
    ) -> WSSessionMetaFrame:
        """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:
            session_meta = self._recv_session_meta(ws)
            if expect_replay:
                self._drain_replay(ws)
            if include_opening:
                self._recv_until_turn_end(ws)
            ws.send(WSCloseRequest(type="close").model_dump_json())
            while True:
                frame = self._recv_frame(ws)
                if isinstance(frame, WSReplayStartFrame):
                    self._drain_replay(ws, replay_start_consumed=True)
                    continue
                break
            if not isinstance(frame, WSClosedFrame):
                raise APIRequestError("Expected closed websocket frame")
        return session_meta
__enter__()

Enter the context manager.

Source code in dcs_simulation_engine/api/client.py
175
176
177
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
179
180
181
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
165
166
167
168
169
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)
anonymous_player()

Create an anonymous player for runs that do not require registration.

Source code in dcs_simulation_engine/api/client.py
187
188
189
def anonymous_player(self) -> RegistrationResponse:
    """Create an anonymous player for runs that do not require registration."""
    return self._request("POST", "/api/player/anonymous", None, RegistrationResponse)
auth(*, api_key=None)

Validate an API key and return player_id + authenticated.

Source code in dcs_simulation_engine/api/client.py
191
192
193
194
195
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)
branch_session(session_id, *, api_key=None)

Create a paused child branch session and return a resumed-style SimulationRun.

Source code in dcs_simulation_engine/api/client.py
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
def branch_session(self, session_id: str, *, api_key: Optional[str] = None) -> SimulationRun:
    """Create a paused child branch session and return a resumed-style SimulationRun."""
    key = self._resolve_api_key(api_key, required=False)
    headers: dict[str, str] = {}
    if key:
        headers["Authorization"] = f"Bearer {key}"
    response = self._request(
        "POST",
        f"/api/sessions/{session_id}/branch",
        None,
        BranchSessionResponse,
        headers=headers,
    )
    return SimulationRun(
        client=self,
        session_id=response.session_id,
        game_name=response.game_name,
        api_key=key,
        resume_on_first_connect=True,
    )
close()

Close the underlying HTTP client transport.

Source code in dcs_simulation_engine/api/client.py
171
172
173
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
221
222
223
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
229
230
231
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
274
275
276
277
278
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
217
218
219
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
213
214
215
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
201
202
203
204
205
206
207
208
209
210
211
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
183
184
185
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)
server_config()

Fetch server capability flags for the active runtime mode.

Source code in dcs_simulation_engine/api/client.py
197
198
199
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
260
261
262
263
264
265
266
267
268
269
270
271
272
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
233
234
235
236
237
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
225
226
227
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
 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 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,
        *,
        resume_on_first_connect: bool = False,
    ) -> 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._resume_on_first_connect = resume_on_first_connect
        self._opened = False
        self._events: list[WSEventFrame] = []
        self._session_meta: WSSessionMetaFrame | None = None
        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
            session_meta, events, turn_end = self._client._ws_open_and_advance(
                session_id=self.session_id,
                api_key=self._api_key,
                text=first_text,
                include_opening=not self._resume_on_first_connect,
                expect_replay=self._resume_on_first_connect,
            )
            self._opened = True
        else:
            session_meta, 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,
                expect_replay=True,
            )

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

    def get_state(self) -> Self:
        """Fetch current session status without advancing a turn."""
        session_meta, status_frame = self._client._ws_status(
            session_id=self.session_id,
            api_key=self._api_key,
            include_opening=(not self._opened) and (not self._resume_on_first_connect),
            expect_replay=self._resume_on_first_connect or self._opened,
        )
        self._opened = True
        self._session_meta = session_meta
        # 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,
            exit_reason=status_frame.exit_reason,
        )
        return self

    def close(self) -> None:
        """Close the server-side session."""
        self._session_meta = self._client._ws_close(
            session_id=self.session_id,
            api_key=self._api_key,
            include_opening=(not self._opened) and (not self._resume_on_first_connect),
            expect_replay=self._resume_on_first_connect or 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 session_meta(self) -> WSSessionMetaFrame | None:
        """Most recent session metadata frame received from the server."""
        return self._session_meta

    @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.

session_meta property

Most recent session metadata frame received from the server.

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
67
68
69
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
71
72
73
74
75
76
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, *, resume_on_first_connect=False)

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

Source code in dcs_simulation_engine/api/client.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def __init__(
    self,
    client: "APIClient",
    session_id: str,
    game_name: str,
    api_key: str | None,
    *,
    resume_on_first_connect: bool = False,
) -> 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._resume_on_first_connect = resume_on_first_connect
    self._opened = False
    self._events: list[WSEventFrame] = []
    self._session_meta: WSSessionMetaFrame | None = None
    self._turn_end: WSTurnEndFrame | None = None
close()

Close the server-side session.

Source code in dcs_simulation_engine/api/client.py
123
124
125
126
127
128
129
130
131
def close(self) -> None:
    """Close the server-side session."""
    self._session_meta = self._client._ws_close(
        session_id=self.session_id,
        api_key=self._api_key,
        include_opening=(not self._opened) and (not self._resume_on_first_connect),
        expect_replay=self._resume_on_first_connect or self._opened,
    )
    self._opened = True
get_state()

Fetch current session status without advancing a turn.

Source code in dcs_simulation_engine/api/client.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def get_state(self) -> Self:
    """Fetch current session status without advancing a turn."""
    session_meta, status_frame = self._client._ws_status(
        session_id=self.session_id,
        api_key=self._api_key,
        include_opening=(not self._opened) and (not self._resume_on_first_connect),
        expect_replay=self._resume_on_first_connect or self._opened,
    )
    self._opened = True
    self._session_meta = session_meta
    # 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,
        exit_reason=status_frame.exit_reason,
    )
    return self
step(user_input='')

Advance the simulation by one step.

Source code in dcs_simulation_engine/api/client.py
 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
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
        session_meta, events, turn_end = self._client._ws_open_and_advance(
            session_id=self.session_id,
            api_key=self._api_key,
            text=first_text,
            include_opening=not self._resume_on_first_connect,
            expect_replay=self._resume_on_first_connect,
        )
        self._opened = True
    else:
        session_meta, 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,
            expect_replay=True,
        )

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

models

Pydantic models and payload parsers for the API layer.

AssignmentSessionRequest

Bases: BaseModel

Payload for creating a session from the current assignment.

Source code in dcs_simulation_engine/api/models.py
270
271
272
273
274
class AssignmentSessionRequest(BaseModel):
    """Payload for creating a session from the current assignment."""

    source: str = Field(default="run", min_length=1)
    assignment_id: str | None = None
AssignmentSummary

Bases: BaseModel

Assignment summary returned by run endpoints.

Source code in dcs_simulation_engine/api/models.py
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
class AssignmentSummary(BaseModel):
    """Assignment summary returned by run endpoints."""

    assignment_id: str
    game_name: str
    pc_hid: str
    npc_hid: str
    status: AssignmentStatus
    active_session_id: str | None = None
    has_pending_forms: bool = False
    game_description: str = ""
    player_character_name: str = ""
    player_character_description: str = ""
    simulator_character_description: str = ""
    simulator_character_details_visible: bool = False
AuthRequest

Bases: BaseModel

Payload for API-key authentication checks.

Source code in dcs_simulation_engine/api/models.py
44
45
46
47
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
50
51
52
53
54
55
class AuthResponse(BaseModel):
    """Response payload for successful API-key auth."""

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

Bases: BaseModel

Response payload for a branched paused child session.

Source code in dcs_simulation_engine/api/models.py
111
112
113
114
115
116
117
118
class BranchSessionResponse(BaseModel):
    """Response payload for a branched paused child session."""

    session_id: str
    branch_from_session_id: str
    game_name: str
    status: SessionStatus
    ws_path: str
CharacterChoice

Bases: BaseModel

A selectable character option for setup screens.

Source code in dcs_simulation_engine/api/models.py
121
122
123
124
125
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
471
472
473
474
475
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
478
479
480
481
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
324
325
326
327
328
329
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
 93
 94
 95
 96
 97
 98
 99
100
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
103
104
105
106
107
108
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
497
498
499
500
class DeleteCharacterResponse(BaseModel):
    """Response payload after deletion."""

    character_id: str
EligibleAssignmentOption

Bases: BaseModel

One eligible game+PC+NPC option returned when assignment choice is allowed.

Source code in dcs_simulation_engine/api/models.py
228
229
230
231
232
233
234
235
236
237
238
class EligibleAssignmentOption(BaseModel):
    """One eligible game+PC+NPC option returned when assignment choice is allowed."""

    game_name: str
    pc_hid: str
    npc_hid: str
    game_description: str = ""
    player_character_name: str = ""
    player_character_description: str = ""
    simulator_character_description: str = ""
    simulator_character_details_visible: bool = False
EligibleAssignmentOptionsResponse

Bases: BaseModel

List of eligible assignment options for a player.

Source code in dcs_simulation_engine/api/models.py
241
242
243
244
class EligibleAssignmentOptionsResponse(BaseModel):
    """List of eligible assignment options for a player."""

    options: list[EligibleAssignmentOption]
FormSubmitRequest

Bases: BaseModel

Payload for submitting one pending run form group.

Source code in dcs_simulation_engine/api/models.py
255
256
257
258
259
class FormSubmitRequest(BaseModel):
    """Payload for submitting one pending run form group."""

    group_id: str = Field(min_length=1)
    responses: dict[str, dict]
FormSubmitResponse

Bases: BaseModel

Response after storing one pending run form group.

Source code in dcs_simulation_engine/api/models.py
262
263
264
265
266
267
class FormSubmitResponse(BaseModel):
    """Response after storing one pending run form group."""

    group_id: str
    trigger: FormTriggerResponse
    assignment_id: str | None = None
FormTriggerResponse

Bases: BaseModel

Canonical form trigger returned by setup APIs.

Source code in dcs_simulation_engine/api/models.py
191
192
193
194
195
class FormTriggerResponse(BaseModel):
    """Canonical form trigger returned by setup APIs."""

    event: FormTriggerEvent
    match: None = None
GameSetupOptionsResponse

Bases: BaseModel

Preflight setup data for a specific game + authenticated player.

Source code in dcs_simulation_engine/api/models.py
128
129
130
131
132
133
134
135
136
137
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]
GameStatusResponse

Bases: BaseModel

Per-game status counts for a run.

Source code in dcs_simulation_engine/api/models.py
165
166
167
168
169
170
class GameStatusResponse(BaseModel):
    """Per-game status counts for a run."""

    total: int
    completed: int
    in_progress: int
GameSummary

Bases: BaseModel

A single game entry.

Source code in dcs_simulation_engine/api/models.py
457
458
459
460
461
462
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
465
466
467
468
class GamesListResponse(BaseModel):
    """Response payload for games list endpoint."""

    games: list[GameSummary]
NextAssignmentState

Bases: BaseModel

Backend-derived state for the next participant action.

Source code in dcs_simulation_engine/api/models.py
182
183
184
185
186
187
188
class NextAssignmentState(BaseModel):
    """Backend-derived state for the next participant action."""

    mode: NextAssignmentMode
    reason: str = ""
    assignment: AssignmentSummary | None = None
    options: list["EligibleAssignmentOption"] = Field(default_factory=list)
PendingFormGroupResponse

Bases: BaseModel

Actionable group of forms the participant must submit.

Source code in dcs_simulation_engine/api/models.py
198
199
200
201
202
203
204
class PendingFormGroupResponse(BaseModel):
    """Actionable group of forms the participant must submit."""

    group_id: str
    trigger: FormTriggerResponse
    forms: list[dict] = Field(default_factory=list)
    assignment_id: str | None = None
ProgressResponse

Bases: BaseModel

Finite progress payload for the usability run.

Source code in dcs_simulation_engine/api/models.py
157
158
159
160
161
162
class ProgressResponse(BaseModel):
    """Finite progress payload for the usability run."""

    total: int
    completed: int
    is_complete: bool
RegistrationRequest

Bases: BaseModel

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

Source code in dcs_simulation_engine/api/models.py
29
30
31
32
33
34
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)
RegistrationResponse

Bases: BaseModel

Response payload for registration.

Source code in dcs_simulation_engine/api/models.py
37
38
39
40
41
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
74
75
76
77
78
79
class RemoteBootstrapResponse(BaseModel):
    """Bootstrap response containing the newly issued remote admin key."""

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

Bases: BaseModel

Public status payload for remote-managed deployments.

Source code in dcs_simulation_engine/api/models.py
82
83
84
85
86
87
88
89
90
class RemoteStatusResponse(BaseModel):
    """Public status payload for remote-managed deployments."""

    status: Literal["ok"] = "ok"
    started_at: datetime
    uptime: int
    run_name: str
    progress: "ProgressResponse | None" = None
    run_status: "RunStatusResponse | None" = None
RunStatusResponse

Bases: BaseModel

Aggregate status payload for a run.

Source code in dcs_simulation_engine/api/models.py
173
174
175
176
177
178
179
class RunStatusResponse(BaseModel):
    """Aggregate status payload for a run."""

    is_open: bool
    total: int
    completed: int
    per_game: dict[str, GameStatusResponse]
SelectAssignmentRequest

Bases: BaseModel

Payload for player-directed assignment selection.

Source code in dcs_simulation_engine/api/models.py
247
248
249
250
251
252
class SelectAssignmentRequest(BaseModel):
    """Payload for player-directed assignment selection."""

    game_name: str
    pc_hid: str
    npc_hid: str
ServerConfigResponse

Bases: BaseModel

Response payload describing server capabilities.

Source code in dcs_simulation_engine/api/models.py
58
59
60
61
62
63
class ServerConfigResponse(BaseModel):
    """Response payload describing server capabilities."""

    authentication_required: bool
    registration_enabled: bool
    run_name: str
SessionEventFeedback

Bases: BaseModel

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

Source code in dcs_simulation_engine/api/models.py
295
296
297
298
299
300
301
302
303
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
277
278
279
280
281
282
283
284
285
286
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
289
290
291
292
class SessionsListResponse(BaseModel):
    """Response payload for session list endpoint."""

    sessions: list[SessionSummary]
SetupResponse

Bases: BaseModel

Setup payload for the run landing page.

Source code in dcs_simulation_engine/api/models.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
class SetupResponse(BaseModel):
    """Setup payload for the run landing page."""

    run_name: str
    description: str
    is_open: bool
    forms: list[dict] = Field(default_factory=list)
    pending_form_groups: list[PendingFormGroupResponse] = Field(default_factory=list)
    progress: ProgressResponse
    current_assignment: AssignmentSummary | None = None
    # True only when the participant has exhausted all assignments available to them.
    assignment_completed: bool = False
    next_assignment: NextAssignmentState | None = None
    allow_choice_if_multiple: bool = False
    require_completion: bool = True
    eligible_assignment_options: list["EligibleAssignmentOption"] = Field(default_factory=list)
    assignments: list[AssignmentSummary] = Field(default_factory=list)
    # Set when the current assignment has a paused session the player can resume.
    resumable_session_id: str | None = None
StatusResponse

Bases: BaseModel

Response payload describing process liveness and uptime.

Source code in dcs_simulation_engine/api/models.py
66
67
68
69
70
71
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
306
307
308
309
310
311
312
313
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
316
317
318
319
320
321
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
484
485
486
487
488
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
491
492
493
494
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
339
340
341
342
343
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
332
333
334
335
336
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
352
353
354
355
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
408
409
410
411
412
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
415
416
417
418
419
420
421
422
423
class WSErrorFrame(BaseModel):
    """WebSocket frame describing a protocol or auth error."""

    type: Literal["error"] = "error"
    detail: str
    failure_type: FailureType | None = None
    provider: str | None = None
    provider_status_code: int | None = None
    provider_code: str | None = None
WSEventFrame

Bases: BaseModel

WebSocket frame representing a single game event.

Source code in dcs_simulation_engine/api/models.py
371
372
373
374
375
376
377
378
379
380
381
382
383
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
    failure_type: FailureType | None = None
    retries_remaining: int | None = None
    provider: str | None = None
    provider_status_code: int | None = None
    provider_code: 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
449
450
451
452
453
454
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
433
434
435
436
437
438
439
440
441
442
443
444
445
446
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"
    failure_type: FailureType | None = None
    retries_remaining: int | None = None
    provider: str | None = None
    provider_status_code: int | None = None
    provider_code: str | None = None
WSReplayStartFrame

Bases: BaseModel

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

Source code in dcs_simulation_engine/api/models.py
426
427
428
429
430
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
361
362
363
364
365
366
367
368
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
397
398
399
400
401
402
403
404
405
class WSStatusFrame(BaseModel):
    """WebSocket frame reporting current session status."""

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

Bases: BaseModel

WebSocket frame for requesting session status.

Source code in dcs_simulation_engine/api/models.py
346
347
348
349
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
386
387
388
389
390
391
392
393
394
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
    failure_type: FailureType | None = None
    exit_reason: str | None = None
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
503
504
505
506
507
508
509
510
511
512
513
514
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
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
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
@dataclass
class SessionEntry:
    """Represents one in-memory API session record."""

    session_id: str
    player_id: str | None
    game_name: str
    manager: SessionManager
    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
32
33
34
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
 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
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._pending_hydration: set[str] = set()
        self._sweep_task: asyncio.Task[None] | None = None
        self._provider: Any = None

    def add(
        self,
        *,
        player_id: str | None,
        game_name: str,
        manager: SessionManager,
        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,
            assignment_id=assignment_id,
        )
        with self._lock:
            self._store[session_id] = entry
        logger.info("Session {} created ({} active)", session_id, self.size)
        return entry

    def reinsert(self, session_id: str, entry: SessionEntry) -> None:
        """Re-add a hydrated session under its original session_id.

        Used after a process restart to restore a paused session that was
        evicted from the in-memory registry.  Raises ``ValueError`` if a live
        entry already exists for this id (prevents a hydration race from
        overwriting an already-connected session).
        """
        with self._lock:
            if session_id in self._store:
                raise ValueError(f"Session {session_id} already exists in registry; skipping reinsert.")
            self._pending_hydration.discard(session_id)
            self._store[session_id] = entry
        logger.info("Session {} reinserted from snapshot ({} active)", session_id, self.size)

    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 claim_hydration(self, session_id: str) -> bool:
        """Mark session_id as being hydrated.

        Returns True if this caller won
        the race; False if another coroutine is already hydrating it.
        """
        with self._lock:
            if session_id in self._store or session_id in self._pending_hydration:
                return False
            self._pending_hydration.add(session_id)
            return True

    def release_hydration(self, session_id: str) -> None:
        """Remove the pending-hydration marker (called on success or failure)."""
        with self._lock:
            self._pending_hydration.discard(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 {} removed ({} 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, provider: Any = None) -> list[str]:
        """Async sweep variant that awaits async session finalization when available.

        When ``provider`` is given, expired sessions that belong to an
        run assignment are marked ``interrupted`` so the player can
        start that assignment again.
        """
        from dcs_simulation_engine.utils.async_utils import maybe_await

        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: {}", session_id)

            if provider is not None and entry.assignment_id is not None:
                try:
                    await maybe_await(
                        provider.update_assignment_status(
                            assignment_id=entry.assignment_id,
                            status="interrupted",
                        )
                    )
                    logger.info(
                        "Marked assignment {} interrupted after TTL expiry of session {}.",
                        entry.assignment_id,
                        session_id,
                    )
                except Exception:
                    logger.exception(
                        "Failed to mark assignment {} interrupted after TTL expiry of session {}.",
                        entry.assignment_id,
                        session_id,
                    )

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

    def set_provider(self, provider: Any) -> None:
        """Attach a data provider so the TTL sweep can mark assignments interrupted."""
        self._provider = provider

    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(provider=self._provider)
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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._pending_hydration: set[str] = set()
    self._sweep_task: asyncio.Task[None] | None = None
    self._provider: Any = None
add(*, player_id, game_name, manager, assignment_id=None)

Create and store a new session entry.

Source code in dcs_simulation_engine/api/registry.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
def add(
    self,
    *,
    player_id: str | None,
    game_name: str,
    manager: SessionManager,
    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,
        assignment_id=assignment_id,
    )
    with self._lock:
        self._store[session_id] = entry
    logger.info("Session {} created ({} active)", session_id, self.size)
    return entry
claim_hydration(session_id)

Mark session_id as being hydrated.

Returns True if this caller won the race; False if another coroutine is already hydrating it.

Source code in dcs_simulation_engine/api/registry.py
 97
 98
 99
100
101
102
103
104
105
106
107
def claim_hydration(self, session_id: str) -> bool:
    """Mark session_id as being hydrated.

    Returns True if this caller won
    the race; False if another coroutine is already hydrating it.
    """
    with self._lock:
        if session_id in self._store or session_id in self._pending_hydration:
            return False
        self._pending_hydration.add(session_id)
        return True
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
159
160
161
162
163
164
165
166
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
92
93
94
95
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
114
115
116
117
118
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
127
128
129
130
131
132
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
134
135
136
137
138
139
140
141
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()
reinsert(session_id, entry)

Re-add a hydrated session under its original session_id.

Used after a process restart to restore a paused session that was evicted from the in-memory registry. Raises ValueError if a live entry already exists for this id (prevents a hydration race from overwriting an already-connected session).

Source code in dcs_simulation_engine/api/registry.py
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def reinsert(self, session_id: str, entry: SessionEntry) -> None:
    """Re-add a hydrated session under its original session_id.

    Used after a process restart to restore a paused session that was
    evicted from the in-memory registry.  Raises ``ValueError`` if a live
    entry already exists for this id (prevents a hydration race from
    overwriting an already-connected session).
    """
    with self._lock:
        if session_id in self._store:
            raise ValueError(f"Session {session_id} already exists in registry; skipping reinsert.")
        self._pending_hydration.discard(session_id)
        self._store[session_id] = entry
    logger.info("Session {} reinserted from snapshot ({} active)", session_id, self.size)
release_hydration(session_id)

Remove the pending-hydration marker (called on success or failure).

Source code in dcs_simulation_engine/api/registry.py
109
110
111
112
def release_hydration(self, session_id: str) -> None:
    """Remove the pending-hydration marker (called on success or failure)."""
    with self._lock:
        self._pending_hydration.discard(session_id)
remove(session_id)

Remove and return a session entry if it exists.

Source code in dcs_simulation_engine/api/registry.py
168
169
170
171
172
173
174
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 {} removed ({} 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
143
144
145
146
147
148
149
150
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_provider(provider)

Attach a data provider so the TTL sweep can mark assignments interrupted.

Source code in dcs_simulation_engine/api/registry.py
227
228
229
def set_provider(self, provider: Any) -> None:
    """Attach a data provider so the TTL sweep can mark assignments interrupted."""
    self._provider = provider
set_ws_connected(session_id, connected)

Update the WebSocket connection flag for a session.

Source code in dcs_simulation_engine/api/registry.py
152
153
154
155
156
157
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
231
232
233
234
235
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
237
238
239
240
241
242
243
244
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(provider=None) async

Async sweep variant that awaits async session finalization when available.

When provider is given, expired sessions that belong to an run assignment are marked interrupted so the player can start that assignment again.

Source code in dcs_simulation_engine/api/registry.py
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
async def sweep_async(self, provider: Any = None) -> list[str]:
    """Async sweep variant that awaits async session finalization when available.

    When ``provider`` is given, expired sessions that belong to an
    run assignment are marked ``interrupted`` so the player can
    start that assignment again.
    """
    from dcs_simulation_engine.utils.async_utils import maybe_await

    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: {}", session_id)

        if provider is not None and entry.assignment_id is not None:
            try:
                await maybe_await(
                    provider.update_assignment_status(
                        assignment_id=entry.assignment_id,
                        status="interrupted",
                    )
                )
                logger.info(
                    "Marked assignment {} interrupted after TTL expiry of session {}.",
                    entry.assignment_id,
                    session_id,
                )
            except Exception:
                logger.exception(
                    "Failed to mark assignment {} interrupted after TTL expiry of session {}.",
                    entry.assignment_id,
                    session_id,
                )

    if stale_ids:
        logger.warning("Swept {} 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
120
121
122
123
124
125
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()
hydrate_session_async(*, session_id, player_id, provider, registry) async

Reconstruct a dormant paused session from DB and insert it into the registry.

Returns the hydrated SessionEntry on success, or None if: - the session is not found / not paused in the DB, - another coroutine won the hydration race, - the snapshot schema is unsupported (logs a warning).

The returned entry has opening_sent=True and status="paused" so the WS handler sends replay events instead of regenerating the opening turn.

Source code in dcs_simulation_engine/api/registry.py
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
async def hydrate_session_async(
    *,
    session_id: str,
    player_id: str | None,
    provider: Any,
    registry: "SessionRegistry",
) -> SessionEntry | None:
    """Reconstruct a dormant paused session from DB and insert it into the registry.

    Returns the hydrated ``SessionEntry`` on success, or ``None`` if:
    - the session is not found / not paused in the DB,
    - another coroutine won the hydration race,
    - the snapshot schema is unsupported (logs a warning).

    The returned entry has ``opening_sent=True`` and ``status="paused"`` so the
    WS handler sends replay events instead of regenerating the opening turn.
    """
    from dcs_simulation_engine.core.session_manager import SessionManager
    from dcs_simulation_engine.dal.mongo.const import MongoColumns
    from dcs_simulation_engine.utils.async_utils import maybe_await

    if not registry.claim_hydration(session_id):
        logger.info("Session {} hydration already in progress; skipping duplicate attempt.", session_id)
        return None

    try:
        session_record = await maybe_await(provider.get_session(session_id=session_id, player_id=player_id))
        if session_record is None:
            logger.info("Session {} not found in DB; cannot hydrate.", session_id)
            return None
        if session_record.status != "paused":
            logger.info(
                "Session {} has status={!r}; only paused sessions can be hydrated.",
                session_id,
                session_record.status,
            )
            return None

        runtime_state = session_record.data.get(MongoColumns.RUNTIME_STATE)
        if not runtime_state:
            logger.warning("Session {} has no runtime_state snapshot; cannot hydrate.", session_id)
            return None

        try:
            manager = await SessionManager.create_from_snapshot(
                snapshot=runtime_state,
                session_record=session_record,
                provider=provider,
            )
        except ValueError as exc:
            logger.warning("Session {} hydration failed: {}", session_id, exc)
            return None

        assignment_record = await maybe_await(provider.get_assignment_for_session_id(session_id=session_id))

        entry = SessionEntry(
            session_id=session_id,
            player_id=session_record.player_id,
            game_name=session_record.game_name,
            manager=manager,
            assignment_id=getattr(assignment_record, "assignment_id", None) if assignment_record else None,
            status="paused",
            opening_sent=True,
        )

        try:
            registry.reinsert(session_id, entry)
        except ValueError:
            # Another coroutine won the race and already inserted; use theirs.
            logger.info("Session {} was inserted by a concurrent hydration; discarding duplicate.", session_id)
            return registry.get(session_id)

        logger.info("Session {} hydrated from snapshot successfully.", session_id)
        return entry

    finally:
        registry.release_hydration(session_id)

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)
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
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
@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)
    player = await require_player_async(provider=provider, api_key=body.api_key)
    player_id = player.id
    await _reject_if_run_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
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
@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)

    try:
        # 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)

        entry = registry.get(session_id)
        if entry is None:
            # Session may be dormant after a process restart — attempt to
            # hydrate it from the persisted runtime snapshot before giving up.
            entry = await hydrate_session_async(
                session_id=session_id,
                player_id=player.id,
                provider=provider,
                registry=registry,
            )
        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 entry.player_id != player.id:
            await _send_error(websocket, "Unauthorized for this session")
            await websocket.close()
            return

        if _session_status(entry.status, entry.manager.exited) == "closed":
            await _send_error(websocket, "Session is closed")
            await websocket.close()
            return

        assignment_status = await _terminal_assignment_status(provider=provider, assignment_id=entry.assignment_id)
        if assignment_status is not None:
            registry.close(session_id)
            await _send_error(websocket, "Session is closed")
            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.trigger.event == "after_assignment" 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_run_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,
                failure_type=_last_failure_type(opening_events),
                exit_reason=entry.manager.exit_reason if entry.manager.exited else None,
            )

        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_run_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,
                    failure_type=_last_failure_type(events),
                    exit_reason=entry.manager.exit_reason if entry.manager.exited else None,
                )
                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,
                    exit_reason=entry.manager.exit_reason if entry.manager.exited else None,
                )
                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_run_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 ModelProviderError as exc:
        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=MODEL_PROVIDER_ERROR,
                        session_id=session_id,
                    )
                    registry.close(session_id)
                    await _sync_run_assignment_if_needed(provider=provider, entry=entry)
                except Exception:
                    logger.exception("Failed to finalize session after model provider websocket error: {}", session_id)
        logger.error("Model provider websocket error for session {}: {}", session_id, exc.user_message)
        try:
            await _send_model_provider_error(websocket, exc)
            await websocket.close()
        except Exception:
            logger.debug("WebSocket already closed while sending model provider error frame")

    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_run_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", failure_type="internal_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
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
@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)
    player = await require_player_async(provider=provider, api_key=api_key_from_request(request))
    player_id = player.id
    await _reject_if_run_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
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
@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")

    run_name = request.app.state.run_config.name
    await EngineRunManager.ensure_run_async(provider=provider)

    return RemoteBootstrapResponse(
        player_id=record.id,
        admin_api_key=api_key,
        run_name=run_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
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
@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)

    run_name = request.app.state.run_config.name
    filename = f"{run_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 run deployments.

Source code in dcs_simulation_engine/api/routers/remote.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
272
@router.get("/status", response_model=RemoteStatusResponse)
async def remote_status(request: Request) -> RemoteStatusResponse:
    """Return a public status summary for remote-managed run deployments."""
    started_at = request.app.state.started_at
    uptime = int((utc_now() - started_at).total_seconds())
    run_name = request.app.state.run_config.name
    provider = get_provider_from_request(request)

    try:
        await EngineRunManager.ensure_run_async(provider=provider)
        progress = _progress_response(await EngineRunManager.compute_progress_async(provider=provider))
        run_status = _status_response(await EngineRunManager.compute_status_async(provider=provider))
    except Exception as exc:
        logger.exception("Failed to compute remote status for {}", run_name)
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail=f"Failed to compute remote status: {exc}",
        ) from exc

    return RemoteStatusResponse(
        started_at=started_at,
        uptime=max(uptime, 0),
        run_name=run_name,
        progress=progress,
        run_status=run_status,
    )
runs

Run-scoped endpoints for assignment-driven study flows.

create_run_session(body, request) async

Create or resume a session for one run assignment.

Source code in dcs_simulation_engine/api/routers/runs.py
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.post("/sessions", response_model=CreateGameResponse)
async def create_run_session(
    body: AssignmentSessionRequest,
    request: Request,
) -> CreateGameResponse:
    """Create or resume a session for one run assignment."""
    manager = _get_run_manager(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 manager.start_assignment_session_async(
            provider=provider,
            registry=registry,
            player=player,
            source=body.source,
            assignment_id=body.assignment_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

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

Return eligible game/PC/NPC triplets for the authenticated player.

Source code in dcs_simulation_engine/api/routers/runs.py
287
288
289
290
291
292
293
294
295
296
297
@router.get("/eligible-options", response_model=EligibleAssignmentOptionsResponse)
async def get_eligible_options(request: Request) -> EligibleAssignmentOptionsResponse:
    """Return eligible game/PC/NPC triplets for the authenticated player."""
    manager = _get_run_manager(request)
    provider = get_provider_from_request(request)
    player = await require_player_async(provider=provider, api_key=api_key_from_request(request))
    options = await manager.get_eligible_options_async(
        provider=provider,
        player=player,
    )
    return EligibleAssignmentOptionsResponse(options=[option for option in await _eligible_assignment_options(provider, options)])
run_progress(request) async

Return the current finite progress for the run.

Source code in dcs_simulation_engine/api/routers/runs.py
276
277
278
279
280
281
282
283
284
@router.get("/progress", response_model=ProgressResponse)
async def run_progress(request: Request) -> ProgressResponse:
    """Return the current finite progress for the run."""
    manager = _get_run_manager(request)
    provider = get_provider_from_request(request)
    await require_player_async(provider=provider, api_key=api_key_from_request(request))
    progress = await manager.compute_progress_async(provider=provider)
    await manager.ensure_run_async(provider=provider)
    return _progress_response(progress)
run_setup(request) async

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

Source code in dcs_simulation_engine/api/routers/runs.py
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
@router.get("/setup", response_model=SetupResponse)
async def run_setup(request: Request) -> SetupResponse:
    """Return run metadata, form schemas, and current player assignment state."""
    manager = _get_run_manager(request)
    provider = get_provider_from_request(request)
    config = manager.run_config
    await manager.ensure_run_async(provider=provider)
    player = await require_player_async(provider=provider, api_key=api_key_from_request(request))
    player_state = await manager.get_player_state_async(
        provider=provider,
        player_id=player.id,
    )
    current_assignment = player_state["active_assignment"]
    pending_assignment_form_ids = set(player_state.get("pending_assignment_form_ids", []))
    has_pending_assignment_forms = bool(pending_assignment_form_ids)
    pending_form_groups = player_state.get("pending_form_groups", [])
    assignment_completed = bool(player_state["has_finished_run"])
    eligible_options = player_state.get("eligible_assignment_options", [])

    # Surface resumable_session_id so the frontend can show a Resume CTA.
    resumable_session_id: str | None = None
    if current_assignment is not None and current_assignment.status == "in_progress":
        current_assignment_data = getattr(current_assignment, "data", {}) or {}
        resumable_session_id = current_assignment_data.get(MongoColumns.ACTIVE_SESSION_ID) or None

    progress = await manager.compute_progress_async(provider=provider)
    is_open = not progress["is_complete"]
    has_pending_initial_forms = any(group["trigger"]["event"] == "before_all_assignments" for group in pending_form_groups)
    return SetupResponse(
        run_name=config.name,
        description=config.description,
        is_open=is_open,
        forms=[form.model_dump(mode="json") for form in config.forms],
        pending_form_groups=[_pending_form_group_response(group) for group in pending_form_groups],
        progress=_progress_response(progress),
        current_assignment=await _assignment_summary(
            provider,
            current_assignment,
            pending_assignment_form_ids=pending_assignment_form_ids,
        ),
        assignment_completed=assignment_completed,
        next_assignment=await _next_assignment_state(
            provider,
            current_assignment=current_assignment,
            eligible_options=eligible_options,
            has_pending_assignment_forms=has_pending_assignment_forms,
            assignment_completed=assignment_completed,
            is_open=is_open,
            has_pending_initial_forms=has_pending_initial_forms,
            pending_assignment_form_ids=pending_assignment_form_ids,
        ),
        allow_choice_if_multiple=config.assignment_strategy.allow_choice_if_multiple,
        require_completion=config.assignment_strategy.require_completion,
        eligible_assignment_options=await _eligible_assignment_options(provider, eligible_options),
        assignments=await _assignment_summaries(
            provider,
            player_state.get("assignments", []),
            pending_assignment_form_ids=pending_assignment_form_ids,
        ),
        resumable_session_id=resumable_session_id,
    )
run_status(request) async

Return the current aggregate status for the run.

Source code in dcs_simulation_engine/api/routers/runs.py
322
323
324
325
326
327
328
329
330
@router.get("/status", response_model=RunStatusResponse)
async def run_status(request: Request) -> RunStatusResponse:
    """Return the current aggregate status for the run."""
    manager = _get_run_manager(request)
    provider = get_provider_from_request(request)
    await require_player_async(provider=provider, api_key=api_key_from_request(request))
    await manager.ensure_run_async(provider=provider)
    status_payload = await manager.compute_status_async(provider=provider)
    return _status_response(status_payload)
select_assignment(body, request) async

Create an assignment for the authenticated player based on their explicit triplet selection.

Source code in dcs_simulation_engine/api/routers/runs.py
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
@router.post("/assignments/select", response_model=AssignmentSummary)
async def select_assignment(
    body: SelectAssignmentRequest,
    request: Request,
) -> AssignmentSummary:
    """Create an assignment for the authenticated player based on their explicit triplet selection."""
    manager = _get_run_manager(request)
    provider = get_provider_from_request(request)
    player = await require_player_async(provider=provider, api_key=api_key_from_request(request))
    try:
        assignment = await manager.create_player_choice_assignment_async(
            provider=provider,
            player=player,
            game_name=body.game_name,
            pc_hid=body.pc_hid,
            npc_hid=body.npc_hid,
        )
    except ValueError as exc:
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
    return await _assignment_summary(provider, assignment)
submit_run_form_group(body, request) async

Store responses for one pending run form group.

Source code in dcs_simulation_engine/api/routers/runs.py
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
@router.post("/forms/submit", response_model=FormSubmitResponse)
async def submit_run_form_group(
    body: FormSubmitRequest,
    request: Request,
) -> FormSubmitResponse:
    """Store responses for one pending run form group."""
    manager = _get_run_manager(request)
    provider = get_provider_from_request(request)
    player = await require_player_async(provider=provider, api_key=api_key_from_request(request))
    try:
        group = await manager.submit_form_group_async(
            provider=provider,
            player_id=player.id,
            group_id=body.group_id,
            responses=body.responses,
        )
    except ValueError as exc:
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc

    return FormSubmitResponse(
        group_id=group["group_id"],
        trigger=group["trigger"],
        assignment_id=group.get("assignment_id"),
    )
sessions

HTTP endpoints for listing in-memory API sessions.

branch_session(session_id, request) async

Clone a persisted paused child session from an existing root session.

Source code in dcs_simulation_engine/api/routers/sessions.py
 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
@router.post("/{session_id}/branch", response_model=BranchSessionResponse)
async def branch_session(session_id: str, request: Request) -> BranchSessionResponse:
    """Clone a persisted paused child session from an existing root session."""
    provider = get_provider_from_request(request)
    player_id = await _resolve_session_player_id(request=request, session_id=session_id)

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

    brancher = getattr(provider, "branch_session", None)
    if brancher is None:
        raise HTTPException(
            status_code=status.HTTP_501_NOT_IMPLEMENTED,
            detail="Session branching is unavailable for this provider.",
        )

    try:
        session_record = await maybe_await(
            brancher(
                session_id=session_id,
                player_id=player_id,
                branched_at=utc_now(),
            )
        )
    except ValueError as exc:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc

    branch_from_session_id = str(session_record.data.get("branch_from_session_id") or "")
    return BranchSessionResponse(
        session_id=session_record.session_id,
        branch_from_session_id=branch_from_session_id,
        game_name=session_record.game_name,
        status="paused",
        ws_path=f"/api/play/game/{session_record.session_id}/ws",
    )
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
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
@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
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
@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."""
    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 live session.

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
80
81
82
83
84
85
86
87
88
89
90
91
@router.get("/{session_id}/status")
async def get_session_status(session_id: str, request: Request) -> dict:
    """Return the current status of a live session.

    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
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
@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."""
    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)
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
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
@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.

anonymous_user(request) async

Create an ephemeral anonymous player for runs that do not require registration.

Source code in dcs_simulation_engine/api/routers/users.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
@router.post("/anonymous", response_model=RegistrationResponse)
async def anonymous_user(request: Request) -> RegistrationResponse:
    """Create an ephemeral anonymous player for runs that do not require registration."""
    run_config = request.app.state.run_config
    if run_config.registration_required:
        raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Anonymous players are disabled for this run.")
    provider = get_provider_from_request(request)
    record, api_key = await maybe_await(
        provider.create_player(
            player_data={"anonymous": True},
            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)
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
@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."""
    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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
@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."""
    run_config = request.app.state.run_config
    if not run_config.registration_required:
        raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Player registration is disabled for this run.")
    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
 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
@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", 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.

database

CLI database administration commands.

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/database.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@database_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}")
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/database.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@database_app.command("dump")
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")
keygen(ctx)

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

Source code in dcs_simulation_engine/cli/commands/database.py
68
69
70
71
72
73
74
@database_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/database.py
14
15
16
17
18
19
20
21
22
@database_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)
engine

CLI commands for managing the local Docker Compose engine stack.

start(ctx, config=typer.Option(DEFAULT_RUN_CONFIG_PATH, '--config', envvar='DCS_RUN_CONFIG', help='Run config YAML to use for this engine run.'), headless=typer.Option(False, '--headless', help='Start only the database and API services.'), api_port=typer.Option(8000, '--api-port', envvar='DCS_API_PORT', help='Host port for the API service.'), ui_port=typer.Option(5173, '--ui-port', envvar='DCS_UI_PORT', help='Host port for the UI service.'), db_port=typer.Option(27017, '--db-port', envvar='DCS_DB_PORT', help='Host port for the database service.'), no_build=typer.Option(False, '--no-build', help='Start existing images without rebuilding them.'), follow_logs=typer.Option(False, '--follow-logs', help='Follow service logs after startup succeeds. Ctrl-C stops following logs, not the services.'), timeout_seconds=typer.Option(120, '--timeout', envvar='DCS_ENGINE_TIMEOUT_SECONDS', help='Seconds to wait for services to become ready.'))

Start the engine.

Source code in dcs_simulation_engine/cli/commands/engine.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
@engine_app.command("start")
def start(
    ctx: typer.Context,
    config: Path = typer.Option(
        DEFAULT_RUN_CONFIG_PATH,
        "--config",
        envvar="DCS_RUN_CONFIG",
        help="Run config YAML to use for this engine run.",
    ),
    headless: bool = typer.Option(
        False,
        "--headless",
        help="Start only the database and API services.",
    ),
    api_port: int = typer.Option(
        8000,
        "--api-port",
        envvar="DCS_API_PORT",
        help="Host port for the API service.",
    ),
    ui_port: int = typer.Option(
        5173,
        "--ui-port",
        envvar="DCS_UI_PORT",
        help="Host port for the UI service.",
    ),
    db_port: int = typer.Option(
        27017,
        "--db-port",
        envvar="DCS_DB_PORT",
        help="Host port for the database service.",
    ),
    no_build: bool = typer.Option(
        False,
        "--no-build",
        help="Start existing images without rebuilding them.",
    ),
    follow_logs: bool = typer.Option(
        False,
        "--follow-logs",
        help="Follow service logs after startup succeeds. Ctrl-C stops following logs, not the services.",
    ),
    timeout_seconds: int = typer.Option(
        120,
        "--timeout",
        envvar="DCS_ENGINE_TIMEOUT_SECONDS",
        help="Seconds to wait for services to become ready.",
    ),
) -> None:
    """Start the engine."""
    try:
        source_assets = resolve_assets(Path.cwd())
    except FileNotFoundError as exc:
        echo(
            ctx,
            str(exc),
            style="error",
        )
        raise typer.Exit(code=1) from exc

    engine_assets = _materialize_engine_assets(source_assets)
    engine_root = engine_assets.root

    config_path = _resolve_run_config_path(config, assets=engine_assets)
    if not config_path.is_file():
        echo(ctx, f"Run config not found: {config_path}", style="error")
        raise typer.Exit(code=1)

    echo(ctx, "Checking prerequisites...")
    _ensure_openrouter_key(ctx)
    echo(ctx, "✓ API keys provided", style="success")
    _ensure_docker_ready(ctx)
    echo(ctx, "✓ Docker ready", style="success")

    services = ["mongo", "api"]
    display_services = ["db", "api"]
    if not headless:
        services.append("ui")
        display_services.append("ui")

    env = _compose_env(
        engine_root=engine_root,
        assets_mode=engine_assets.mode,
        api_port=api_port,
        ui_port=ui_port,
        db_port=db_port,
    )

    with tempfile.TemporaryDirectory(prefix="dcs-engine-") as temp_dir:
        override_path = Path(temp_dir) / "compose.engine.yml"
        _write_run_config_override(
            override_path=override_path,
            host_config_path=_host_path_for_docker(config_path, engine_root=engine_root, assets_mode=engine_assets.mode),
        )
        compose_command = _compose_command(engine_root=engine_root)
        startup_compose_command = _compose_command(engine_root=engine_root, override_path=override_path)
        up_command = startup_compose_command + ["up"]
        if not no_build:
            up_command.append("--build")
        up_command += ["--detach", *services]

        echo(ctx, "Starting engine locally...")
        try:
            _run_checked(up_command, env=env)
        except subprocess.CalledProcessError as exc:
            echo(ctx, "Docker Compose failed to start the local engine.", style="error")
            echo(ctx, f"Run logs: {' '.join(compose_command)} logs {' '.join(services)}")
            raise typer.Exit(code=1) from exc
        echo(ctx, f"✓ Compose up: {', '.join(display_services)}", style="success")

        try:
            _wait_for_db(compose_command, env=env, timeout_seconds=timeout_seconds)
        except TimeoutError as exc:
            echo(ctx, f"Database did not become ready within {timeout_seconds} seconds.", style="error")
            echo(ctx, f"Run logs: {' '.join(compose_command)} logs mongo")
            raise typer.Exit(code=1) from exc
        echo(ctx, "✓ Database ready", style="success")

        api_url = f"http://localhost:{api_port}"
        try:
            _wait_for_http(f"http://127.0.0.1:{api_port}/healthz", timeout_seconds=timeout_seconds)
        except TimeoutError as exc:
            echo(ctx, f"Engine API did not become ready within {timeout_seconds} seconds.", style="error")
            echo(ctx, f"Run logs: {' '.join(compose_command)} logs api")
            raise typer.Exit(code=1) from exc
        echo(ctx, "✓ Engine API ready", style="success")
        echo(ctx, f"Engine running at {api_url}")

        if headless:
            echo(ctx, "⚠ Headless mode: default UI not started", style="warning")
            echo(ctx, "  • If you need a front-end, install and start your own UI client")
        else:
            ui_url = f"http://localhost:{ui_port}"
            echo(ctx, "Starting UI...")
            try:
                _wait_for_http(f"http://127.0.0.1:{ui_port}", timeout_seconds=timeout_seconds)
            except TimeoutError as exc:
                echo(ctx, f"UI did not become ready within {timeout_seconds} seconds.", style="error")
                echo(ctx, f"Run logs: {' '.join(compose_command)} logs ui")
                raise typer.Exit(code=1) from exc
            echo(ctx, "✓ UI ready", style="success")
            echo(ctx, f"→ Access the app ui at: {ui_url}")

        echo(ctx, f"View logs: {' '.join(compose_command)} logs -f {' '.join(services)}", style="dim")
        echo(ctx, "Stop: dcs engine stop", style="dim")

        if follow_logs:
            _follow_logs(compose_command, services=services, env=env)
status(ctx, api_port=typer.Option(8000, '--api-port', envvar='DCS_API_PORT', help='Host port for the API service.'), ui_port=typer.Option(5173, '--ui-port', envvar='DCS_UI_PORT', help='Host port for the UI service.'), json_output=typer.Option(False, '--json', help='Print the status payload as JSON.'))

Check local engine status (service health and run progress).

Source code in dcs_simulation_engine/cli/commands/engine.py
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
@engine_app.command("status")
def status(
    ctx: typer.Context,
    api_port: int = typer.Option(
        8000,
        "--api-port",
        envvar="DCS_API_PORT",
        help="Host port for the API service.",
    ),
    ui_port: int = typer.Option(
        5173,
        "--ui-port",
        envvar="DCS_UI_PORT",
        help="Host port for the UI service.",
    ),
    json_output: bool = typer.Option(False, "--json", help="Print the status payload as JSON."),
) -> None:
    """Check local engine status (service health and run progress)."""
    try:
        engine_assets = _materialize_engine_assets(resolve_assets(Path.cwd()))
    except FileNotFoundError as exc:
        echo(
            ctx,
            str(exc),
            style="error",
        )
        raise typer.Exit(code=1) from exc

    _ensure_docker_ready(ctx)
    env = _stop_env(engine_root=engine_assets.root, assets_mode=engine_assets.mode)
    compose_command = _compose_command(engine_root=engine_assets.root)
    services = _compose_services(compose_command, env=env)
    payload = _engine_status_payload(
        compose_command=compose_command,
        env=env,
        services=services,
        api_port=api_port,
        ui_port=ui_port,
    )

    if json_output:
        typer.echo(json.dumps(payload, indent=2, sort_keys=True))
    else:
        _print_status(ctx, payload, api_port=api_port, ui_port=ui_port)

    if payload["status"] != "healthy":
        raise typer.Exit(code=1)
stop(ctx, clean=typer.Option(False, '--clean', help='Also remove Docker volumes, including local database state. Does not delete ./runs.'))

Stop the engine.

Source code in dcs_simulation_engine/cli/commands/engine.py
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
@engine_app.command("stop")
def stop(
    ctx: typer.Context,
    clean: bool = typer.Option(
        False,
        "--clean",
        help="Also remove Docker volumes, including local database state. Does not delete ./runs.",
    ),
) -> None:
    """Stop the engine."""
    try:
        engine_assets = _materialize_engine_assets(resolve_assets(Path.cwd()))
    except FileNotFoundError as exc:
        echo(
            ctx,
            str(exc),
            style="error",
        )
        raise typer.Exit(code=1) from exc

    _ensure_docker_ready(ctx)
    compose_command = _compose_command(engine_root=engine_assets.root)
    down_command = compose_command + ["down"]
    if clean:
        down_command.append("--volumes")

    try:
        _run_checked(
            down_command,
            env=_stop_env(engine_root=engine_assets.root, assets_mode=engine_assets.mode),
        )
    except subprocess.CalledProcessError as exc:
        echo(ctx, "Docker Compose failed to stop the local engine.", style="error")
        raise typer.Exit(code=1) from exc
    echo(ctx, "✓ Engine stopped", style="success")
remote

Remote Fly deployment and lifecycle commands.

deploy(ctx, config=typer.Option(Path('examples/run_configs/demo.yml'), '--config', dir_okay=False, file_okay=True, help='Run config YAML to deploy. Pass a local path, or a packaged example like demo.yml.'), 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', dir_okay=True, file_okay=True, help='Mongo bootstrap seed source: a local .zip/.tar.gz archive, .json/.ndjson dump, directory, or packaged seed name like prod.'), 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
 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
@remote_app.command("deploy", context_settings={"allow_extra_args": True})
def deploy(
    ctx: typer.Context,
    config: Path = typer.Option(
        Path("examples/run_configs/demo.yml"),
        "--config",
        dir_okay=False,
        file_okay=True,
        help="Run config YAML to deploy. Pass a local path, or a packaged example like demo.yml.",
    ),
    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",
        dir_okay=True,
        file_okay=True,
        help=(
            "Mongo bootstrap seed source: a local .zip/.tar.gz archive, .json/.ndjson dump, directory, "
            "or packaged seed name like prod."
        ),
    ),
    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:
        region_candidates = _parse_region_candidates(ctx, region)
        if json_output:
            result = _deploy_with_region_fallback(
                ctx=ctx,
                config=config,
                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 run to Fly"):
                result = _deploy_with_region_fallback(
                    ctx=ctx,
                    config=config,
                    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.run_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
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
@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
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
@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.run_status or {})
        return

    typer.echo(json.dumps(result.run_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 run.

Source code in dcs_simulation_engine/cli/commands/remote.py
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
@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 run."""
    try:
        with step("Saving remote database and destroying Fly apps"):
            result_path = stop_remote_run(
                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.'), config=typer.Option(DEFAULT_RUN_CONFIG_PATH, '--config', envvar='DCS_RUN_CONFIG', help='Run config YAML to use for this engine run.'), 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.'), 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.'))

Run the DCS API server.

Source code in dcs_simulation_engine/cli/commands/server.py
 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
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.",
    ),
    config: Path = typer.Option(
        DEFAULT_RUN_CONFIG_PATH,
        "--config",
        envvar="DCS_RUN_CONFIG",
        help="Run config YAML to use for this engine run.",
    ),
    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.",
    ),
    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:
    """Run 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,
            run_config_path=config,
            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)
workflow

Workflow CLI subcommands for reports, HITL, and publishing.

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
43
44
45
46
47
48
49
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 run workflows.

AssignmentCandidate

Bases: NamedTuple

One candidate assignment returned by a strategy.

Source code in dcs_simulation_engine/core/assignment_strategies/base.py
10
11
12
13
14
15
16
class AssignmentCandidate(NamedTuple):
    """One candidate assignment returned by a strategy."""

    game_name: str
    pc_hid: str
    npc_hid: str
    metadata: dict[str, Any] | None = None
AssignmentStrategy

Bases: Protocol

Behavior contract for run assignment strategies.

Source code in dcs_simulation_engine/core/assignment_strategies/base.py
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
class AssignmentStrategy(Protocol):
    """Behavior contract for run assignment strategies."""

    name: str

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

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

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

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

    async def list_candidate_assignments_async(
        self,
        *,
        provider: Any,
        config: "RunConfig",
        player: "PlayerRecord",
    ) -> list[AssignmentCandidate]:
        """Return candidate assignments for the player under the current strategy."""

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

Return run progress payload for the public API.

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

Return run status payload for the public API.

Source code in dcs_simulation_engine/core/assignment_strategies/base.py
33
34
async def compute_status_async(self, *, provider: Any, config: "RunConfig") -> dict[str, Any]:
    """Return run 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
45
46
47
48
49
50
51
52
async def get_or_create_assignment_async(
    self,
    *,
    provider: Any,
    config: "RunConfig",
    player: "PlayerRecord",
) -> "AssignmentRecord | None":
    """Return the current assignment for a player or create one."""
list_candidate_assignments_async(*, provider, config, player) async

Return candidate assignments for the player under the current strategy.

Source code in dcs_simulation_engine/core/assignment_strategies/base.py
36
37
38
39
40
41
42
43
async def list_candidate_assignments_async(
    self,
    *,
    provider: Any,
    config: "RunConfig",
    player: "PlayerRecord",
) -> list[AssignmentCandidate]:
    """Return candidate assignments for the player under the current strategy."""
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
27
28
def max_assignments_per_player(self, *, config: "RunConfig") -> 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
24
25
def validate_config(self, *, config: "RunConfig") -> None:
    """Validate strategy-specific config constraints."""
common

Shared helpers for candidate-based assignment strategies.

CandidateAssignmentStrategy

Shared implementation for strategies that emit candidate assignments.

Source code in dcs_simulation_engine/core/assignment_strategies/common.py
 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
class CandidateAssignmentStrategy:
    """Shared implementation for strategies that emit candidate assignments."""

    name = ""

    def validate_config(self, *, config: "RunConfig") -> None:
        """Validate strategy config shared across candidate-based strategies."""
        if not config.game_names:
            raise ValueError(f"{self.name} requires run config games")
        if config.assignment_strategy.quota_per_game is not None and config.assignment_strategy.quota_per_game <= 0:
            raise ValueError(f"{self.name} 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(f"{self.name} requires max_assignments_per_player to be positive")

    def max_assignments_per_player(self, *, config: "RunConfig") -> int:
        """Return the configured per-player assignment cap for this strategy."""
        configured = config.assignment_strategy.max_assignments_per_player
        if configured is None:
            return DEFAULT_MAX_ASSIGNMENTS_PER_PLAYER
        return max(0, int(configured))

    async def compute_progress_async(self, *, provider: Any, config: "RunConfig") -> dict[str, Any]:
        """Compute quota-based run progress for the configured games."""
        quota = config.assignment_strategy.quota_per_game
        completed_players_by_game = await self._players_by_game(
            provider=provider,
            statuses=["completed"],
        )
        counted_players_by_game = await self._players_by_game(
            provider=provider,
            statuses=["in_progress", "completed"],
        )
        completed_total = sum(len(players) for players in completed_players_by_game.values())
        if quota is None:
            return {
                "total": completed_total,
                "completed": completed_total,
                "is_complete": False,
            }
        quota_count = int(quota)
        return {
            "total": quota_count * len(config.game_names),
            "completed": completed_total,
            "is_complete": all(len(counted_players_by_game.get(game_name, set())) >= quota_count for game_name in config.game_names),
        }

    async def compute_status_async(self, *, provider: Any, config: "RunConfig") -> dict[str, Any]:
        """Compute per-game status counts and overall run openness."""
        quota = config.assignment_strategy.quota_per_game
        completed_players_by_game = await self._players_by_game(
            provider=provider,
            statuses=["completed"],
        )
        in_progress_players_by_game = await self._players_by_game(
            provider=provider,
            statuses=["in_progress"],
        )
        counted_players_by_game = await self._players_by_game(
            provider=provider,
            statuses=["in_progress", "completed"],
        )

        per_game: dict[str, dict[str, int]] = {}
        quota_count = int(quota) if quota is not None else 0
        for game_name in config.game_names:
            per_game[game_name] = {
                "total": quota_count,
                "completed": len(completed_players_by_game.get(game_name, set())),
                "in_progress": len(in_progress_players_by_game.get(game_name, set())),
            }

        if quota is None:
            return {
                "is_open": True,
                "total": sum(item["completed"] for item in per_game.values()),
                "completed": sum(item["completed"] for item in per_game.values()),
                "per_game": per_game,
            }
        return {
            "is_open": any(len(counted_players_by_game.get(game_name, set())) < quota_count for game_name in config.game_names),
            "total": quota_count * len(config.game_names),
            "completed": sum(item["completed"] for item in per_game.values()),
            "per_game": per_game,
        }

    async def list_candidate_assignments_async(
        self,
        *,
        provider: Any,
        config: "RunConfig",
        player: "PlayerRecord",
    ) -> list[AssignmentCandidate]:
        """Return ordered candidate assignments for the player."""
        raise NotImplementedError

    async def get_eligible_options_async(
        self,
        *,
        provider: Any,
        config: "RunConfig",
        player: "PlayerRecord",
    ) -> list[dict[str, str]]:
        """Return serialized candidate assignment options."""
        candidates = await self.list_candidate_assignments_async(provider=provider, config=config, player=player)
        return [candidate_to_dict(candidate) for candidate in candidates]

    async def get_or_create_assignment_async(
        self,
        *,
        provider: Any,
        config: "RunConfig",
        player: "PlayerRecord",
    ) -> "AssignmentRecord | None":
        """Return an existing assignment or create one from this strategy's candidates."""
        reusable_assignment = await self._reusable_assignment_or_none(provider=provider, config=config, player=player)
        if reusable_assignment is not None:
            return reusable_assignment

        player_assignments = await self._list_player_assignments(provider=provider, config=config, player=player)
        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

        candidates = await self.list_candidate_assignments_async(provider=provider, config=config, player=player)
        if not candidates:
            return None

        selected = candidates[0]
        return await maybe_await(
            provider.create_assignment(
                assignment_doc=self._assignment_doc_for_candidate(config=config, player=player, candidate=selected),
                allow_concurrent=not config.assignment_strategy.require_completion,
            )
        )

    async def _reusable_assignment_or_none(
        self,
        *,
        provider: Any,
        config: "RunConfig",
        player: "PlayerRecord",
    ) -> "AssignmentRecord | None":
        active_assignment = await maybe_await(provider.get_active_assignment(player_id=player.id))
        if active_assignment is None:
            return None
        if config.assignment_strategy.require_completion:
            return active_assignment
        if active_assignment.status in {"assigned", "in_progress"}:
            return active_assignment
        return None

    def _assignment_doc_for_candidate(
        self,
        *,
        config: "RunConfig",
        player: "PlayerRecord",
        candidate: AssignmentCandidate,
    ) -> dict[str, Any]:
        assignment_doc: dict[str, Any] = {
            MongoColumns.PLAYER_ID: player.id,
            MongoColumns.GAME_NAME: candidate.game_name,
            MongoColumns.PC_HID: candidate.pc_hid,
            MongoColumns.NPC_HID: candidate.npc_hid,
            MongoColumns.STATUS: "assigned",
            MongoColumns.FORM_RESPONSES: {},
        }
        if candidate.metadata:
            assignment_doc.update(candidate.metadata)
        return assignment_doc

    async def _build_candidate_pool(
        self,
        *,
        provider: Any,
        config: "RunConfig",
        player: "PlayerRecord",
    ) -> list[AssignmentCandidate]:
        counted_players_by_game = await self._players_by_game(
            provider=provider,
            statuses=["in_progress", "completed"],
        )
        quota = config.assignment_strategy.quota_per_game
        pc_eligible_only = bool(config.assignment_strategy.pc_eligible_only)
        candidates: list[AssignmentCandidate] = []

        for game_name in config.game_names:
            if quota is not None and len(counted_players_by_game.get(game_name, set())) >= int(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, valid_npcs = await maybe_await(
                    game_config.get_valid_characters(
                        player_id=player.id,
                        provider=provider,
                        pc_eligible_only=pc_eligible_only,
                    )
                )
            else:
                valid_pcs, valid_npcs = await maybe_await(
                    get_valid(
                        player_id=player.id,
                        provider=provider,
                        pc_eligible_only=pc_eligible_only,
                    )
                )
            for _, pc_hid in valid_pcs:
                for _, npc_hid in valid_npcs:
                    candidates.append(AssignmentCandidate(game_name=game_name, pc_hid=pc_hid, npc_hid=npc_hid))
        return candidates

    async def _list_player_assignments(
        self,
        *,
        provider: Any,
        config: "RunConfig",
        player: "PlayerRecord",
    ) -> list["AssignmentRecord"]:
        return await maybe_await(provider.list_assignments(player_id=player.id))

    async def _players_by_game(
        self,
        *,
        provider: Any,
        statuses: list[str],
    ) -> dict[str, set[str]]:
        assignments = await maybe_await(provider.list_assignments(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

    async def _assignments_by_group(
        self,
        *,
        provider: Any,
        config: "RunConfig",
        statuses: list[str],
    ) -> dict[tuple[str, str], int]:
        assignments = await maybe_await(provider.list_assignments(statuses=statuses))
        counts: dict[tuple[str, str], int] = defaultdict(int)
        for assignment in assignments:
            counts[(assignment.game_name, assignment.npc_hid)] += 1
        return counts

    async def _character_map(self, *, provider: Any) -> dict[str, CharacterRecord]:
        characters = await maybe_await(provider.get_characters())
        return {record.hid: record for record in characters}

    def _completed_triple_counts(self, *, assignments: list["AssignmentRecord"]) -> dict[tuple[str, str, str], int]:
        counts: dict[tuple[str, str, str], int] = defaultdict(int)
        for assignment in assignments:
            if assignment.status == "completed":
                counts[(assignment.game_name, assignment.pc_hid, assignment.npc_hid)] += 1
        return counts

    def _completed_group_keys(self, *, assignments: list["AssignmentRecord"]) -> set[tuple[str, str]]:
        return {
            (assignment.game_name, assignment.npc_hid)
            for assignment in assignments
            if assignment.status == "completed"
        }

    def _latest_completed_assignment(self, *, assignments: list["AssignmentRecord"]) -> "AssignmentRecord | None":
        completed = [assignment for assignment in assignments if assignment.status == "completed"]
        if not completed:
            return None
        return completed[-1]

    async def _expertise_match_hids(
        self,
        *,
        provider: Any,
        config: "RunConfig",
        player: "PlayerRecord",
        characters_by_hid: dict[str, CharacterRecord],
    ) -> set[str]:
        expertise_values = await self._expertise_values(provider=provider, config=config, player=player)
        if not expertise_values:
            return set()
        expertise_tokens: set[str] = set()
        for value in expertise_values:
            expertise_tokens.update(_tokenize(str(value)))
        if not expertise_tokens:
            return set()
        matched_hids: set[str] = set()
        for hid, character in characters_by_hid.items():
            labels = character.data.get("common_labels", [])
            label_tokens: set[str] = set()
            for label in labels if isinstance(labels, list) else []:
                label_tokens.update(_tokenize(str(label)))
            if expertise_tokens & label_tokens:
                matched_hids.add(hid)
        return matched_hids

    async def _expertise_values(self, *, provider: Any, config: "RunConfig", player: "PlayerRecord") -> list[str]:
        values: list[str] = []
        player_forms = await maybe_await(provider.get_player_forms(player_id=player.id))
        for form_payload in (player_forms.data if player_forms else {}).values():
            if not isinstance(form_payload, dict):
                continue
            answers = form_payload.get("answers", {})
            if not isinstance(answers, dict):
                continue
            answer_payload = answers.get("expertise")
            if isinstance(answer_payload, dict):
                values.extend(self._flatten_expertise_values(answer_payload.get("answer")))
        return values

    def _flatten_expertise_values(self, value: Any) -> list[str]:
        if value in (None, ""):
            return []
        if isinstance(value, dict):
            if "answer" in value:
                return self._flatten_expertise_values(value["answer"])
            return []
        if isinstance(value, (list, tuple, set)):
            values: list[str] = []
            for item in value:
                values.extend(self._flatten_expertise_values(item))
            return values
        text = str(value).strip()
        return [text] if text else []

    def _sort_with_expertise_priority(
        self,
        *,
        candidates: list[AssignmentCandidate],
        matched_npc_hids: set[str],
        game_order: dict[str, int],
    ) -> list[AssignmentCandidate]:
        return sorted(
            candidates,
            key=lambda candidate: (
                0 if candidate.npc_hid in matched_npc_hids else 1,
                game_order[candidate.game_name],
                candidate.pc_hid,
                candidate.npc_hid,
            ),
        )

    def _sort_by_descending_divergence(
        self,
        *,
        candidates: list[AssignmentCandidate],
        reference_npc: CharacterRecord,
        characters_by_hid: dict[str, CharacterRecord],
        fallback_counts: dict[tuple[str, str], int] | None = None,
        game_order: dict[str, int],
    ) -> list[AssignmentCandidate]:
        group_scores: dict[tuple[str, str], float] = {}
        for candidate in candidates:
            key = (candidate.game_name, candidate.npc_hid)
            if key in group_scores:
                continue
            npc = characters_by_hid.get(candidate.npc_hid)
            group_scores[key] = compute_divergence_score(reference_npc, npc) if npc is not None else 0.0
        return sorted(
            candidates,
            key=lambda candidate: (
                -group_scores[(candidate.game_name, candidate.npc_hid)],
                (fallback_counts or {}).get((candidate.game_name, candidate.npc_hid), 0),
                game_order[candidate.game_name],
                candidate.npc_hid,
                candidate.pc_hid,
            ),
        )

    def _sort_by_descending_contrast(
        self,
        *,
        candidates: list[AssignmentCandidate],
        characters_by_hid: dict[str, CharacterRecord],
        game_order: dict[str, int],
    ) -> list[AssignmentCandidate]:
        def _score(candidate: AssignmentCandidate) -> float:
            pc = characters_by_hid.get(candidate.pc_hid)
            npc = characters_by_hid.get(candidate.npc_hid)
            if pc is None or npc is None:
                return 0.0
            return compute_divergence_score(pc, npc)

        return sorted(
            candidates,
            key=lambda candidate: (
                -_score(candidate),
                game_order[candidate.game_name],
                candidate.pc_hid,
                candidate.npc_hid,
            ),
        )
compute_progress_async(*, provider, config) async

Compute quota-based run progress for the configured games.

Source code in dcs_simulation_engine/core/assignment_strategies/common.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
async def compute_progress_async(self, *, provider: Any, config: "RunConfig") -> dict[str, Any]:
    """Compute quota-based run progress for the configured games."""
    quota = config.assignment_strategy.quota_per_game
    completed_players_by_game = await self._players_by_game(
        provider=provider,
        statuses=["completed"],
    )
    counted_players_by_game = await self._players_by_game(
        provider=provider,
        statuses=["in_progress", "completed"],
    )
    completed_total = sum(len(players) for players in completed_players_by_game.values())
    if quota is None:
        return {
            "total": completed_total,
            "completed": completed_total,
            "is_complete": False,
        }
    quota_count = int(quota)
    return {
        "total": quota_count * len(config.game_names),
        "completed": completed_total,
        "is_complete": all(len(counted_players_by_game.get(game_name, set())) >= quota_count for game_name in config.game_names),
    }
compute_status_async(*, provider, config) async

Compute per-game status counts and overall run openness.

Source code in dcs_simulation_engine/core/assignment_strategies/common.py
 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
async def compute_status_async(self, *, provider: Any, config: "RunConfig") -> dict[str, Any]:
    """Compute per-game status counts and overall run openness."""
    quota = config.assignment_strategy.quota_per_game
    completed_players_by_game = await self._players_by_game(
        provider=provider,
        statuses=["completed"],
    )
    in_progress_players_by_game = await self._players_by_game(
        provider=provider,
        statuses=["in_progress"],
    )
    counted_players_by_game = await self._players_by_game(
        provider=provider,
        statuses=["in_progress", "completed"],
    )

    per_game: dict[str, dict[str, int]] = {}
    quota_count = int(quota) if quota is not None else 0
    for game_name in config.game_names:
        per_game[game_name] = {
            "total": quota_count,
            "completed": len(completed_players_by_game.get(game_name, set())),
            "in_progress": len(in_progress_players_by_game.get(game_name, set())),
        }

    if quota is None:
        return {
            "is_open": True,
            "total": sum(item["completed"] for item in per_game.values()),
            "completed": sum(item["completed"] for item in per_game.values()),
            "per_game": per_game,
        }
    return {
        "is_open": any(len(counted_players_by_game.get(game_name, set())) < quota_count for game_name in config.game_names),
        "total": quota_count * len(config.game_names),
        "completed": sum(item["completed"] for item in per_game.values()),
        "per_game": per_game,
    }
get_eligible_options_async(*, provider, config, player) async

Return serialized candidate assignment options.

Source code in dcs_simulation_engine/core/assignment_strategies/common.py
132
133
134
135
136
137
138
139
140
141
async def get_eligible_options_async(
    self,
    *,
    provider: Any,
    config: "RunConfig",
    player: "PlayerRecord",
) -> list[dict[str, str]]:
    """Return serialized candidate assignment options."""
    candidates = await self.list_candidate_assignments_async(provider=provider, config=config, player=player)
    return [candidate_to_dict(candidate) for candidate in candidates]
get_or_create_assignment_async(*, provider, config, player) async

Return an existing assignment or create one from this strategy's candidates.

Source code in dcs_simulation_engine/core/assignment_strategies/common.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
async def get_or_create_assignment_async(
    self,
    *,
    provider: Any,
    config: "RunConfig",
    player: "PlayerRecord",
) -> "AssignmentRecord | None":
    """Return an existing assignment or create one from this strategy's candidates."""
    reusable_assignment = await self._reusable_assignment_or_none(provider=provider, config=config, player=player)
    if reusable_assignment is not None:
        return reusable_assignment

    player_assignments = await self._list_player_assignments(provider=provider, config=config, player=player)
    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

    candidates = await self.list_candidate_assignments_async(provider=provider, config=config, player=player)
    if not candidates:
        return None

    selected = candidates[0]
    return await maybe_await(
        provider.create_assignment(
            assignment_doc=self._assignment_doc_for_candidate(config=config, player=player, candidate=selected),
            allow_concurrent=not config.assignment_strategy.require_completion,
        )
    )
list_candidate_assignments_async(*, provider, config, player) async

Return ordered candidate assignments for the player.

Source code in dcs_simulation_engine/core/assignment_strategies/common.py
122
123
124
125
126
127
128
129
130
async def list_candidate_assignments_async(
    self,
    *,
    provider: Any,
    config: "RunConfig",
    player: "PlayerRecord",
) -> list[AssignmentCandidate]:
    """Return ordered candidate assignments for the player."""
    raise NotImplementedError
max_assignments_per_player(*, config)

Return the configured per-player assignment cap for this strategy.

Source code in dcs_simulation_engine/core/assignment_strategies/common.py
51
52
53
54
55
56
def max_assignments_per_player(self, *, config: "RunConfig") -> int:
    """Return the configured per-player assignment cap for this strategy."""
    configured = config.assignment_strategy.max_assignments_per_player
    if configured is None:
        return DEFAULT_MAX_ASSIGNMENTS_PER_PLAYER
    return max(0, int(configured))
validate_config(*, config)

Validate strategy config shared across candidate-based strategies.

Source code in dcs_simulation_engine/core/assignment_strategies/common.py
40
41
42
43
44
45
46
47
48
49
def validate_config(self, *, config: "RunConfig") -> None:
    """Validate strategy config shared across candidate-based strategies."""
    if not config.game_names:
        raise ValueError(f"{self.name} requires run config games")
    if config.assignment_strategy.quota_per_game is not None and config.assignment_strategy.quota_per_game <= 0:
        raise ValueError(f"{self.name} 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(f"{self.name} requires max_assignments_per_player to be positive")
candidate_to_dict(candidate)

Serialize one candidate for API responses.

Source code in dcs_simulation_engine/core/assignment_strategies/common.py
26
27
28
29
30
31
32
def candidate_to_dict(candidate: AssignmentCandidate) -> dict[str, str]:
    """Serialize one candidate for API responses."""
    return {
        "game_name": candidate.game_name,
        "pc_hid": candidate.pc_hid,
        "npc_hid": candidate.npc_hid,
    }
expertise_matched_character_batch

Expertise-matched batch assignment strategy.

ExpertiseMatchedCharacterBatchAssignmentStrategy

Bases: CandidateAssignmentStrategy

Candidate assignments include triplets for the current NPC batch until its games are completed.

Source code in dcs_simulation_engine/core/assignment_strategies/expertise_matched_character_batch.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
 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
class ExpertiseMatchedCharacterBatchAssignmentStrategy(CandidateAssignmentStrategy):
    """Candidate assignments include triplets for the current NPC batch until its games are completed."""

    name = "expertise_matched_character_batch"

    async def list_candidate_assignments_async(
        self,
        *,
        provider: Any,
        config,
        player,
    ) -> list[AssignmentCandidate]:
        """Return triplets for the current NPC batch until its configured games are completed."""
        candidates = await self._build_candidate_pool(provider=provider, config=config, player=player)
        if not candidates:
            return []

        player_assignments = await self._list_player_assignments(provider=provider, config=config, player=player)
        completed_games_by_npc: dict[str, set[str]] = {}
        for assignment in player_assignments:
            if assignment.status != "completed":
                continue
            completed_games_by_npc.setdefault(assignment.npc_hid, set()).add(assignment.game_name)

        active_batch_npc = self._active_batch_npc(config=config, assignments=player_assignments)
        characters_by_hid = await self._character_map(provider=provider)
        matched_npc_hids = await self._expertise_match_hids(
            provider=provider,
            config=config,
            player=player,
            characters_by_hid=characters_by_hid,
        )
        game_order = {game_name: index for index, game_name in enumerate(config.game_names)}
        npc_order = self._ordered_npcs(
            candidates=candidates,
            matched_npc_hids=matched_npc_hids,
            game_order=game_order,
        )

        target_npc = active_batch_npc
        if target_npc is None:
            for npc_hid in npc_order:
                completed_games = completed_games_by_npc.get(npc_hid, set())
                if len(completed_games) < len(config.game_names):
                    target_npc = npc_hid
                    break
        if target_npc is None:
            return []

        remaining_games = [
            game_name
            for game_name in config.game_names
            if game_name not in completed_games_by_npc.get(target_npc, set())
        ]
        if not remaining_games:
            return []

        next_game = remaining_games[0]
        batch_id = f"{config.name}:{player.id}:{target_npc}"
        return [
            AssignmentCandidate(
                game_name=candidate.game_name,
                pc_hid=candidate.pc_hid,
                npc_hid=candidate.npc_hid,
                metadata={"batch_id": batch_id, "batch_npc_hid": target_npc},
            )
            for candidate in candidates
            if candidate.game_name == next_game and candidate.npc_hid == target_npc
        ]

    def _active_batch_npc(self, *, config, assignments) -> str | None:
        for assignment in reversed(assignments):
            batch_npc_hid = str(assignment.data.get("batch_npc_hid") or "").strip()
            if not batch_npc_hid:
                continue
            completed_games = {
                item.game_name
                for item in assignments
                if item.status == "completed" and str(item.data.get("batch_npc_hid") or "") == batch_npc_hid
            }
            if len(completed_games) < len(config.game_names):
                return batch_npc_hid
        return None

    def _ordered_npcs(
        self,
        *,
        candidates: list[AssignmentCandidate],
        matched_npc_hids: set[str],
        game_order: dict[str, int],
    ) -> list[str]:
        seen: set[str] = set()
        ordered_npcs: list[str] = []
        for candidate in sorted(
            candidates,
            key=lambda item: (
                0 if item.npc_hid in matched_npc_hids else 1,
                game_order[item.game_name],
                item.npc_hid,
                item.pc_hid,
            ),
        ):
            if candidate.npc_hid in seen:
                continue
            seen.add(candidate.npc_hid)
            ordered_npcs.append(candidate.npc_hid)
        return ordered_npcs
list_candidate_assignments_async(*, provider, config, player) async

Return triplets for the current NPC batch until its configured games are completed.

Source code in dcs_simulation_engine/core/assignment_strategies/expertise_matched_character_batch.py
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
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
async def list_candidate_assignments_async(
    self,
    *,
    provider: Any,
    config,
    player,
) -> list[AssignmentCandidate]:
    """Return triplets for the current NPC batch until its configured games are completed."""
    candidates = await self._build_candidate_pool(provider=provider, config=config, player=player)
    if not candidates:
        return []

    player_assignments = await self._list_player_assignments(provider=provider, config=config, player=player)
    completed_games_by_npc: dict[str, set[str]] = {}
    for assignment in player_assignments:
        if assignment.status != "completed":
            continue
        completed_games_by_npc.setdefault(assignment.npc_hid, set()).add(assignment.game_name)

    active_batch_npc = self._active_batch_npc(config=config, assignments=player_assignments)
    characters_by_hid = await self._character_map(provider=provider)
    matched_npc_hids = await self._expertise_match_hids(
        provider=provider,
        config=config,
        player=player,
        characters_by_hid=characters_by_hid,
    )
    game_order = {game_name: index for index, game_name in enumerate(config.game_names)}
    npc_order = self._ordered_npcs(
        candidates=candidates,
        matched_npc_hids=matched_npc_hids,
        game_order=game_order,
    )

    target_npc = active_batch_npc
    if target_npc is None:
        for npc_hid in npc_order:
            completed_games = completed_games_by_npc.get(npc_hid, set())
            if len(completed_games) < len(config.game_names):
                target_npc = npc_hid
                break
    if target_npc is None:
        return []

    remaining_games = [
        game_name
        for game_name in config.game_names
        if game_name not in completed_games_by_npc.get(target_npc, set())
    ]
    if not remaining_games:
        return []

    next_game = remaining_games[0]
    batch_id = f"{config.name}:{player.id}:{target_npc}"
    return [
        AssignmentCandidate(
            game_name=candidate.game_name,
            pc_hid=candidate.pc_hid,
            npc_hid=candidate.npc_hid,
            metadata={"batch_id": batch_id, "batch_npc_hid": target_npc},
        )
        for candidate in candidates
        if candidate.game_name == next_game and candidate.npc_hid == target_npc
    ]
expertise_matched_character_choice

Expertise-matched choice assignment strategy.

ExpertiseMatchedCharacterChoiceAssignmentStrategy

Bases: CandidateAssignmentStrategy

Candidate assignments include allowed triplets ordered with expertise-matching NPCs first.

Source code in dcs_simulation_engine/core/assignment_strategies/expertise_matched_character_choice.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
class ExpertiseMatchedCharacterChoiceAssignmentStrategy(CandidateAssignmentStrategy):
    """Candidate assignments include allowed triplets ordered with expertise-matching NPCs first."""

    name = "expertise_matched_character_choice"

    async def list_candidate_assignments_async(
        self,
        *,
        provider: Any,
        config,
        player,
    ) -> list[AssignmentCandidate]:
        """Return allowed triplets ordered with expertise-matching NPCs first."""
        candidates = await self._build_candidate_pool(provider=provider, config=config, player=player)
        characters_by_hid = await self._character_map(provider=provider)
        matched_npc_hids = await self._expertise_match_hids(
            provider=provider,
            config=config,
            player=player,
            characters_by_hid=characters_by_hid,
        )
        game_order = {game_name: index for index, game_name in enumerate(config.game_names)}
        return self._sort_with_expertise_priority(
            candidates=candidates,
            matched_npc_hids=matched_npc_hids,
            game_order=game_order,
        )
list_candidate_assignments_async(*, provider, config, player) async

Return allowed triplets ordered with expertise-matching NPCs first.

Source code in dcs_simulation_engine/core/assignment_strategies/expertise_matched_character_choice.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
async def list_candidate_assignments_async(
    self,
    *,
    provider: Any,
    config,
    player,
) -> list[AssignmentCandidate]:
    """Return allowed triplets ordered with expertise-matching NPCs first."""
    candidates = await self._build_candidate_pool(provider=provider, config=config, player=player)
    characters_by_hid = await self._character_map(provider=provider)
    matched_npc_hids = await self._expertise_match_hids(
        provider=provider,
        config=config,
        player=player,
        characters_by_hid=characters_by_hid,
    )
    game_order = {game_name: index for index, game_name in enumerate(config.game_names)}
    return self._sort_with_expertise_priority(
        candidates=candidates,
        matched_npc_hids=matched_npc_hids,
        game_order=game_order,
    )
expertise_matched_character_next

Expertise-matched next assignment strategy.

ExpertiseMatchedCharacterNextAssignmentStrategy

Bases: CandidateAssignmentStrategy

Candidate assignments include allowed triplets ordered with expertise-matching NPCs first.

Source code in dcs_simulation_engine/core/assignment_strategies/expertise_matched_character_next.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
class ExpertiseMatchedCharacterNextAssignmentStrategy(CandidateAssignmentStrategy):
    """Candidate assignments include allowed triplets ordered with expertise-matching NPCs first."""

    name = "expertise_matched_character_next"

    async def list_candidate_assignments_async(
        self,
        *,
        provider: Any,
        config,
        player,
    ) -> list[AssignmentCandidate]:
        """Return allowed triplets ordered with expertise-matching NPCs first."""
        candidates = await self._build_candidate_pool(provider=provider, config=config, player=player)
        characters_by_hid = await self._character_map(provider=provider)
        matched_npc_hids = await self._expertise_match_hids(
            provider=provider,
            config=config,
            player=player,
            characters_by_hid=characters_by_hid,
        )
        game_order = {game_name: index for index, game_name in enumerate(config.game_names)}
        return self._sort_with_expertise_priority(
            candidates=candidates,
            matched_npc_hids=matched_npc_hids,
            game_order=game_order,
        )
list_candidate_assignments_async(*, provider, config, player) async

Return allowed triplets ordered with expertise-matching NPCs first.

Source code in dcs_simulation_engine/core/assignment_strategies/expertise_matched_character_next.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
async def list_candidate_assignments_async(
    self,
    *,
    provider: Any,
    config,
    player,
) -> list[AssignmentCandidate]:
    """Return allowed triplets ordered with expertise-matching NPCs first."""
    candidates = await self._build_candidate_pool(provider=provider, config=config, player=player)
    characters_by_hid = await self._character_map(provider=provider)
    matched_npc_hids = await self._expertise_match_hids(
        provider=provider,
        config=config,
        player=player,
        characters_by_hid=characters_by_hid,
    )
    game_order = {game_name: index for index, game_name in enumerate(config.game_names)}
    return self._sort_with_expertise_priority(
        candidates=candidates,
        matched_npc_hids=matched_npc_hids,
        game_order=game_order,
    )
full_character_access

Full-character-access assignment strategy.

FullCharacterAccessAssignmentStrategy

Bases: CandidateAssignmentStrategy

Candidate assignments include every allowed game + pc + npc triplet.

Source code in dcs_simulation_engine/core/assignment_strategies/full_character_access.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class FullCharacterAccessAssignmentStrategy(CandidateAssignmentStrategy):
    """Candidate assignments include every allowed game + pc + npc triplet."""

    name = "full_character_access"

    async def list_candidate_assignments_async(
        self,
        *,
        provider: Any,
        config,
        player,
    ) -> list[AssignmentCandidate]:
        """Return every allowed game + pc + npc triplet."""
        return await self._build_candidate_pool(provider=provider, config=config, player=player)
list_candidate_assignments_async(*, provider, config, player) async

Return every allowed game + pc + npc triplet.

Source code in dcs_simulation_engine/core/assignment_strategies/full_character_access.py
14
15
16
17
18
19
20
21
22
async def list_candidate_assignments_async(
    self,
    *,
    provider: Any,
    config,
    player,
) -> list[AssignmentCandidate]:
    """Return every allowed game + pc + npc triplet."""
    return await self._build_candidate_pool(provider=provider, config=config, player=player)
least_played_combination_next

Least-played-combination assignment strategy.

LeastPlayedCombinationNextAssignmentStrategy

Bases: CandidateAssignmentStrategy

Candidate assignments include triplets for the globally least-played game + npc group.

Source code in dcs_simulation_engine/core/assignment_strategies/least_played_combination_next.py
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
class LeastPlayedCombinationNextAssignmentStrategy(CandidateAssignmentStrategy):
    """Candidate assignments include triplets for the globally least-played game + npc group."""

    name = "least_played_combination_next"

    async def list_candidate_assignments_async(
        self,
        *,
        provider: Any,
        config,
        player,
    ) -> list[AssignmentCandidate]:
        """Return triplets for the globally least-played game + npc group."""
        candidates = await self._build_candidate_pool(provider=provider, config=config, player=player)
        if not candidates:
            return []

        game_order = {game_name: index for index, game_name in enumerate(config.game_names)}
        counts = await self._assignments_by_group(
            provider=provider,
            config=config,
            statuses=["in_progress", "completed"],
        )
        grouped: dict[tuple[str, str], list[AssignmentCandidate]] = defaultdict(list)
        for candidate in candidates:
            grouped[(candidate.game_name, candidate.npc_hid)].append(candidate)

        selected_key = min(
            grouped,
            key=lambda key: (
                counts.get(key, 0),
                game_order[key[0]],
                key[1],
            ),
        )
        return grouped[selected_key]
list_candidate_assignments_async(*, provider, config, player) async

Return triplets for the globally least-played game + npc group.

Source code in dcs_simulation_engine/core/assignment_strategies/least_played_combination_next.py
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
async def list_candidate_assignments_async(
    self,
    *,
    provider: Any,
    config,
    player,
) -> list[AssignmentCandidate]:
    """Return triplets for the globally least-played game + npc group."""
    candidates = await self._build_candidate_pool(provider=provider, config=config, player=player)
    if not candidates:
        return []

    game_order = {game_name: index for index, game_name in enumerate(config.game_names)}
    counts = await self._assignments_by_group(
        provider=provider,
        config=config,
        statuses=["in_progress", "completed"],
    )
    grouped: dict[tuple[str, str], list[AssignmentCandidate]] = defaultdict(list)
    for candidate in candidates:
        grouped[(candidate.game_name, candidate.npc_hid)].append(candidate)

    selected_key = min(
        grouped,
        key=lambda key: (
            counts.get(key, 0),
            game_order[key[0]],
            key[1],
        ),
    )
    return grouped[selected_key]
max_contrast_pairing

Max-contrast pairing assignment strategy.

MaxContrastPairingAssignmentStrategy

Bases: CandidateAssignmentStrategy

Candidate assignments include allowed triplets ordered by descending pc-to-npc divergence.

Source code in dcs_simulation_engine/core/assignment_strategies/max_contrast_pairing.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class MaxContrastPairingAssignmentStrategy(CandidateAssignmentStrategy):
    """Candidate assignments include allowed triplets ordered by descending pc-to-npc divergence."""

    name = "max_contrast_pairing"

    async def list_candidate_assignments_async(
        self,
        *,
        provider: Any,
        config,
        player,
    ) -> list[AssignmentCandidate]:
        """Return allowed triplets ordered by descending pc-to-npc divergence."""
        candidates = await self._build_candidate_pool(provider=provider, config=config, player=player)
        characters_by_hid = await self._character_map(provider=provider)
        game_order = {game_name: index for index, game_name in enumerate(config.game_names)}
        return self._sort_by_descending_contrast(
            candidates=candidates,
            characters_by_hid=characters_by_hid,
            game_order=game_order,
        )
list_candidate_assignments_async(*, provider, config, player) async

Return allowed triplets ordered by descending pc-to-npc divergence.

Source code in dcs_simulation_engine/core/assignment_strategies/max_contrast_pairing.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
async def list_candidate_assignments_async(
    self,
    *,
    provider: Any,
    config,
    player,
) -> list[AssignmentCandidate]:
    """Return allowed triplets ordered by descending pc-to-npc divergence."""
    candidates = await self._build_candidate_pool(provider=provider, config=config, player=player)
    characters_by_hid = await self._character_map(provider=provider)
    game_order = {game_name: index for index, game_name in enumerate(config.game_names)}
    return self._sort_by_descending_contrast(
        candidates=candidates,
        characters_by_hid=characters_by_hid,
        game_order=game_order,
    )
next_incomplete_combination

Next-incomplete-combination assignment strategy.

NextIncompleteCombinationAssignmentStrategy

Bases: CandidateAssignmentStrategy

Candidate assignments include triplets for the first incomplete game + npc group in config order.

Source code in dcs_simulation_engine/core/assignment_strategies/next_incomplete_combination.py
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
class NextIncompleteCombinationAssignmentStrategy(CandidateAssignmentStrategy):
    """Candidate assignments include triplets for the first incomplete game + npc group in config order."""

    name = "next_incomplete_combination"

    async def list_candidate_assignments_async(
        self,
        *,
        provider: Any,
        config,
        player,
    ) -> list[AssignmentCandidate]:
        """Return triplets for the first incomplete game + npc group in config order."""
        assignments = await self._list_player_assignments(provider=provider, config=config, player=player)
        completed_groups = self._completed_group_keys(assignments=assignments)
        candidates = await self._build_candidate_pool(provider=provider, config=config, player=player)
        grouped: dict[tuple[str, str], list[AssignmentCandidate]] = defaultdict(list)
        for candidate in candidates:
            grouped[(candidate.game_name, candidate.npc_hid)].append(candidate)

        for game_name in config.game_names:
            matching_groups = sorted(key for key in grouped if key[0] == game_name)
            for key in matching_groups:
                if key not in completed_groups:
                    return grouped[key]
        return []
list_candidate_assignments_async(*, provider, config, player) async

Return triplets for the first incomplete game + npc group in config order.

Source code in dcs_simulation_engine/core/assignment_strategies/next_incomplete_combination.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
async def list_candidate_assignments_async(
    self,
    *,
    provider: Any,
    config,
    player,
) -> list[AssignmentCandidate]:
    """Return triplets for the first incomplete game + npc group in config order."""
    assignments = await self._list_player_assignments(provider=provider, config=config, player=player)
    completed_groups = self._completed_group_keys(assignments=assignments)
    candidates = await self._build_candidate_pool(provider=provider, config=config, player=player)
    grouped: dict[tuple[str, str], list[AssignmentCandidate]] = defaultdict(list)
    for candidate in candidates:
        grouped[(candidate.game_name, candidate.npc_hid)].append(candidate)

    for game_name in config.game_names:
        matching_groups = sorted(key for key in grouped if key[0] == game_name)
        for key in matching_groups:
            if key not in completed_groups:
                return grouped[key]
    return []
progressive_divergence_assignment

Progressive-divergence assignment strategy.

ProgressiveDivergenceAssignmentStrategy

Bases: CandidateAssignmentStrategy

Candidate assignments include triplets ordered by descending divergence from the last completed NPC.

Source code in dcs_simulation_engine/core/assignment_strategies/progressive_divergence_assignment.py
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
50
51
52
53
54
55
56
57
58
59
class ProgressiveDivergenceAssignmentStrategy(CandidateAssignmentStrategy):
    """Candidate assignments include triplets ordered by descending divergence from the last completed NPC."""

    name = "progressive_divergence_assignment"

    async def list_candidate_assignments_async(
        self,
        *,
        provider: Any,
        config,
        player,
    ) -> list[AssignmentCandidate]:
        """Return triplets ordered by descending divergence from the player's last completed NPC."""
        assignments = await self._list_player_assignments(provider=provider, config=config, player=player)
        latest_completed = self._latest_completed_assignment(assignments=assignments)
        if latest_completed is None:
            fallback = LeastPlayedCombinationNextAssignmentStrategy()
            return await fallback.list_candidate_assignments_async(provider=provider, config=config, player=player)

        candidates = await self._build_candidate_pool(provider=provider, config=config, player=player)
        if not candidates:
            return []

        characters_by_hid = await self._character_map(provider=provider)
        reference_npc = characters_by_hid.get(latest_completed.npc_hid)
        if reference_npc is None:
            fallback = LeastPlayedCombinationNextAssignmentStrategy()
            return await fallback.list_candidate_assignments_async(provider=provider, config=config, player=player)

        counts = await self._assignments_by_group(
            provider=provider,
            config=config,
            statuses=["in_progress", "completed"],
        )
        game_order = {game_name: index for index, game_name in enumerate(config.game_names)}
        ranked = self._sort_by_descending_divergence(
            candidates=candidates,
            reference_npc=reference_npc,
            characters_by_hid=characters_by_hid,
            fallback_counts=counts,
            game_order=game_order,
        )
        grouped: dict[tuple[str, str], list[AssignmentCandidate]] = defaultdict(list)
        for candidate in ranked:
            grouped[(candidate.game_name, candidate.npc_hid)].append(candidate)
        top = ranked[0]
        return grouped[(top.game_name, top.npc_hid)]
list_candidate_assignments_async(*, provider, config, player) async

Return triplets ordered by descending divergence from the player's last completed NPC.

Source code in dcs_simulation_engine/core/assignment_strategies/progressive_divergence_assignment.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
async def list_candidate_assignments_async(
    self,
    *,
    provider: Any,
    config,
    player,
) -> list[AssignmentCandidate]:
    """Return triplets ordered by descending divergence from the player's last completed NPC."""
    assignments = await self._list_player_assignments(provider=provider, config=config, player=player)
    latest_completed = self._latest_completed_assignment(assignments=assignments)
    if latest_completed is None:
        fallback = LeastPlayedCombinationNextAssignmentStrategy()
        return await fallback.list_candidate_assignments_async(provider=provider, config=config, player=player)

    candidates = await self._build_candidate_pool(provider=provider, config=config, player=player)
    if not candidates:
        return []

    characters_by_hid = await self._character_map(provider=provider)
    reference_npc = characters_by_hid.get(latest_completed.npc_hid)
    if reference_npc is None:
        fallback = LeastPlayedCombinationNextAssignmentStrategy()
        return await fallback.list_candidate_assignments_async(provider=provider, config=config, player=player)

    counts = await self._assignments_by_group(
        provider=provider,
        config=config,
        statuses=["in_progress", "completed"],
    )
    game_order = {game_name: index for index, game_name in enumerate(config.game_names)}
    ranked = self._sort_by_descending_divergence(
        candidates=candidates,
        reference_npc=reference_npc,
        characters_by_hid=characters_by_hid,
        fallback_counts=counts,
        game_order=game_order,
    )
    grouped: dict[tuple[str, str], list[AssignmentCandidate]] = defaultdict(list)
    for candidate in ranked:
        grouped[(candidate.game_name, candidate.npc_hid)].append(candidate)
    top = ranked[0]
    return grouped[(top.game_name, top.npc_hid)]
random_unique_game

Random unique game assignment strategy module.

RandomUniqueGameAssignmentStrategy

Bases: CandidateAssignmentStrategy

Candidate assignments include allowed triplets from games the player has not already been assigned.

Source code in dcs_simulation_engine/core/assignment_strategies/random_unique_game.py
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
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
class RandomUniqueGameAssignmentStrategy(CandidateAssignmentStrategy):
    """Candidate assignments include allowed triplets from games the player has not already been assigned."""

    name = "random_unique_game"

    def validate_config(self, *, config) -> None:
        """Validate the legacy unique-game constraint for this strategy."""
        super().validate_config(config=config)
        if config.assignment_strategy.quota_per_game is None or config.assignment_strategy.quota_per_game <= 0:
            raise ValueError("random_unique_game requires a positive quota_per_game")
        max_assignments = config.assignment_strategy.max_assignments_per_player
        if max_assignments is not None and max_assignments > len(config.game_names):
            raise ValueError(
                "random_unique_game cannot assign more games per player than are listed in run config games"
            )

    async def list_candidate_assignments_async(
        self,
        *,
        provider: Any,
        config,
        player,
    ) -> list[AssignmentCandidate]:
        """Return allowed triplets from unassigned games in deterministic random game order."""
        player_assignments = await self._list_player_assignments(provider=provider, config=config, player=player)
        assigned_games = {assignment.game_name for assignment in player_assignments}
        pool = await self._build_candidate_pool(provider=provider, config=config, player=player)
        eligible = [candidate for candidate in pool if candidate.game_name not in assigned_games]
        if not eligible:
            return []

        game_rng = self._rng_for(config=config, player_id=player.id, salt="game")
        grouped: dict[str, list[AssignmentCandidate]] = {}
        for candidate in eligible:
            grouped.setdefault(candidate.game_name, []).append(candidate)

        game_names = list(grouped)
        game_rng.shuffle(game_names)
        ordered: list[AssignmentCandidate] = []
        for game_name in game_names:
            candidates = list(grouped[game_name])
            selection_rng = self._rng_for(config=config, player_id=player.id, salt=f"combo:{game_name}")
            selection_rng.shuffle(candidates)
            ordered.extend(candidates)
        return ordered

    async def generate_remaining_assignments_async(
        self,
        *,
        provider: Any,
        config,
        player,
    ) -> list:
        """Pre-generate remaining unique-game assignments for legacy tests."""
        existing_assignments = await self._list_player_assignments(provider=provider, config=config, player=player)
        if len(existing_assignments) >= self.max_assignments_per_player(config=config):
            return []

        created = []
        assigned_games = {assignment.game_name for assignment in existing_assignments}
        candidates = await self.list_candidate_assignments_async(provider=provider, config=config, player=player)
        for candidate in candidates:
            if len(existing_assignments) + len(created) >= self.max_assignments_per_player(config=config):
                break
            if candidate.game_name in assigned_games:
                continue
            assignment = await maybe_await(
                provider.create_assignment(
                    assignment_doc=self._assignment_doc_for_candidate(config=config, player=player, candidate=candidate),
                    allow_concurrent=True,
                )
            )
            if assignment is not None:
                assigned_games.add(candidate.game_name)
                created.append(assignment)
        return created

    def _rng_for(self, *, config, player_id: str, salt: str) -> random.Random:
        seed_value = config.assignment_strategy.seed or config.name
        return random.Random(f"{seed_value}:{player_id}:{salt}")
generate_remaining_assignments_async(*, provider, config, player) async

Pre-generate remaining unique-game assignments for legacy tests.

Source code in dcs_simulation_engine/core/assignment_strategies/random_unique_game.py
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
async def generate_remaining_assignments_async(
    self,
    *,
    provider: Any,
    config,
    player,
) -> list:
    """Pre-generate remaining unique-game assignments for legacy tests."""
    existing_assignments = await self._list_player_assignments(provider=provider, config=config, player=player)
    if len(existing_assignments) >= self.max_assignments_per_player(config=config):
        return []

    created = []
    assigned_games = {assignment.game_name for assignment in existing_assignments}
    candidates = await self.list_candidate_assignments_async(provider=provider, config=config, player=player)
    for candidate in candidates:
        if len(existing_assignments) + len(created) >= self.max_assignments_per_player(config=config):
            break
        if candidate.game_name in assigned_games:
            continue
        assignment = await maybe_await(
            provider.create_assignment(
                assignment_doc=self._assignment_doc_for_candidate(config=config, player=player, candidate=candidate),
                allow_concurrent=True,
            )
        )
        if assignment is not None:
            assigned_games.add(candidate.game_name)
            created.append(assignment)
    return created
list_candidate_assignments_async(*, provider, config, player) async

Return allowed triplets from unassigned games in deterministic random game order.

Source code in dcs_simulation_engine/core/assignment_strategies/random_unique_game.py
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
async def list_candidate_assignments_async(
    self,
    *,
    provider: Any,
    config,
    player,
) -> list[AssignmentCandidate]:
    """Return allowed triplets from unassigned games in deterministic random game order."""
    player_assignments = await self._list_player_assignments(provider=provider, config=config, player=player)
    assigned_games = {assignment.game_name for assignment in player_assignments}
    pool = await self._build_candidate_pool(provider=provider, config=config, player=player)
    eligible = [candidate for candidate in pool if candidate.game_name not in assigned_games]
    if not eligible:
        return []

    game_rng = self._rng_for(config=config, player_id=player.id, salt="game")
    grouped: dict[str, list[AssignmentCandidate]] = {}
    for candidate in eligible:
        grouped.setdefault(candidate.game_name, []).append(candidate)

    game_names = list(grouped)
    game_rng.shuffle(game_names)
    ordered: list[AssignmentCandidate] = []
    for game_name in game_names:
        candidates = list(grouped[game_name])
        selection_rng = self._rng_for(config=config, player_id=player.id, salt=f"combo:{game_name}")
        selection_rng.shuffle(candidates)
        ordered.extend(candidates)
    return ordered
validate_config(*, config)

Validate the legacy unique-game constraint for this strategy.

Source code in dcs_simulation_engine/core/assignment_strategies/random_unique_game.py
16
17
18
19
20
21
22
23
24
25
def validate_config(self, *, config) -> None:
    """Validate the legacy unique-game constraint for this strategy."""
    super().validate_config(config=config)
    if config.assignment_strategy.quota_per_game is None or config.assignment_strategy.quota_per_game <= 0:
        raise ValueError("random_unique_game requires a positive quota_per_game")
    max_assignments = config.assignment_strategy.max_assignments_per_player
    if max_assignments is not None and max_assignments > len(config.game_names):
        raise ValueError(
            "random_unique_game cannot assign more games per player than are listed in run config games"
        )
unplayed_combination_choice

Unplayed-combination assignment strategy.

UnplayedCombinationChoiceAssignmentStrategy

Bases: CandidateAssignmentStrategy

Candidate assignments include allowed triplets ordered with never-played triplets first.

Source code in dcs_simulation_engine/core/assignment_strategies/unplayed_combination_choice.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
class UnplayedCombinationChoiceAssignmentStrategy(CandidateAssignmentStrategy):
    """Candidate assignments include allowed triplets ordered with never-played triplets first."""

    name = "unplayed_combination_choice"

    async def list_candidate_assignments_async(
        self,
        *,
        provider: Any,
        config,
        player,
    ) -> list[AssignmentCandidate]:
        """Return allowed triplets ordered by ascending player completion count for the exact triplet."""
        game_order = {game_name: index for index, game_name in enumerate(config.game_names)}
        assignments = await self._list_player_assignments(provider=provider, config=config, player=player)
        triple_counts = self._completed_triple_counts(assignments=assignments)
        candidates = await self._build_candidate_pool(provider=provider, config=config, player=player)
        return sorted(
            candidates,
            key=lambda candidate: (
                triple_counts[(candidate.game_name, candidate.pc_hid, candidate.npc_hid)],
                game_order[candidate.game_name],
                candidate.pc_hid,
                candidate.npc_hid,
            ),
        )
list_candidate_assignments_async(*, provider, config, player) async

Return allowed triplets ordered by ascending player completion count for the exact triplet.

Source code in dcs_simulation_engine/core/assignment_strategies/unplayed_combination_choice.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
async def list_candidate_assignments_async(
    self,
    *,
    provider: Any,
    config,
    player,
) -> list[AssignmentCandidate]:
    """Return allowed triplets ordered by ascending player completion count for the exact triplet."""
    game_order = {game_name: index for index, game_name in enumerate(config.game_names)}
    assignments = await self._list_player_assignments(provider=provider, config=config, player=player)
    triple_counts = self._completed_triple_counts(assignments=assignments)
    candidates = await self._build_candidate_pool(provider=provider, config=config, player=player)
    return sorted(
        candidates,
        key=lambda candidate: (
            triple_counts[(candidate.game_name, candidate.pc_hid, candidate.npc_hid)],
            game_order[candidate.game_name],
            candidate.pc_hid,
            candidate.npc_hid,
        ),
    )

constants

Constants for core module.

engine_run_manager

Engine-run orchestration for assignment-driven flows.

EngineRunManager

Resolves assignment workflows for the single configured engine run.

Source code in dcs_simulation_engine/core/engine_run_manager.py
 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
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
class EngineRunManager:
    """Resolves assignment workflows for the single configured engine run."""

    _run_config: RunConfig | None = None

    def __init__(self, run_config: RunConfig, provider: Any | None = None) -> None:
        """Initialize the manager with the active run config and optional provider."""
        self.run_config = run_config
        self.provider = provider
        type(self)._run_config = run_config

    @classmethod
    def get_run_config(cls) -> RunConfig:
        """Return the active run config."""
        if cls._run_config is None:
            raise RuntimeError("EngineRunManager has not been configured with a RunConfig.")
        return cls._run_config

    @classmethod
    async def ensure_run_async(cls, *, provider: Any) -> RunRecord:
        """Persist the run config snapshot if it is not already stored."""
        config = cls.get_run_config()
        existing = await maybe_await(provider.get_run())
        progress = await cls.compute_progress_async(provider=provider)
        if existing is not None:
            updated = await maybe_await(provider.set_run_progress(progress=progress))
            return updated or existing
        return await maybe_await(
            provider.upsert_run(
                name=config.name,
                description=config.description,
                config_snapshot=config.model_dump(mode="json"),
                progress=progress,
            )
        )

    @classmethod
    async def compute_progress_async(cls, *, provider: Any) -> dict[str, Any]:
        """Compute finite usability progress from assignment state."""
        config = cls.get_run_config()
        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) -> dict[str, Any]:
        """Compute quota-centric status counts for a run."""
        config = cls.get_run_config()
        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,
        player_id: str,
    ) -> dict[str, Any]:
        """Return the assignment state visible to one authenticated player."""
        config = cls.get_run_config()
        player_assignments = await maybe_await(provider.list_assignments(player_id=player_id))
        active_assignment = await maybe_await(provider.get_active_assignment(player_id=player_id))
        completed_assignments = [item for item in player_assignments if item.status == "completed"]
        strategy = cls._strategy_for(config=config)
        has_finished_run = len(completed_assignments) >= strategy.max_assignments_per_player(config=config)
        eligible_assignment_options: list[dict[str, str]] = []
        pending_form_groups = await cls.pending_form_groups_async(
            provider=provider,
            config=config,
            player_id=player_id,
            assignments=player_assignments,
            active_assignment=active_assignment,
            has_finished_run=has_finished_run,
        )

        if not cls._blocks_assignment_resolution(pending_form_groups) and not has_finished_run:
            player_record = await maybe_await(provider.get_player(player_id=player_id))
            if player_record is not None:
                active_assignment, eligible_assignment_options = await cls.resolve_assignment_state_async(
                    provider=provider,
                    player=player_record,
                    player_assignments=player_assignments,
                    active_assignment=active_assignment,
                )
                player_assignments = await maybe_await(provider.list_assignments(player_id=player_id))
                completed_assignments = [item for item in player_assignments if item.status == "completed"]
                has_finished_run = len(completed_assignments) >= strategy.max_assignments_per_player(config=config)
                pending_form_groups = await cls.pending_form_groups_async(
                    provider=provider,
                    config=config,
                    player_id=player_id,
                    assignments=player_assignments,
                    active_assignment=active_assignment,
                    has_finished_run=has_finished_run,
                )

        pending_assignment_form_items = [
            assignment
            for assignment in player_assignments
            if any(
                group["trigger"]["event"] == "after_assignment" and group.get("assignment_id") == assignment.assignment_id
                for group in pending_form_groups
            )
        ]

        return {
            "active_assignment": active_assignment,
            "pending_assignment_form_ids": [item.assignment_id for item in pending_assignment_form_items],
            "has_finished_run": has_finished_run,
            "eligible_assignment_options": eligible_assignment_options,
            "pending_form_groups": pending_form_groups,
            "assignments": await maybe_await(provider.list_assignments(player_id=player_id)),
        }

    @classmethod
    async def pending_form_groups_async(
        cls,
        *,
        provider: Any,
        config: RunConfig,
        player_id: str,
        assignments: list[AssignmentRecord] | None = None,
        active_assignment: AssignmentRecord | None = None,
        has_finished_run: bool | None = None,
    ) -> list[dict[str, Any]]:
        """Return actionable form groups still required for a player."""
        if assignments is None:
            assignments = await maybe_await(provider.list_assignments(player_id=player_id))
        if active_assignment is None:
            active_assignment = await maybe_await(provider.get_active_assignment(player_id=player_id))
        if has_finished_run is None:
            strategy = cls._strategy_for(config=config)
            completed_count = sum(1 for item in assignments if item.status == "completed")
            has_finished_run = completed_count >= strategy.max_assignments_per_player(config=config)

        groups: list[dict[str, Any]] = []
        player_forms = await maybe_await(provider.get_player_forms(player_id=player_id))
        submitted_player_form_names = set((player_forms.data if player_forms else {}).keys())
        groups.extend(
            cls._pending_player_form_group(
                config=config,
                event="before_all_assignments",
                submitted_form_names=submitted_player_form_names,
            )
        )

        for assignment in assignments:
            if assignment.status in {"assigned", "interrupted"}:
                groups.extend(cls._pending_assignment_form_group(config=config, event="before_assignment", assignment=assignment))
            if assignment.status == "completed":
                groups.extend(cls._pending_assignment_form_group(config=config, event="after_assignment", assignment=assignment))

        if has_finished_run:
            groups.extend(
                cls._pending_player_form_group(
                    config=config,
                    event="after_all_assignments",
                    submitted_form_names=submitted_player_form_names,
                )
            )

        return groups

    @classmethod
    def _pending_player_form_group(
        cls,
        *,
        config: RunConfig,
        event: FormTriggerEvent,
        submitted_form_names: set[str],
    ) -> list[dict[str, Any]]:
        forms = config.forms_for_trigger(event=event)
        if not forms:
            return []
        required_names = {form.name for form in forms}
        if required_names.issubset(submitted_form_names):
            return []
        return [
            {
                "group_id": event,
                "trigger": {"event": event, "match": None},
                "forms": forms,
            }
        ]

    @classmethod
    def _pending_assignment_form_group(
        cls,
        *,
        config: RunConfig,
        event: FormTriggerEvent,
        assignment: AssignmentRecord,
    ) -> list[dict[str, Any]]:
        forms = config.forms_for_trigger(event=event)
        if not forms:
            return []
        submitted = set(assignment.data.get(MongoColumns.FORM_RESPONSES, {}).keys())
        required_names = {form.name for form in forms}
        if required_names.issubset(submitted):
            return []
        return [
            {
                "group_id": f"{event}:{assignment.assignment_id}",
                "trigger": {"event": event, "match": None},
                "assignment_id": assignment.assignment_id,
                "forms": forms,
            }
        ]

    @classmethod
    def _blocks_assignment_resolution(cls, pending_form_groups: list[dict[str, Any]]) -> bool:
        blocking_events = {"before_all_assignments", "after_assignment", "after_all_assignments"}
        return any(group["trigger"]["event"] in blocking_events for group in pending_form_groups)

    @classmethod
    def _blocks_assignment_start(cls, *, pending_form_groups: list[dict[str, Any]], assignment_id: str) -> bool:
        for group in pending_form_groups:
            event = group["trigger"]["event"]
            if event in {"before_all_assignments", "after_assignment", "after_all_assignments"}:
                return True
            if event == "before_assignment" and group.get("assignment_id") == assignment_id:
                return True
        return False

    @classmethod
    async def resolve_assignment_state_async(
        cls,
        *,
        provider: Any,
        player: PlayerRecord,
        player_assignments: list[AssignmentRecord] | None = None,
        active_assignment: AssignmentRecord | None = None,
    ) -> tuple[AssignmentRecord | None, list[dict[str, str]]]:
        """Return the current assignment or selectable next options for a player."""
        config = cls.get_run_config()
        strategy = cls._strategy_for(config=config)
        assignments = player_assignments
        if assignments is None:
            assignments = await maybe_await(provider.list_assignments(player_id=player.id))
        pending_groups = await cls.pending_form_groups_async(
            provider=provider,
            config=config,
            player_id=player.id,
            assignments=assignments,
            active_assignment=active_assignment,
        )
        if cls._blocks_assignment_resolution(pending_groups):
            return None, []
        current = cls._current_assignment_from_policy(
            config=config,
            assignments=assignments,
            active_assignment=active_assignment,
        )
        if current is not None:
            return current, []

        completed_count = sum(1 for item in assignments if item.status == "completed")
        if completed_count >= strategy.max_assignments_per_player(config=config):
            return None, []

        candidates = await maybe_await(strategy.list_candidate_assignments_async(provider=provider, config=config, player=player))
        if not candidates:
            return None, []

        if config.assignment_strategy.allow_choice_if_multiple and len(candidates) > 1:
            return None, [cls._candidate_to_option(candidate) for candidate in candidates]

        assignment = await maybe_await(
            provider.create_assignment(
                assignment_doc=cls._assignment_doc_for_candidate(config=config, player=player, candidate=candidates[0]),
                allow_concurrent=cls._allow_concurrent_assignment(config=config, assignments=assignments),
            )
        )
        return assignment, []

    @classmethod
    def _current_assignment_from_policy(
        cls,
        *,
        config: RunConfig,
        assignments: list[AssignmentRecord],
        active_assignment: AssignmentRecord | None,
    ) -> AssignmentRecord | None:
        if config.assignment_strategy.require_completion:
            if active_assignment is not None and active_assignment.status == "in_progress":
                return active_assignment
            for assignment in assignments:
                if assignment.status == "in_progress":
                    return assignment
            if active_assignment is not None:
                return active_assignment
            for assignment in assignments:
                if assignment.status in {"assigned", "interrupted"}:
                    return assignment
            return None
        if active_assignment is not None and active_assignment.status == "assigned":
            return active_assignment
        for assignment in assignments:
            if assignment.status == "assigned":
                return assignment
        return None

    @classmethod
    def _allow_concurrent_assignment(cls, *, config: RunConfig, assignments: list[AssignmentRecord]) -> bool:
        return not config.assignment_strategy.require_completion

    @classmethod
    def _candidate_to_option(cls, candidate: AssignmentCandidate) -> dict[str, str]:
        return {
            "game_name": candidate.game_name,
            "pc_hid": candidate.pc_hid,
            "npc_hid": candidate.npc_hid,
        }

    @classmethod
    async def assignment_display_metadata_async(
        cls,
        *,
        provider: Any,
        game_name: str,
        pc_hid: str,
        npc_hid: str,
    ) -> dict[str, Any]:
        """Return display metadata for an assignment triplet."""
        game_config = SessionManager.get_game_config_cached(game_name)
        pc = await maybe_await(provider.get_character(hid=pc_hid))
        npc = await maybe_await(provider.get_character(hid=npc_hid))
        show_simulator_details = cls._game_shows_simulator_details(game_config=game_config)
        simulator_description = npc.short_description or ""
        if not show_simulator_details:
            simulator_description = "Details hidden"
        return {
            "game_description": game_config.description or "",
            "player_character_name": pc.name or pc.hid,
            "player_character_description": pc.short_description or "",
            "simulator_character_description": simulator_description,
            "simulator_character_details_visible": show_simulator_details,
        }

    @classmethod
    async def enrich_assignment_option_async(
        cls,
        *,
        provider: Any,
        option: dict[str, str],
    ) -> dict[str, Any]:
        """Attach display metadata to an assignment option."""
        metadata = await cls.assignment_display_metadata_async(
            provider=provider,
            game_name=option["game_name"],
            pc_hid=option["pc_hid"],
            npc_hid=option["npc_hid"],
        )
        return {**option, **metadata}

    @classmethod
    def _game_shows_simulator_details(cls, *, game_config: Any) -> bool:
        game_cls = game_config.get_game_class()
        overrides = game_cls.parse_overrides(getattr(game_config, "overrides", {}) or {})
        return bool(getattr(overrides, "show_npc_details", True))

    @classmethod
    def _assignment_doc_for_candidate(
        cls,
        *,
        config: RunConfig,
        player: PlayerRecord,
        candidate: AssignmentCandidate,
    ) -> dict[str, Any]:
        assignment_doc: dict[str, Any] = {
            MongoColumns.PLAYER_ID: player.id,
            MongoColumns.GAME_NAME: candidate.game_name,
            MongoColumns.PC_HID: candidate.pc_hid,
            MongoColumns.NPC_HID: candidate.npc_hid,
            MongoColumns.STATUS: "assigned",
            MongoColumns.FORM_RESPONSES: {},
        }
        if candidate.metadata:
            assignment_doc.update(candidate.metadata)
        return assignment_doc

    @classmethod
    async def submit_form_group_async(
        cls,
        *,
        provider: Any,
        player_id: str,
        group_id: str,
        responses: dict[str, Any],
    ) -> dict[str, Any]:
        """Store responses for one currently pending form group."""
        config = cls.get_run_config()
        pending_groups = await cls.pending_form_groups_async(
            provider=provider,
            config=config,
            player_id=player_id,
        )
        group = next((item for item in pending_groups if item["group_id"] == group_id), None)
        if group is None:
            raise ValueError("No pending form group matches the submitted group_id.")

        forms = list(group["forms"])
        normalized = cls.normalize_form_submissions(forms=forms, responses=responses)
        event = group["trigger"]["event"]
        if event in {"before_all_assignments", "after_all_assignments"}:
            await cls.store_player_form_payloads_async(
                provider=provider,
                player_id=player_id,
                forms_payload=normalized,
            )
            return group

        assignment_id = group.get("assignment_id")
        if not assignment_id:
            raise ValueError("Assignment-scoped form group is missing assignment_id.")
        updated = await cls.store_form_payloads_async(
            provider=provider,
            assignment_id=assignment_id,
            forms_payload=normalized,
        )
        if updated is None:
            raise ValueError("Failed to store the form response.")
        return group

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

    @classmethod
    async def start_assignment_session_async(
        cls,
        *,
        provider: Any,
        registry: "SessionRegistry",
        player: PlayerRecord,
        source: str = "run",
        assignment_id: str | None = None,
    ) -> tuple["SessionEntry", AssignmentRecord]:
        """Start or resume gameplay for the requested assignment."""
        from dcs_simulation_engine.api.registry import hydrate_session_async

        assignment: AssignmentRecord | None
        if assignment_id is not None:
            assignment = await cls.get_assignment_for_player_async(
                provider=provider,
                player_id=player.id,
                assignment_id=assignment_id,
            )
        else:
            state = await cls.get_player_state_async(provider=provider, player_id=player.id)
            assignment = state["active_assignment"]
        if assignment is None:
            raise ValueError("No matching assignment is available for this player.")
        if assignment.status == "completed":
            raise ValueError("Completed assignments cannot be resumed.")
        if assignment.status == "interrupted":
            raise ValueError("Interrupted assignments cannot be resumed.")
        config = cls.get_run_config()
        assignments = await maybe_await(provider.list_assignments(player_id=player.id))
        pending_groups = await cls.pending_form_groups_async(
            provider=provider,
            config=config,
            player_id=player.id,
            assignments=assignments,
            active_assignment=assignment,
        )
        if cls._blocks_assignment_start(pending_form_groups=pending_groups, assignment_id=assignment.assignment_id):
            raise ValueError("Required form responses must be submitted before starting this assignment.")

        if assignment.status == "in_progress":
            # Check whether the existing session is still resumable before
            # refusing to start.  If it is paused (in registry or in DB),
            # return it instead of creating a duplicate session.
            existing_session_id = assignment.data.get(MongoColumns.ACTIVE_SESSION_ID)
            if existing_session_id:
                existing_entry = registry.get(existing_session_id)
                if existing_entry is None:
                    existing_entry = await hydrate_session_async(
                        session_id=existing_session_id,
                        player_id=player.id,
                        provider=provider,
                        registry=registry,
                    )
                if existing_entry is not None and not existing_entry.manager.exited:
                    return existing_entry, assignment
            raise ValueError("This assignment is already in progress.")

        manager = await SessionManager.create_async(
            game=assignment.game_name,
            provider=provider,
            source=source,
            pc_choice=assignment.pc_hid,
            npc_choice=assignment.npc_hid,
            player_id=player.id,
        )
        entry = registry.add(
            player_id=player.id,
            game_name=assignment.game_name,
            manager=manager,
            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 run assignment as in progress.")
        return entry, updated_assignment

    @classmethod
    async def get_assignment_for_player_async(
        cls,
        *,
        provider: Any,
        player_id: str,
        assignment_id: str,
    ) -> AssignmentRecord | None:
        """Return one assignment if it belongs to the requested player."""
        assignment = await maybe_await(provider.get_assignment(assignment_id=assignment_id))
        if assignment is None:
            return None
        if assignment.player_id != player_id:
            return None
        return assignment

    @classmethod
    async def handle_session_terminal_state_async(
        cls,
        *,
        provider: Any,
        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_run_async(provider=provider)
        return updated

    @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,
        forms_payload: dict[str, dict[str, Any]],
    ) -> None:
        """Store one or more named player-scoped form payloads on the forms record."""
        for form_key, payload in forms_payload.items():
            await maybe_await(
                provider.set_player_form_response(
                    player_id=player_id,
                    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 assignment for a player."""
        getter = getattr(provider, "get_latest_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[Form],
        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,
                "trigger": form.trigger.model_dump(mode="json"),
                "submitted_at": utc_now(),
                "answers": normalized_answers,
            }
        return normalized

    @classmethod
    def _normalize_question_answer(cls, *, question: FormQuestion, 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,
        player: PlayerRecord,
    ) -> list[dict[str, str]]:
        """Return selectable {game_name, pc_hid, npc_hid} options for the player."""
        state = await cls.get_player_state_async(provider=provider, player_id=player.id)
        return list(state.get("eligible_assignment_options", []))

    @classmethod
    async def create_player_choice_assignment_async(
        cls,
        *,
        provider: Any,
        player: PlayerRecord,
        game_name: str,
        pc_hid: str,
        npc_hid: str,
    ) -> AssignmentRecord:
        """Create a specific assignment for a player who selected one explicit candidate."""
        config = cls.get_run_config()
        if not config.assignment_strategy.allow_choice_if_multiple:
            raise ValueError("This run is not configured for participant assignment choice.")
        eligible = await cls.get_eligible_options_async(
            provider=provider,
            player=player,
        )
        eligible_set = {(opt["game_name"], opt["pc_hid"], opt["npc_hid"]) for opt in eligible}
        if (game_name, pc_hid, npc_hid) not in eligible_set:
            raise ValueError("The selected game, PC, and NPC are not available for assignment.")
        player_assignments = await maybe_await(provider.list_assignments(player_id=player.id))
        assignment = await maybe_await(
            provider.create_assignment(
                assignment_doc={
                    MongoColumns.PLAYER_ID: player.id,
                    MongoColumns.GAME_NAME: game_name,
                    MongoColumns.PC_HID: pc_hid,
                    MongoColumns.NPC_HID: npc_hid,
                    MongoColumns.STATUS: "assigned",
                    MongoColumns.FORM_RESPONSES: {},
                },
                allow_concurrent=cls._allow_concurrent_assignment(config=config, assignments=player_assignments),
            )
        )
        if assignment is None:
            raise ValueError("Failed to create the assignment.")
        return assignment

    @classmethod
    def _strategy_for(cls, *, config: RunConfig):
        """Resolve the configured assignment strategy for one run."""
        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",
            "player_finished",
        }
        return normalized in completion_reasons or normalized.startswith("stopping_condition_met:")
__init__(run_config, provider=None)

Initialize the manager with the active run config and optional provider.

Source code in dcs_simulation_engine/core/engine_run_manager.py
32
33
34
35
36
def __init__(self, run_config: RunConfig, provider: Any | None = None) -> None:
    """Initialize the manager with the active run config and optional provider."""
    self.run_config = run_config
    self.provider = provider
    type(self)._run_config = run_config
assignment_display_metadata_async(*, provider, game_name, pc_hid, npc_hid) async classmethod

Return display metadata for an assignment triplet.

Source code in dcs_simulation_engine/core/engine_run_manager.py
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
@classmethod
async def assignment_display_metadata_async(
    cls,
    *,
    provider: Any,
    game_name: str,
    pc_hid: str,
    npc_hid: str,
) -> dict[str, Any]:
    """Return display metadata for an assignment triplet."""
    game_config = SessionManager.get_game_config_cached(game_name)
    pc = await maybe_await(provider.get_character(hid=pc_hid))
    npc = await maybe_await(provider.get_character(hid=npc_hid))
    show_simulator_details = cls._game_shows_simulator_details(game_config=game_config)
    simulator_description = npc.short_description or ""
    if not show_simulator_details:
        simulator_description = "Details hidden"
    return {
        "game_description": game_config.description or "",
        "player_character_name": pc.name or pc.hid,
        "player_character_description": pc.short_description or "",
        "simulator_character_description": simulator_description,
        "simulator_character_details_visible": show_simulator_details,
    }
compute_progress_async(*, provider) async classmethod

Compute finite usability progress from assignment state.

Source code in dcs_simulation_engine/core/engine_run_manager.py
63
64
65
66
67
68
@classmethod
async def compute_progress_async(cls, *, provider: Any) -> dict[str, Any]:
    """Compute finite usability progress from assignment state."""
    config = cls.get_run_config()
    strategy = cls._strategy_for(config=config)
    return await maybe_await(strategy.compute_progress_async(provider=provider, config=config))
compute_status_async(*, provider) async classmethod

Compute quota-centric status counts for a run.

Source code in dcs_simulation_engine/core/engine_run_manager.py
70
71
72
73
74
75
@classmethod
async def compute_status_async(cls, *, provider: Any) -> dict[str, Any]:
    """Compute quota-centric status counts for a run."""
    config = cls.get_run_config()
    strategy = cls._strategy_for(config=config)
    return await maybe_await(strategy.compute_status_async(provider=provider, config=config))
create_player_choice_assignment_async(*, provider, player, game_name, pc_hid, npc_hid) async classmethod

Create a specific assignment for a player who selected one explicit candidate.

Source code in dcs_simulation_engine/core/engine_run_manager.py
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
@classmethod
async def create_player_choice_assignment_async(
    cls,
    *,
    provider: Any,
    player: PlayerRecord,
    game_name: str,
    pc_hid: str,
    npc_hid: str,
) -> AssignmentRecord:
    """Create a specific assignment for a player who selected one explicit candidate."""
    config = cls.get_run_config()
    if not config.assignment_strategy.allow_choice_if_multiple:
        raise ValueError("This run is not configured for participant assignment choice.")
    eligible = await cls.get_eligible_options_async(
        provider=provider,
        player=player,
    )
    eligible_set = {(opt["game_name"], opt["pc_hid"], opt["npc_hid"]) for opt in eligible}
    if (game_name, pc_hid, npc_hid) not in eligible_set:
        raise ValueError("The selected game, PC, and NPC are not available for assignment.")
    player_assignments = await maybe_await(provider.list_assignments(player_id=player.id))
    assignment = await maybe_await(
        provider.create_assignment(
            assignment_doc={
                MongoColumns.PLAYER_ID: player.id,
                MongoColumns.GAME_NAME: game_name,
                MongoColumns.PC_HID: pc_hid,
                MongoColumns.NPC_HID: npc_hid,
                MongoColumns.STATUS: "assigned",
                MongoColumns.FORM_RESPONSES: {},
            },
            allow_concurrent=cls._allow_concurrent_assignment(config=config, assignments=player_assignments),
        )
    )
    if assignment is None:
        raise ValueError("Failed to create the assignment.")
    return assignment
enrich_assignment_option_async(*, provider, option) async classmethod

Attach display metadata to an assignment option.

Source code in dcs_simulation_engine/core/engine_run_manager.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
@classmethod
async def enrich_assignment_option_async(
    cls,
    *,
    provider: Any,
    option: dict[str, str],
) -> dict[str, Any]:
    """Attach display metadata to an assignment option."""
    metadata = await cls.assignment_display_metadata_async(
        provider=provider,
        game_name=option["game_name"],
        pc_hid=option["pc_hid"],
        npc_hid=option["npc_hid"],
    )
    return {**option, **metadata}
ensure_run_async(*, provider) async classmethod

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

Source code in dcs_simulation_engine/core/engine_run_manager.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@classmethod
async def ensure_run_async(cls, *, provider: Any) -> RunRecord:
    """Persist the run config snapshot if it is not already stored."""
    config = cls.get_run_config()
    existing = await maybe_await(provider.get_run())
    progress = await cls.compute_progress_async(provider=provider)
    if existing is not None:
        updated = await maybe_await(provider.set_run_progress(progress=progress))
        return updated or existing
    return await maybe_await(
        provider.upsert_run(
            name=config.name,
            description=config.description,
            config_snapshot=config.model_dump(mode="json"),
            progress=progress,
        )
    )
get_assignment_for_player_async(*, provider, player_id, assignment_id) async classmethod

Return one assignment if it belongs to the requested player.

Source code in dcs_simulation_engine/core/engine_run_manager.py
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
@classmethod
async def get_assignment_for_player_async(
    cls,
    *,
    provider: Any,
    player_id: str,
    assignment_id: str,
) -> AssignmentRecord | None:
    """Return one assignment if it belongs to the requested player."""
    assignment = await maybe_await(provider.get_assignment(assignment_id=assignment_id))
    if assignment is None:
        return None
    if assignment.player_id != player_id:
        return None
    return assignment
get_eligible_options_async(*, provider, player) async classmethod

Return selectable {game_name, pc_hid, npc_hid} options for the player.

Source code in dcs_simulation_engine/core/engine_run_manager.py
735
736
737
738
739
740
741
742
743
744
@classmethod
async def get_eligible_options_async(
    cls,
    *,
    provider: Any,
    player: PlayerRecord,
) -> list[dict[str, str]]:
    """Return selectable {game_name, pc_hid, npc_hid} options for the player."""
    state = await cls.get_player_state_async(provider=provider, player_id=player.id)
    return list(state.get("eligible_assignment_options", []))
get_latest_assignment_for_player_async(*, provider, player_id) async classmethod

Return the latest assignment for a player.

Source code in dcs_simulation_engine/core/engine_run_manager.py
621
622
623
624
625
626
627
@classmethod
async def get_latest_assignment_for_player_async(cls, *, provider: Any, player_id: str) -> AssignmentRecord | None:
    """Return the latest assignment for a player."""
    getter = getattr(provider, "get_latest_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, player) async classmethod

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

Source code in dcs_simulation_engine/core/engine_run_manager.py
450
451
452
453
454
455
456
457
458
459
460
461
462
@classmethod
async def get_or_create_assignment_async(
    cls,
    *,
    provider: Any,
    player: PlayerRecord,
) -> AssignmentRecord | None:
    """Return the active assignment for a player or create one on demand."""
    assignment, _options = await cls.resolve_assignment_state_async(
        provider=provider,
        player=player,
    )
    return assignment
get_player_state_async(*, provider, player_id) async classmethod

Return the assignment state visible to one authenticated player.

Source code in dcs_simulation_engine/core/engine_run_manager.py
 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
@classmethod
async def get_player_state_async(
    cls,
    *,
    provider: Any,
    player_id: str,
) -> dict[str, Any]:
    """Return the assignment state visible to one authenticated player."""
    config = cls.get_run_config()
    player_assignments = await maybe_await(provider.list_assignments(player_id=player_id))
    active_assignment = await maybe_await(provider.get_active_assignment(player_id=player_id))
    completed_assignments = [item for item in player_assignments if item.status == "completed"]
    strategy = cls._strategy_for(config=config)
    has_finished_run = len(completed_assignments) >= strategy.max_assignments_per_player(config=config)
    eligible_assignment_options: list[dict[str, str]] = []
    pending_form_groups = await cls.pending_form_groups_async(
        provider=provider,
        config=config,
        player_id=player_id,
        assignments=player_assignments,
        active_assignment=active_assignment,
        has_finished_run=has_finished_run,
    )

    if not cls._blocks_assignment_resolution(pending_form_groups) and not has_finished_run:
        player_record = await maybe_await(provider.get_player(player_id=player_id))
        if player_record is not None:
            active_assignment, eligible_assignment_options = await cls.resolve_assignment_state_async(
                provider=provider,
                player=player_record,
                player_assignments=player_assignments,
                active_assignment=active_assignment,
            )
            player_assignments = await maybe_await(provider.list_assignments(player_id=player_id))
            completed_assignments = [item for item in player_assignments if item.status == "completed"]
            has_finished_run = len(completed_assignments) >= strategy.max_assignments_per_player(config=config)
            pending_form_groups = await cls.pending_form_groups_async(
                provider=provider,
                config=config,
                player_id=player_id,
                assignments=player_assignments,
                active_assignment=active_assignment,
                has_finished_run=has_finished_run,
            )

    pending_assignment_form_items = [
        assignment
        for assignment in player_assignments
        if any(
            group["trigger"]["event"] == "after_assignment" and group.get("assignment_id") == assignment.assignment_id
            for group in pending_form_groups
        )
    ]

    return {
        "active_assignment": active_assignment,
        "pending_assignment_form_ids": [item.assignment_id for item in pending_assignment_form_items],
        "has_finished_run": has_finished_run,
        "eligible_assignment_options": eligible_assignment_options,
        "pending_form_groups": pending_form_groups,
        "assignments": await maybe_await(provider.list_assignments(player_id=player_id)),
    }
get_run_config() classmethod

Return the active run config.

Source code in dcs_simulation_engine/core/engine_run_manager.py
38
39
40
41
42
43
@classmethod
def get_run_config(cls) -> RunConfig:
    """Return the active run config."""
    if cls._run_config is None:
        raise RuntimeError("EngineRunManager has not been configured with a RunConfig.")
    return cls._run_config
handle_session_terminal_state_async(*, provider, assignment_id, exit_reason) async classmethod

Map a gameplay terminal reason onto an assignment lifecycle status.

Source code in dcs_simulation_engine/core/engine_run_manager.py
568
569
570
571
572
573
574
575
576
577
578
579
580
581
@classmethod
async def handle_session_terminal_state_async(
    cls,
    *,
    provider: Any,
    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_run_async(provider=provider)
    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/engine_run_manager.py
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
@classmethod
def normalize_form_submissions(
    cls,
    *,
    forms: list[Form],
    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,
            "trigger": form.trigger.model_dump(mode="json"),
            "submitted_at": utc_now(),
            "answers": normalized_answers,
        }
    return normalized
pending_form_groups_async(*, provider, config, player_id, assignments=None, active_assignment=None, has_finished_run=None) async classmethod

Return actionable form groups still required for a player.

Source code in dcs_simulation_engine/core/engine_run_manager.py
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
@classmethod
async def pending_form_groups_async(
    cls,
    *,
    provider: Any,
    config: RunConfig,
    player_id: str,
    assignments: list[AssignmentRecord] | None = None,
    active_assignment: AssignmentRecord | None = None,
    has_finished_run: bool | None = None,
) -> list[dict[str, Any]]:
    """Return actionable form groups still required for a player."""
    if assignments is None:
        assignments = await maybe_await(provider.list_assignments(player_id=player_id))
    if active_assignment is None:
        active_assignment = await maybe_await(provider.get_active_assignment(player_id=player_id))
    if has_finished_run is None:
        strategy = cls._strategy_for(config=config)
        completed_count = sum(1 for item in assignments if item.status == "completed")
        has_finished_run = completed_count >= strategy.max_assignments_per_player(config=config)

    groups: list[dict[str, Any]] = []
    player_forms = await maybe_await(provider.get_player_forms(player_id=player_id))
    submitted_player_form_names = set((player_forms.data if player_forms else {}).keys())
    groups.extend(
        cls._pending_player_form_group(
            config=config,
            event="before_all_assignments",
            submitted_form_names=submitted_player_form_names,
        )
    )

    for assignment in assignments:
        if assignment.status in {"assigned", "interrupted"}:
            groups.extend(cls._pending_assignment_form_group(config=config, event="before_assignment", assignment=assignment))
        if assignment.status == "completed":
            groups.extend(cls._pending_assignment_form_group(config=config, event="after_assignment", assignment=assignment))

    if has_finished_run:
        groups.extend(
            cls._pending_player_form_group(
                config=config,
                event="after_all_assignments",
                submitted_form_names=submitted_player_form_names,
            )
        )

    return groups
resolve_assignment_state_async(*, provider, player, player_assignments=None, active_assignment=None) async classmethod

Return the current assignment or selectable next options for a player.

Source code in dcs_simulation_engine/core/engine_run_manager.py
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
@classmethod
async def resolve_assignment_state_async(
    cls,
    *,
    provider: Any,
    player: PlayerRecord,
    player_assignments: list[AssignmentRecord] | None = None,
    active_assignment: AssignmentRecord | None = None,
) -> tuple[AssignmentRecord | None, list[dict[str, str]]]:
    """Return the current assignment or selectable next options for a player."""
    config = cls.get_run_config()
    strategy = cls._strategy_for(config=config)
    assignments = player_assignments
    if assignments is None:
        assignments = await maybe_await(provider.list_assignments(player_id=player.id))
    pending_groups = await cls.pending_form_groups_async(
        provider=provider,
        config=config,
        player_id=player.id,
        assignments=assignments,
        active_assignment=active_assignment,
    )
    if cls._blocks_assignment_resolution(pending_groups):
        return None, []
    current = cls._current_assignment_from_policy(
        config=config,
        assignments=assignments,
        active_assignment=active_assignment,
    )
    if current is not None:
        return current, []

    completed_count = sum(1 for item in assignments if item.status == "completed")
    if completed_count >= strategy.max_assignments_per_player(config=config):
        return None, []

    candidates = await maybe_await(strategy.list_candidate_assignments_async(provider=provider, config=config, player=player))
    if not candidates:
        return None, []

    if config.assignment_strategy.allow_choice_if_multiple and len(candidates) > 1:
        return None, [cls._candidate_to_option(candidate) for candidate in candidates]

    assignment = await maybe_await(
        provider.create_assignment(
            assignment_doc=cls._assignment_doc_for_candidate(config=config, player=player, candidate=candidates[0]),
            allow_concurrent=cls._allow_concurrent_assignment(config=config, assignments=assignments),
        )
    )
    return assignment, []
start_assignment_session_async(*, provider, registry, player, source='run', assignment_id=None) async classmethod

Start or resume gameplay for the requested assignment.

Source code in dcs_simulation_engine/core/engine_run_manager.py
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
@classmethod
async def start_assignment_session_async(
    cls,
    *,
    provider: Any,
    registry: "SessionRegistry",
    player: PlayerRecord,
    source: str = "run",
    assignment_id: str | None = None,
) -> tuple["SessionEntry", AssignmentRecord]:
    """Start or resume gameplay for the requested assignment."""
    from dcs_simulation_engine.api.registry import hydrate_session_async

    assignment: AssignmentRecord | None
    if assignment_id is not None:
        assignment = await cls.get_assignment_for_player_async(
            provider=provider,
            player_id=player.id,
            assignment_id=assignment_id,
        )
    else:
        state = await cls.get_player_state_async(provider=provider, player_id=player.id)
        assignment = state["active_assignment"]
    if assignment is None:
        raise ValueError("No matching assignment is available for this player.")
    if assignment.status == "completed":
        raise ValueError("Completed assignments cannot be resumed.")
    if assignment.status == "interrupted":
        raise ValueError("Interrupted assignments cannot be resumed.")
    config = cls.get_run_config()
    assignments = await maybe_await(provider.list_assignments(player_id=player.id))
    pending_groups = await cls.pending_form_groups_async(
        provider=provider,
        config=config,
        player_id=player.id,
        assignments=assignments,
        active_assignment=assignment,
    )
    if cls._blocks_assignment_start(pending_form_groups=pending_groups, assignment_id=assignment.assignment_id):
        raise ValueError("Required form responses must be submitted before starting this assignment.")

    if assignment.status == "in_progress":
        # Check whether the existing session is still resumable before
        # refusing to start.  If it is paused (in registry or in DB),
        # return it instead of creating a duplicate session.
        existing_session_id = assignment.data.get(MongoColumns.ACTIVE_SESSION_ID)
        if existing_session_id:
            existing_entry = registry.get(existing_session_id)
            if existing_entry is None:
                existing_entry = await hydrate_session_async(
                    session_id=existing_session_id,
                    player_id=player.id,
                    provider=provider,
                    registry=registry,
                )
            if existing_entry is not None and not existing_entry.manager.exited:
                return existing_entry, assignment
        raise ValueError("This assignment is already in progress.")

    manager = await SessionManager.create_async(
        game=assignment.game_name,
        provider=provider,
        source=source,
        pc_choice=assignment.pc_hid,
        npc_choice=assignment.npc_hid,
        player_id=player.id,
    )
    entry = registry.add(
        player_id=player.id,
        game_name=assignment.game_name,
        manager=manager,
        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 run 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/engine_run_manager.py
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
@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, forms_payload) async classmethod

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

Source code in dcs_simulation_engine/core/engine_run_manager.py
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
@classmethod
async def store_player_form_payloads_async(
    cls,
    *,
    provider: Any,
    player_id: str,
    forms_payload: dict[str, dict[str, Any]],
) -> None:
    """Store one or more named player-scoped form payloads on the forms record."""
    for form_key, payload in forms_payload.items():
        await maybe_await(
            provider.set_player_form_response(
                player_id=player_id,
                form_key=form_key,
                response=payload,
            )
        )
submit_form_group_async(*, provider, player_id, group_id, responses) async classmethod

Store responses for one currently pending form group.

Source code in dcs_simulation_engine/core/engine_run_manager.py
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
@classmethod
async def submit_form_group_async(
    cls,
    *,
    provider: Any,
    player_id: str,
    group_id: str,
    responses: dict[str, Any],
) -> dict[str, Any]:
    """Store responses for one currently pending form group."""
    config = cls.get_run_config()
    pending_groups = await cls.pending_form_groups_async(
        provider=provider,
        config=config,
        player_id=player_id,
    )
    group = next((item for item in pending_groups if item["group_id"] == group_id), None)
    if group is None:
        raise ValueError("No pending form group matches the submitted group_id.")

    forms = list(group["forms"])
    normalized = cls.normalize_form_submissions(forms=forms, responses=responses)
    event = group["trigger"]["event"]
    if event in {"before_all_assignments", "after_all_assignments"}:
        await cls.store_player_form_payloads_async(
            provider=provider,
            player_id=player_id,
            forms_payload=normalized,
        )
        return group

    assignment_id = group.get("assignment_id")
    if not assignment_id:
        raise ValueError("Assignment-scoped form group is missing assignment_id.")
    updated = await cls.store_form_payloads_async(
        provider=provider,
        assignment_id=assignment_id,
        forms_payload=normalized,
    )
    if updated is None:
        raise ValueError("Failed to store the form response.")
    return group

forms

Shared form models for run workflows.

Form

Bases: BaseModel

Named run form shown at a registered run trigger.

Source code in dcs_simulation_engine/core/forms.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
117
118
119
120
class Form(BaseModel):
    """Named run form shown at a registered run trigger."""

    model_config = ConfigDict(extra="forbid")

    name: str
    trigger: FormTrigger
    questions: list[FormQuestion] = 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) -> "Form":
        """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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
@model_validator(mode="after")
def assign_question_keys(self) -> "Form":
    """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
 96
 97
 98
 99
100
101
102
103
@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
FormQuestion

Bases: BaseModel

Question model used by run forms.

Source code in dcs_simulation_engine/core/forms.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
class FormQuestion(BaseModel):
    """Question model used by run 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) -> "FormQuestion":
        """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
59
60
61
62
63
64
65
66
@model_validator(mode="after")
def validate_question(self) -> "FormQuestion":
    """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
FormTrigger

Bases: BaseModel

Canonical trigger for run forms.

Source code in dcs_simulation_engine/core/forms.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
class FormTrigger(BaseModel):
    """Canonical trigger for run forms."""

    model_config = ConfigDict(extra="forbid")

    event: FormTriggerEvent
    match: Any = None

    @model_validator(mode="after")
    def validate_registered_trigger(self) -> "FormTrigger":
        """Validate event registration and v1 match support."""
        if self.event not in REGISTERED_FORM_TRIGGER_EVENTS:
            raise ValueError(f"Unknown form trigger event: {self.event}")
        if self.match is not None:
            raise ValueError("Form trigger match must be null for the current built-in triggers.")
        return self
validate_registered_trigger()

Validate event registration and v1 match support.

Source code in dcs_simulation_engine/core/forms.py
77
78
79
80
81
82
83
84
@model_validator(mode="after")
def validate_registered_trigger(self) -> "FormTrigger":
    """Validate event registration and v1 match support."""
    if self.event not in REGISTERED_FORM_TRIGGER_EVENTS:
        raise ValueError(f"Unknown form trigger event: {self.event}")
    if self.match is not None:
        raise ValueError("Form trigger match must be null for the current built-in triggers.")
    return self

game

Base classes for new-style game implementations.

BaseGameOverrides

Bases: BaseModel

Common overridable parameters shared across all games.

Run configs may supply any of these fields in a game's overrides block. Subclass this in each game to add game-specific overridable fields.

Source code in dcs_simulation_engine/core/game.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
class BaseGameOverrides(BaseModel):
    """Common overridable parameters shared across all games.

    Run configs may supply any of these fields in a game's ``overrides`` block.
    Subclass this in each game to add game-specific overridable fields.
    """

    model_config = ConfigDict(extra="forbid")

    max_turns: int | None = None
    max_playtime: int | None = None

    player_retry_budget: int | None = None
    simulator_recovery_budget: int | None = None
    max_input_length: int | None = None
    pcs_allowed: str | None = None
    npcs_allowed: str | None = None
Game

Bases: ABC

Abstract base class.

Provides a concrete step() that handles the full turn lifecycle: setup on first call, finish-flow routing, command dispatch, input validation, and scene advancement. Subclasses only need to implement the game-specific pieces.

Each concrete subclass must also declare an inner Overrides model (subclass of BaseGameOverrides) listing every kwarg a run config may supply.

Each concrete subclass must also define GAME_NAME and GAME_DESCRIPTION class attributes with non-empty string values.

Source code in dcs_simulation_engine/core/game.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
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
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
852
853
class Game(ABC):
    """Abstract base class.

    Provides a concrete ``step()`` that handles the full turn lifecycle:
    setup on first call, finish-flow routing, command dispatch, input
    validation, and scene advancement.  Subclasses only need to implement
    the game-specific pieces.

    Each concrete subclass must also declare an inner ``Overrides`` model
    (subclass of ``BaseGameOverrides``) listing every kwarg a run config
    may supply.

    Each concrete subclass must also define GAME_NAME and GAME_DESCRIPTION
    class attributes with non-empty string values.
    """

    GAME_NAME: ClassVar[str]
    GAME_DESCRIPTION: ClassVar[str]

    DEFAULT_MAX_TURNS = 50
    ALLOWED_MAX_TURNS_RANGE: ClassVar[NumericRange] = (1, 500)
    DEFAULT_MAX_PLAYTIME = 1800  # 30 minutes
    ALLOWED_MAX_PLAYTIME_RANGE: ClassVar[NumericRange] = (1, 3600)
    DEFAULT_PLAYER_RETRY_BUDGET = 10
    ALLOWED_PLAYER_RETRY_BUDGET_RANGE: ClassVar[NumericRange] = (0, 10)
    DEFAULT_SIMULATOR_RECOVERY_BUDGET = 3
    ALLOWED_SIMULATOR_RECOVERY_BUDGET_RANGE: ClassVar[NumericRange] = (1, 10)
    DEFAULT_MAX_INPUT_LENGTH = 350
    ALLOWED_MAX_INPUT_LENGTH_RANGE: ClassVar[NumericRange] = (1, 350)
    DEFAULT_PCS_FILTER: CharacterFilter = get_character_filter("pc-eligible")
    DEFAULT_NPCS_FILTER: CharacterFilter = get_character_filter("all")
    ALLOWED_PCS: ClassVar[frozenset[str]] = frozenset(
        {
            "pc-eligible",
            "human-normative",
            "divergent",
            "hypersensitive",
            "hyposensitive",
            "neurotypical",
            "neurodivergent",
            "physical-divergence",
        }
    )
    ALLOWED_NPCS: ClassVar[frozenset[str]] = frozenset(list_character_filter_names())
    OPENING_PREFIX = "Opening scene: "
    SIMULATOR_PREFIX = "Simulator: "
    PLAYER_PREFIX = "Player"
    PLAYER_VALIDATION_EXHAUSTED_REASON = "player_validation_retry_exhausted"
    SIMULATOR_VALIDATION_EXHAUSTED_REASON = "simulator_validation_retry_exhausted"
    SIMULATOR_RECOVERY_BUDGET_EXHAUSTED_REASON = SIMULATOR_RECOVERY_BUDGET_EXHAUSTED
    INTERNAL_ERROR_REASON = "internal_error"
    MODEL_PROVIDER_ERROR_REASON = MODEL_PROVIDER_ERROR
    SIMULATOR_RETRY_MESSAGE = "The simulator could not resolve that action cleanly. Try a different action or rephrase."
    SIMULATOR_RECOVERY_BUDGET_EXHAUSTED_MESSAGE = (
        "The simulator could not produce a valid response after several attempts. "
        "This was not caused by your action, and the game is ending now."
    )
    INTERNAL_ERROR_MESSAGE = (
        "Oops, the simulation engine hit an internal problem. This was not caused by your action, "
        "and there is nothing you need to fix. The game is ending now. Sorry about that."
    )

    # ---- Overrides schema ------------------------------------------------

    class Overrides(BaseGameOverrides):
        """Base overrides — no additional fields.

        Concrete games replace this with their own typed model.
        """

    def __init_subclass__(cls, **kwargs: Any) -> None:
        """Enforce that concrete game subclasses define GAME_NAME and GAME_DESCRIPTION."""
        super().__init_subclass__(**kwargs)

        parent_game_cls = next((base for base in cls.__mro__[1:] if issubclass(base, Game)), None)
        if parent_game_cls is None:
            return

        cls._validate_class_bounds(parent_game_cls)

        # Skip abstract intermediates for concrete metadata checks.
        if getattr(cls, "__abstractmethods__", None):
            return

        for field in ("GAME_NAME", "GAME_DESCRIPTION"):
            value = getattr(cls, field, None)
            if not isinstance(value, str) or not value.strip():
                raise TypeError(f"{cls.__name__} must define non-empty {field} class attribute. Got: {field}={value!r}")

    @classmethod
    def parse_overrides(cls, raw: dict[str, Any]) -> "Game.Overrides":
        """Validate and coerce a raw overrides dict from the run config."""
        overrides = cls.Overrides.model_validate(raw)
        cls._validate_common_overrides(overrides)
        return overrides

    @classmethod
    def _coerce_numeric_range(cls, value: Any, *, field_name: str) -> NumericRange:
        """Validate and normalize a numeric bounds tuple."""
        if not isinstance(value, tuple):
            value = tuple(value)
        if len(value) != 2:
            raise TypeError(f"{cls.__name__}.{field_name} must contain exactly two integers.")
        lower, upper = value
        if isinstance(lower, bool) or not isinstance(lower, int) or isinstance(upper, bool) or not isinstance(upper, int):
            raise TypeError(f"{cls.__name__}.{field_name} must contain only integers.")
        if lower > upper:
            raise TypeError(f"{cls.__name__}.{field_name} must be an inclusive range with lower <= upper.")
        return (lower, upper)

    @classmethod
    def _coerce_allowed_filter_names(cls, value: Any, *, field_name: str) -> frozenset[str]:
        """Validate and normalize the allowed filter-name set."""
        try:
            names = frozenset(value)
        except TypeError as exc:
            raise TypeError(f"{cls.__name__}.{field_name} must be an iterable of filter names.") from exc
        if not names:
            raise TypeError(f"{cls.__name__}.{field_name} must not be empty.")
        invalid_names = sorted(name for name in names if not isinstance(name, str) or not name.strip())
        if invalid_names:
            raise TypeError(f"{cls.__name__}.{field_name} must contain non-empty strings. Got: {invalid_names!r}")
        unknown_names = sorted(name for name in names if name not in set(list_character_filter_names()))
        if unknown_names:
            raise TypeError(f"{cls.__name__}.{field_name} contains unknown filters: {unknown_names!r}")
        return names

    @classmethod
    def _get_filter_name(cls, value: Any, *, field_name: str) -> str:
        """Return a validated registry name from a default CharacterFilter."""
        name = getattr(value, "name", None)
        if not isinstance(name, str) or not name.strip():
            raise TypeError(f"{cls.__name__}.{field_name} must define a CharacterFilter with a non-empty name.")
        try:
            get_character_filter(name)
        except ValueError as exc:
            raise TypeError(f"{cls.__name__}.{field_name} uses unknown character filter {name!r}.") from exc
        return name

    @classmethod
    def _validate_default_in_range(cls, *, field_name: str, value: Any, allowed_range: NumericRange) -> None:
        """Validate that a numeric default falls within its inclusive range."""
        lower, upper = allowed_range
        if isinstance(value, bool) or not isinstance(value, int):
            raise TypeError(f"{cls.__name__}.{field_name} must be an integer.")
        if not lower <= value <= upper:
            raise TypeError(f"{cls.__name__}.{field_name}={value} is outside allowed range [{lower}, {upper}].")

    @classmethod
    def _validate_override_number(cls, *, field_name: str, value: int | None, allowed_range: NumericRange) -> None:
        """Reject numeric override values that fall outside the configured range."""
        if value is None:
            return
        lower, upper = allowed_range
        if not lower <= value <= upper:
            raise ValueError(f"{cls.__name__} override {field_name!r}={value} is outside allowed range [{lower}, {upper}].")

    @classmethod
    def _validate_override_filter_name(cls, *, field_name: str, value: str | None, allowed_names: frozenset[str]) -> None:
        """Reject unknown or disallowed filter override values."""
        if value is None:
            return
        get_character_filter(value)
        if value not in allowed_names:
            raise ValueError(f"{cls.__name__} override {field_name!r}={value!r} is not allowed. Allowed values: {sorted(allowed_names)!r}")

    @classmethod
    def _validate_common_overrides(cls, overrides: BaseGameOverrides) -> None:
        """Validate shared override values against the class bounds."""
        cls._validate_override_number(
            field_name="max_turns",
            value=overrides.max_turns,
            allowed_range=cls.ALLOWED_MAX_TURNS_RANGE,
        )
        cls._validate_override_number(
            field_name="max_playtime",
            value=overrides.max_playtime,
            allowed_range=cls.ALLOWED_MAX_PLAYTIME_RANGE,
        )
        cls._validate_override_number(
            field_name="player_retry_budget",
            value=overrides.player_retry_budget,
            allowed_range=cls.ALLOWED_PLAYER_RETRY_BUDGET_RANGE,
        )
        cls._validate_override_number(
            field_name="simulator_recovery_budget",
            value=overrides.simulator_recovery_budget,
            allowed_range=cls.ALLOWED_SIMULATOR_RECOVERY_BUDGET_RANGE,
        )
        cls._validate_override_number(
            field_name="max_input_length",
            value=overrides.max_input_length,
            allowed_range=cls.ALLOWED_MAX_INPUT_LENGTH_RANGE,
        )
        cls._validate_override_filter_name(
            field_name="pcs_allowed",
            value=overrides.pcs_allowed,
            allowed_names=cls.ALLOWED_PCS,
        )
        cls._validate_override_filter_name(
            field_name="npcs_allowed",
            value=overrides.npcs_allowed,
            allowed_names=cls.ALLOWED_NPCS,
        )

    @classmethod
    def _validate_narrowed_range(
        cls,
        *,
        field_name: str,
        child_range: NumericRange,
        parent_range: NumericRange,
    ) -> None:
        """Ensure a child inclusive range is not wider than its parent."""
        child_lower, child_upper = child_range
        parent_lower, parent_upper = parent_range
        if child_lower < parent_lower or child_upper > parent_upper:
            raise TypeError(
                f"{cls.__name__}.{field_name}={child_range} widens parent range {parent_range}. "
                "Child ranges must be equal to or narrower than the parent."
            )

    @classmethod
    def _validate_subset(
        cls,
        *,
        field_name: str,
        child_values: frozenset[str],
        parent_values: frozenset[str],
    ) -> None:
        """Ensure a child allowed-set is not wider than its parent."""
        if not child_values.issubset(parent_values):
            extras = sorted(child_values - parent_values)
            raise TypeError(
                f"{cls.__name__}.{field_name} contains values not allowed by the parent: {extras!r}. "
                "Child allowed sets must be subsets of the parent."
            )

    @classmethod
    def _validate_class_bounds(cls, parent_game_cls: type["Game"]) -> None:
        """Validate subclass defaults and allowed bounds against the parent Game class."""
        cls.ALLOWED_MAX_TURNS_RANGE = cls._coerce_numeric_range(cls.ALLOWED_MAX_TURNS_RANGE, field_name="ALLOWED_MAX_TURNS_RANGE")
        cls.ALLOWED_MAX_PLAYTIME_RANGE = cls._coerce_numeric_range(
            cls.ALLOWED_MAX_PLAYTIME_RANGE,
            field_name="ALLOWED_MAX_PLAYTIME_RANGE",
        )
        cls.ALLOWED_PLAYER_RETRY_BUDGET_RANGE = cls._coerce_numeric_range(
            cls.ALLOWED_PLAYER_RETRY_BUDGET_RANGE,
            field_name="ALLOWED_PLAYER_RETRY_BUDGET_RANGE",
        )
        cls.ALLOWED_MAX_INPUT_LENGTH_RANGE = cls._coerce_numeric_range(
            cls.ALLOWED_MAX_INPUT_LENGTH_RANGE,
            field_name="ALLOWED_MAX_INPUT_LENGTH_RANGE",
        )
        cls.ALLOWED_PCS = cls._coerce_allowed_filter_names(cls.ALLOWED_PCS, field_name="ALLOWED_PCS")
        cls.ALLOWED_NPCS = cls._coerce_allowed_filter_names(cls.ALLOWED_NPCS, field_name="ALLOWED_NPCS")

        cls._validate_narrowed_range(
            field_name="ALLOWED_MAX_TURNS_RANGE",
            child_range=cls.ALLOWED_MAX_TURNS_RANGE,
            parent_range=parent_game_cls.ALLOWED_MAX_TURNS_RANGE,
        )
        cls._validate_narrowed_range(
            field_name="ALLOWED_MAX_PLAYTIME_RANGE",
            child_range=cls.ALLOWED_MAX_PLAYTIME_RANGE,
            parent_range=parent_game_cls.ALLOWED_MAX_PLAYTIME_RANGE,
        )
        cls._validate_narrowed_range(
            field_name="ALLOWED_PLAYER_RETRY_BUDGET_RANGE",
            child_range=cls.ALLOWED_PLAYER_RETRY_BUDGET_RANGE,
            parent_range=parent_game_cls.ALLOWED_PLAYER_RETRY_BUDGET_RANGE,
        )
        cls._validate_narrowed_range(
            field_name="ALLOWED_MAX_INPUT_LENGTH_RANGE",
            child_range=cls.ALLOWED_MAX_INPUT_LENGTH_RANGE,
            parent_range=parent_game_cls.ALLOWED_MAX_INPUT_LENGTH_RANGE,
        )
        cls._validate_subset(
            field_name="ALLOWED_PCS",
            child_values=cls.ALLOWED_PCS,
            parent_values=parent_game_cls.ALLOWED_PCS,
        )
        cls._validate_subset(
            field_name="ALLOWED_NPCS",
            child_values=cls.ALLOWED_NPCS,
            parent_values=parent_game_cls.ALLOWED_NPCS,
        )

        cls._validate_default_in_range(
            field_name="DEFAULT_MAX_TURNS",
            value=cls.DEFAULT_MAX_TURNS,
            allowed_range=cls.ALLOWED_MAX_TURNS_RANGE,
        )
        cls._validate_default_in_range(
            field_name="DEFAULT_MAX_PLAYTIME",
            value=cls.DEFAULT_MAX_PLAYTIME,
            allowed_range=cls.ALLOWED_MAX_PLAYTIME_RANGE,
        )
        cls._validate_default_in_range(
            field_name="DEFAULT_PLAYER_RETRY_BUDGET",
            value=cls.DEFAULT_PLAYER_RETRY_BUDGET,
            allowed_range=cls.ALLOWED_PLAYER_RETRY_BUDGET_RANGE,
        )
        cls._validate_default_in_range(
            field_name="DEFAULT_SIMULATOR_RECOVERY_BUDGET",
            value=cls.DEFAULT_SIMULATOR_RECOVERY_BUDGET,
            allowed_range=cls.ALLOWED_SIMULATOR_RECOVERY_BUDGET_RANGE,
        )
        cls._validate_default_in_range(
            field_name="DEFAULT_MAX_INPUT_LENGTH",
            value=cls.DEFAULT_MAX_INPUT_LENGTH,
            allowed_range=cls.ALLOWED_MAX_INPUT_LENGTH_RANGE,
        )

        pcs_filter_name = cls._get_filter_name(cls.DEFAULT_PCS_FILTER, field_name="DEFAULT_PCS_FILTER")
        npcs_filter_name = cls._get_filter_name(cls.DEFAULT_NPCS_FILTER, field_name="DEFAULT_NPCS_FILTER")
        if pcs_filter_name not in cls.ALLOWED_PCS:
            raise TypeError(f"{cls.__name__}.DEFAULT_PCS_FILTER={pcs_filter_name!r} is not included in {cls.__name__}.ALLOWED_PCS.")
        if npcs_filter_name not in cls.ALLOWED_NPCS:
            raise TypeError(f"{cls.__name__}.DEFAULT_NPCS_FILTER={npcs_filter_name!r} is not included in {cls.__name__}.ALLOWED_NPCS.")

    @classmethod
    def _resolve_character_filter(
        cls,
        *,
        override_value: str | None,
        default_value: CharacterFilter,
        field_name: str,
        allowed_names: frozenset[str],
    ) -> CharacterFilter:
        """Resolve an optional filter override string into a CharacterFilter."""
        if override_value is None:
            return default_value
        cls._validate_override_filter_name(field_name=field_name, value=override_value, allowed_names=allowed_names)
        return get_character_filter(override_value)

    @classmethod
    def build_base_init_kwargs(cls, overrides: BaseGameOverrides) -> dict[str, Any]:
        """Build common constructor kwargs from validated overrides.

        Subclasses can use this in ``create_from_context`` and only add
        game-specific constructor arguments.
        """
        return {
            "player_retry_budget": (
                overrides.player_retry_budget if overrides.player_retry_budget is not None else cls.DEFAULT_PLAYER_RETRY_BUDGET
            ),
            "simulator_recovery_budget": (
                overrides.simulator_recovery_budget
                if overrides.simulator_recovery_budget is not None
                else cls.DEFAULT_SIMULATOR_RECOVERY_BUDGET
            ),
            "max_input_length": (overrides.max_input_length if overrides.max_input_length is not None else cls.DEFAULT_MAX_INPUT_LENGTH),
            "pcs_allowed": cls._resolve_character_filter(
                override_value=overrides.pcs_allowed,
                default_value=cls.DEFAULT_PCS_FILTER,
                field_name="pcs_allowed",
                allowed_names=cls.ALLOWED_PCS,
            ),
            "npcs_allowed": cls._resolve_character_filter(
                override_value=overrides.npcs_allowed,
                default_value=cls.DEFAULT_NPCS_FILTER,
                field_name="npcs_allowed",
                allowed_names=cls.ALLOWED_NPCS,
            ),
        }

    # ---- Constructor (common state) --------------------------------------

    def __init__(
        self,
        *,
        pc: CharacterRecord,
        npc: CharacterRecord,
        engine: Any,  # SimulatorClient from ai_client.py
        player_retry_budget: int | None = None,
        simulator_recovery_budget: int | None = None,
        max_input_length: int | None = None,
        pcs_allowed: CharacterFilter | None = None,
        npcs_allowed: CharacterFilter | None = None,
    ) -> None:
        """Initialise shared game state. Call via super().__init__() in subclasses."""
        self._pc = pc
        self._npc = npc
        self._engine = engine
        self._player_retry_budget = player_retry_budget if player_retry_budget is not None else type(self).DEFAULT_PLAYER_RETRY_BUDGET
        self._simulator_recovery_budget = (
            simulator_recovery_budget if simulator_recovery_budget is not None else type(self).DEFAULT_SIMULATOR_RECOVERY_BUDGET
        )
        self._simulator_recovery_failures = 0
        self._max_input_length = max_input_length if max_input_length is not None else type(self).DEFAULT_MAX_INPUT_LENGTH
        self._pcs_allowed = pcs_allowed if pcs_allowed is not None else type(self).DEFAULT_PCS_FILTER
        self._npcs_allowed = npcs_allowed if npcs_allowed is not None else type(self).DEFAULT_NPCS_FILTER
        self._entered = False
        self._exited = False
        self._exit_reason = ""
        self._in_finish_flow = False
        self._filtered_transcript_buffer: list[str] = []

    # ---- Concrete lifecycle (no more boilerplate in each game) -----------

    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"{type(self).__name__} 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 get_transcript(self) -> str:
        """Return the filtered game transcript (opening + successful turns only)."""
        return "\n".join(self._filtered_transcript_buffer)

    # ---- Default step() with routing -------------------------------------

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

        Routing order:
        1. Exited → no-op.
        2. First call → setup phase (welcome message + opening scene).
        3. In finish flow → delegate to on_finish_input().
        4. Command (starts with ``/``) → dispatch to command handler.
        5. Normal input → length check, then engine step (validate + update).
        """
        if self._exited:
            return

        if not self._entered:
            self._entered = True
            yield GameEvent.now(type="info", content=self.get_setup_content())
            try:
                opening = await self._engine.chat(None)
            except ModelProviderError as exc:
                logger.error("Opening scene failed due to model provider error: {}", exc.user_message)
                self.exit(self.MODEL_PROVIDER_ERROR_REASON)
                yield self._model_provider_error_event(exc)
                return
            self._consume_model_metadata(stage="opening", metadata=opening.metadata)
            self._filtered_transcript_buffer.append(f"{self.OPENING_PREFIX}{opening.content}")
            yield GameEvent.now(type=opening.type, content=opening.content)
            return

        if not user_input:
            return

        if self._in_finish_flow:
            async for event in self.on_finish_input(user_input):
                yield event
            return

        if user_input.strip().startswith("/"):
            async for event in self._dispatch_command(user_input):
                yield 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

        try:
            result = await self._engine.step(user_input)
        except ModelProviderError as exc:
            logger.error("Simulator turn failed due to model provider error: {}", exc.user_message)
            self.exit(self.MODEL_PROVIDER_ERROR_REASON)
            yield self._model_provider_error_event(exc)
            return
        self._consume_turn_metadata(result)
        if result.ok:
            self._simulator_recovery_failures = 0
            self._filtered_transcript_buffer.append(f"{self.PLAYER_PREFIX} ({self._pc.hid}): {user_input}")
            self._filtered_transcript_buffer.append(f"{self.SIMULATOR_PREFIX}{result.simulator_response}")
            yield GameEvent.now(type="ai", content=result.simulator_response)
            return

        failure_type = getattr(result, "failure_type", None) or PLAYER_TURN_VALIDATION_FAILED
        if failure_type == PLAYER_TURN_VALIDATION_FAILED:
            self._player_retry_budget -= 1
            logger.debug(f"Player validation failed. Retry budget remaining: {self._player_retry_budget}")
            if self._player_retry_budget <= 0:
                self.exit(self.PLAYER_VALIDATION_EXHAUSTED_REASON)
                yield GameEvent.now(
                    type="error",
                    content="Error: Too many failed attempts. Game over.",
                    failure_type=failure_type,
                    retries_remaining=0,
                    exit_reason=self.PLAYER_VALIDATION_EXHAUSTED_REASON,
                )
                return

            remaining_label = "attempt" if self._player_retry_budget == 1 else "attempts"
            yield GameEvent.now(
                type="error",
                content=(
                    f"That action was blocked: {result.error_message or 'Invalid action.'}\n\n"
                    f"Please try a different action. Failed attempts remaining: {self._player_retry_budget} {remaining_label}."
                ),
                failure_type=failure_type,
                retries_remaining=self._player_retry_budget,
            )
            return

        if failure_type == SIMULATOR_TURN_VALIDATION_RETRY_EXHAUSTED:
            self._simulator_recovery_failures += 1
            remaining = self._simulator_recovery_budget - self._simulator_recovery_failures
            logger.warning(
                "Simulator validation exhausted turn retries. Consecutive failures: {}/{}. Check validation_violation events for details.",
                self._simulator_recovery_failures,
                self._simulator_recovery_budget,
            )
            if remaining > 0:
                attempt_label = "attempt" if remaining == 1 else "attempts"
                yield GameEvent.now(
                    type="error",
                    content=f"{self.SIMULATOR_RETRY_MESSAGE} Simulator recovery attempts remaining: {remaining} {attempt_label}.",
                    failure_type=failure_type,
                    retries_remaining=remaining,
                )
                return

            logger.error("Simulator recovery budget exhausted; ending game without scoring.")
            self.exit(self.SIMULATOR_RECOVERY_BUDGET_EXHAUSTED_REASON)
            yield GameEvent.now(
                type="error",
                content=self.SIMULATOR_RECOVERY_BUDGET_EXHAUSTED_MESSAGE,
                failure_type=SIMULATOR_RECOVERY_BUDGET_EXHAUSTED,
                retries_remaining=0,
                exit_reason=self.SIMULATOR_RECOVERY_BUDGET_EXHAUSTED_REASON,
            )
            return
        else:
            logger.error("Internal simulator error; ending game without scoring.")
            self.exit(self.INTERNAL_ERROR_REASON)
            exit_reason = self.INTERNAL_ERROR_REASON

        yield GameEvent.now(
            type="error",
            content=self.INTERNAL_ERROR_MESSAGE,
            failure_type=failure_type,
            exit_reason=exit_reason,
        )

    def _model_provider_error_event(self, exc: ModelProviderError) -> GameEvent:
        """Build a visible terminal event for an upstream model provider failure."""
        return GameEvent.now(
            type="error",
            content=exc.user_message,
            failure_type=MODEL_PROVIDER_ERROR,
            exit_reason=self.MODEL_PROVIDER_ERROR_REASON,
            provider=exc.provider,
            provider_status_code=exc.status_code,
            provider_code=exc.provider_code,
        )

    def _internal_error_event(self) -> GameEvent:
        """Build a visible terminal event for an internal game failure."""
        return GameEvent.now(
            type="error",
            content=self.INTERNAL_ERROR_MESSAGE,
            failure_type=INTERNAL_ERROR,
            exit_reason=self.INTERNAL_ERROR_REASON,
        )

    async def _run_finish_scoring(self, scorer: Callable[[], Awaitable[None]]) -> GameEvent | None:
        """Run final scoring and return a terminal error event if scoring fails."""
        try:
            await scorer()
        except ModelProviderError as exc:
            logger.error("Final scoring failed due to model provider error: {}", exc.user_message)
            self.exit(self.MODEL_PROVIDER_ERROR_REASON)
            return self._model_provider_error_event(exc)
        except Exception:
            logger.exception("Final scoring failed due to an internal error.")
            self.exit(self.INTERNAL_ERROR_REASON)
            return self._internal_error_event()
        return None

    @staticmethod
    def _zero_score(reasoning: str) -> dict[str, Any]:
        """Build a valid score for a completed game with no supporting player evidence."""
        return {"tier": 0, "score": 0, "reasoning": reasoning}

    def _consume_model_metadata(self, *, stage: str, metadata: dict[str, Any]) -> None:
        """Consume optional structured metadata attached to a model response."""
        return

    def _consume_turn_metadata(self, result: Any) -> None:
        """Forward optional component metadata from a simulator turn to the game hook."""
        for stage, component in (("updater", getattr(result, "updater_result", None)),):
            if component is None:
                continue
            self._consume_model_metadata(stage=stage, metadata=getattr(component, "metadata", {}) or {})

    async def _dispatch_command(self, user_input: str) -> AsyncIterator[GameEvent]:
        """Route a slash command to the appropriate handler."""
        command_body = user_input.strip()[1:].strip()
        if not command_body:
            return
        cmd = command_body.split()[0].lower()

        if cmd == "help":
            yield GameEvent.now(type="info", content=self.get_help_content(), command_response=True)
            return

        if cmd == "abilities":
            yield GameEvent.now(type="info", content=self.get_abilities_content(), command_response=True)
            return

        if cmd == "finish":
            async for event in self.on_finish():
                yield event
            return

        handler = self.get_command_handler(cmd)
        if handler is not None:
            async for event in handler():
                yield event

    # ---- Abstract methods (must be implemented by each game) -------------

    def get_setup_content(self) -> str:
        """Content for the initial enter message. Defaults to ``get_help_content()``."""
        return self.get_help_content()

    @abstractmethod
    def get_help_content(self) -> str:
        """Content for the ``/help`` command response."""

    @abstractmethod
    def get_abilities_content(self) -> str:
        """Content for the ``/abilities`` command response."""

    @abstractmethod
    async def on_finish(self) -> AsyncIterator[GameEvent]:
        """Called when ``/finish`` is issued.

        Simple games call ``self.exit()`` and yield a closing message.
        Multi-step games set ``self._in_finish_flow = True`` and yield a
        question; subsequent inputs are routed to ``on_finish_input()``.
        """
        raise NotImplementedError
        yield  # pragma: no cover — keeps this typed as an async generator

    async def on_finish_input(self, user_input: str) -> AsyncIterator[GameEvent]:
        """Handle user input during a multi-step finish flow.

        Override when the finish phase requires one or more follow-up inputs
        (e.g. a confidence-rating question after a prediction).  The default
        implementation is a no-op.
        """
        return
        yield  # pragma: no cover — makes this an async generator

    def get_command_handler(self, cmd: str) -> Callable[[], AsyncIterator[GameEvent]] | None:
        """Return a zero-argument async-generator handler for extra commands.

        Override to support commands beyond ``/help``, ``/abilities``, and
        the ``/finish``.  Return ``None`` for unrecognised commands so
        ``SessionManager`` can handle them.
        """
        return None

    def _export_engine_state(self) -> dict[str, Any]:
        """Return a serialisable snapshot of the engine state when supported."""
        export_state = getattr(self._engine, "export_state", None)
        if callable(export_state):
            state = export_state()
            if isinstance(state, dict):
                return state

        export_history = getattr(self._engine, "export_history", None)
        if callable(export_history):
            return {"history": list(export_history())}

        return {}

    def _import_engine_state(self, state: dict[str, Any]) -> None:
        """Restore engine state, including legacy history-only snapshots."""
        import_state = getattr(self._engine, "import_state", None)
        engine_state = state.get("engine_state")
        legacy_history = state.get("updater_history", [])

        if callable(import_state):
            if isinstance(engine_state, dict):
                import_state(engine_state)
                return
            import_state({"history": legacy_history, "transcript_events": legacy_history})
            return

        import_history = getattr(self._engine, "import_history", None)
        if callable(import_history):
            import_history(legacy_history)

    def _export_additional_state(self) -> dict[str, Any]:
        """Return subclass-specific mutable state."""
        return {}

    def _import_additional_state(self, state: dict[str, Any]) -> None:
        """Restore subclass-specific mutable state."""
        return

    def export_state(self) -> dict[str, Any]:
        """Return a JSON-serialisable snapshot of this game's mutable state.

        Must include every field needed to restore behaviour exactly — lifecycle
        flags, retry budgets, state-machine booleans, collected player inputs,
        and evaluation payloads.  The returned dict is stored under the
        ``game_state`` key of the session ``runtime_state`` document.
        """
        state = {
            "entered": self._entered,
            "exited": self._exited,
            "exit_reason": self._exit_reason,
            "player_retry_budget": self._player_retry_budget,
            "simulator_recovery_failures": self._simulator_recovery_failures,
            "in_finish_flow": self._in_finish_flow,
            "filtered_transcript_buffer": list(self._filtered_transcript_buffer),
            "engine_state": self._export_engine_state(),
        }
        state.update(self._export_additional_state())
        return state

    def import_state(self, state: dict[str, Any]) -> None:
        """Restore mutable state from a snapshot produced by ``export_state``.

        Called by ``SessionManager.create_from_snapshot`` immediately after the
        game instance is constructed via ``create_from_context``.
        """
        legacy_retry_budget = state.get("retry_budget", type(self).DEFAULT_PLAYER_RETRY_BUDGET)
        self._entered = bool(state.get("entered", False))
        self._exited = bool(state.get("exited", False))
        self._exit_reason = str(state.get("exit_reason", ""))
        self._player_retry_budget = int(state.get("player_retry_budget", legacy_retry_budget))
        self._simulator_recovery_failures = int(state.get("simulator_recovery_failures", 0))
        self._in_finish_flow = bool(state.get("in_finish_flow", False))

        transcript_buffer = state.get("filtered_transcript_buffer")
        if isinstance(transcript_buffer, list):
            self._filtered_transcript_buffer = [str(entry) for entry in transcript_buffer]
        else:
            legacy_history = state.get("updater_history", [])
            self._filtered_transcript_buffer = [str(entry) for entry in legacy_history] if isinstance(legacy_history, list) else []

        self._import_engine_state(state)
        self._import_additional_state(state)

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

        Receives character records and any run-config kwargs (validated against
        ``cls.Overrides``).  Returns a fully initialised Game instance.
        """
exit_reason property

Reason the game ended, or empty string.

exited property

True if the game has ended.

Overrides

Bases: BaseGameOverrides

Base overrides — no additional fields.

Concrete games replace this with their own typed model.

Source code in dcs_simulation_engine/core/game.py
151
152
153
154
155
class Overrides(BaseGameOverrides):
    """Base overrides — no additional fields.

    Concrete games replace this with their own typed model.
    """
__init__(*, pc, npc, engine, player_retry_budget=None, simulator_recovery_budget=None, max_input_length=None, pcs_allowed=None, npcs_allowed=None)

Initialise shared game state. Call via super().init() in subclasses.

Source code in dcs_simulation_engine/core/game.py
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
def __init__(
    self,
    *,
    pc: CharacterRecord,
    npc: CharacterRecord,
    engine: Any,  # SimulatorClient from ai_client.py
    player_retry_budget: int | None = None,
    simulator_recovery_budget: int | None = None,
    max_input_length: int | None = None,
    pcs_allowed: CharacterFilter | None = None,
    npcs_allowed: CharacterFilter | None = None,
) -> None:
    """Initialise shared game state. Call via super().__init__() in subclasses."""
    self._pc = pc
    self._npc = npc
    self._engine = engine
    self._player_retry_budget = player_retry_budget if player_retry_budget is not None else type(self).DEFAULT_PLAYER_RETRY_BUDGET
    self._simulator_recovery_budget = (
        simulator_recovery_budget if simulator_recovery_budget is not None else type(self).DEFAULT_SIMULATOR_RECOVERY_BUDGET
    )
    self._simulator_recovery_failures = 0
    self._max_input_length = max_input_length if max_input_length is not None else type(self).DEFAULT_MAX_INPUT_LENGTH
    self._pcs_allowed = pcs_allowed if pcs_allowed is not None else type(self).DEFAULT_PCS_FILTER
    self._npcs_allowed = npcs_allowed if npcs_allowed is not None else type(self).DEFAULT_NPCS_FILTER
    self._entered = False
    self._exited = False
    self._exit_reason = ""
    self._in_finish_flow = False
    self._filtered_transcript_buffer: list[str] = []
__init_subclass__(**kwargs)

Enforce that concrete game subclasses define GAME_NAME and GAME_DESCRIPTION.

Source code in dcs_simulation_engine/core/game.py
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
def __init_subclass__(cls, **kwargs: Any) -> None:
    """Enforce that concrete game subclasses define GAME_NAME and GAME_DESCRIPTION."""
    super().__init_subclass__(**kwargs)

    parent_game_cls = next((base for base in cls.__mro__[1:] if issubclass(base, Game)), None)
    if parent_game_cls is None:
        return

    cls._validate_class_bounds(parent_game_cls)

    # Skip abstract intermediates for concrete metadata checks.
    if getattr(cls, "__abstractmethods__", None):
        return

    for field in ("GAME_NAME", "GAME_DESCRIPTION"):
        value = getattr(cls, field, None)
        if not isinstance(value, str) or not value.strip():
            raise TypeError(f"{cls.__name__} must define non-empty {field} class attribute. Got: {field}={value!r}")
build_base_init_kwargs(overrides) classmethod

Build common constructor kwargs from validated overrides.

Subclasses can use this in create_from_context and only add game-specific constructor arguments.

Source code in dcs_simulation_engine/core/game.py
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
@classmethod
def build_base_init_kwargs(cls, overrides: BaseGameOverrides) -> dict[str, Any]:
    """Build common constructor kwargs from validated overrides.

    Subclasses can use this in ``create_from_context`` and only add
    game-specific constructor arguments.
    """
    return {
        "player_retry_budget": (
            overrides.player_retry_budget if overrides.player_retry_budget is not None else cls.DEFAULT_PLAYER_RETRY_BUDGET
        ),
        "simulator_recovery_budget": (
            overrides.simulator_recovery_budget
            if overrides.simulator_recovery_budget is not None
            else cls.DEFAULT_SIMULATOR_RECOVERY_BUDGET
        ),
        "max_input_length": (overrides.max_input_length if overrides.max_input_length is not None else cls.DEFAULT_MAX_INPUT_LENGTH),
        "pcs_allowed": cls._resolve_character_filter(
            override_value=overrides.pcs_allowed,
            default_value=cls.DEFAULT_PCS_FILTER,
            field_name="pcs_allowed",
            allowed_names=cls.ALLOWED_PCS,
        ),
        "npcs_allowed": cls._resolve_character_filter(
            override_value=overrides.npcs_allowed,
            default_value=cls.DEFAULT_NPCS_FILTER,
            field_name="npcs_allowed",
            allowed_names=cls.ALLOWED_NPCS,
        ),
    }
create_from_context(pc, npc, **kwargs) abstractmethod classmethod

Factory method called by SessionManager.

Receives character records and any run-config kwargs (validated against cls.Overrides). Returns a fully initialised Game instance.

Source code in dcs_simulation_engine/core/game.py
846
847
848
849
850
851
852
853
@classmethod
@abstractmethod
def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "Game":
    """Factory method called by SessionManager.

    Receives character records and any run-config kwargs (validated against
    ``cls.Overrides``).  Returns a fully initialised Game instance.
    """
exit(reason)

Mark the game as ended.

Source code in dcs_simulation_engine/core/game.py
488
489
490
491
492
493
494
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"{type(self).__name__} exited: {reason}")
export_state()

Return a JSON-serialisable snapshot of this game's mutable state.

Must include every field needed to restore behaviour exactly — lifecycle flags, retry budgets, state-machine booleans, collected player inputs, and evaluation payloads. The returned dict is stored under the game_state key of the session runtime_state document.

Source code in dcs_simulation_engine/core/game.py
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
def export_state(self) -> dict[str, Any]:
    """Return a JSON-serialisable snapshot of this game's mutable state.

    Must include every field needed to restore behaviour exactly — lifecycle
    flags, retry budgets, state-machine booleans, collected player inputs,
    and evaluation payloads.  The returned dict is stored under the
    ``game_state`` key of the session ``runtime_state`` document.
    """
    state = {
        "entered": self._entered,
        "exited": self._exited,
        "exit_reason": self._exit_reason,
        "player_retry_budget": self._player_retry_budget,
        "simulator_recovery_failures": self._simulator_recovery_failures,
        "in_finish_flow": self._in_finish_flow,
        "filtered_transcript_buffer": list(self._filtered_transcript_buffer),
        "engine_state": self._export_engine_state(),
    }
    state.update(self._export_additional_state())
    return state
get_abilities_content() abstractmethod

Content for the /abilities command response.

Source code in dcs_simulation_engine/core/game.py
728
729
730
@abstractmethod
def get_abilities_content(self) -> str:
    """Content for the ``/abilities`` command response."""
get_command_handler(cmd)

Return a zero-argument async-generator handler for extra commands.

Override to support commands beyond /help, /abilities, and the /finish. Return None for unrecognised commands so SessionManager can handle them.

Source code in dcs_simulation_engine/core/game.py
753
754
755
756
757
758
759
760
def get_command_handler(self, cmd: str) -> Callable[[], AsyncIterator[GameEvent]] | None:
    """Return a zero-argument async-generator handler for extra commands.

    Override to support commands beyond ``/help``, ``/abilities``, and
    the ``/finish``.  Return ``None`` for unrecognised commands so
    ``SessionManager`` can handle them.
    """
    return None
get_help_content() abstractmethod

Content for the /help command response.

Source code in dcs_simulation_engine/core/game.py
724
725
726
@abstractmethod
def get_help_content(self) -> str:
    """Content for the ``/help`` command response."""
get_setup_content()

Content for the initial enter message. Defaults to get_help_content().

Source code in dcs_simulation_engine/core/game.py
720
721
722
def get_setup_content(self) -> str:
    """Content for the initial enter message. Defaults to ``get_help_content()``."""
    return self.get_help_content()
get_transcript()

Return the filtered game transcript (opening + successful turns only).

Source code in dcs_simulation_engine/core/game.py
506
507
508
def get_transcript(self) -> str:
    """Return the filtered game transcript (opening + successful turns only)."""
    return "\n".join(self._filtered_transcript_buffer)
import_state(state)

Restore mutable state from a snapshot produced by export_state.

Called by SessionManager.create_from_snapshot immediately after the game instance is constructed via create_from_context.

Source code in dcs_simulation_engine/core/game.py
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
def import_state(self, state: dict[str, Any]) -> None:
    """Restore mutable state from a snapshot produced by ``export_state``.

    Called by ``SessionManager.create_from_snapshot`` immediately after the
    game instance is constructed via ``create_from_context``.
    """
    legacy_retry_budget = state.get("retry_budget", type(self).DEFAULT_PLAYER_RETRY_BUDGET)
    self._entered = bool(state.get("entered", False))
    self._exited = bool(state.get("exited", False))
    self._exit_reason = str(state.get("exit_reason", ""))
    self._player_retry_budget = int(state.get("player_retry_budget", legacy_retry_budget))
    self._simulator_recovery_failures = int(state.get("simulator_recovery_failures", 0))
    self._in_finish_flow = bool(state.get("in_finish_flow", False))

    transcript_buffer = state.get("filtered_transcript_buffer")
    if isinstance(transcript_buffer, list):
        self._filtered_transcript_buffer = [str(entry) for entry in transcript_buffer]
    else:
        legacy_history = state.get("updater_history", [])
        self._filtered_transcript_buffer = [str(entry) for entry in legacy_history] if isinstance(legacy_history, list) else []

    self._import_engine_state(state)
    self._import_additional_state(state)
on_finish() abstractmethod async

Called when /finish is issued.

Simple games call self.exit() and yield a closing message. Multi-step games set self._in_finish_flow = True and yield a question; subsequent inputs are routed to on_finish_input().

Source code in dcs_simulation_engine/core/game.py
732
733
734
735
736
737
738
739
740
741
@abstractmethod
async def on_finish(self) -> AsyncIterator[GameEvent]:
    """Called when ``/finish`` is issued.

    Simple games call ``self.exit()`` and yield a closing message.
    Multi-step games set ``self._in_finish_flow = True`` and yield a
    question; subsequent inputs are routed to ``on_finish_input()``.
    """
    raise NotImplementedError
    yield  # pragma: no cover — keeps this typed as an async generator
on_finish_input(user_input) async

Handle user input during a multi-step finish flow.

Override when the finish phase requires one or more follow-up inputs (e.g. a confidence-rating question after a prediction). The default implementation is a no-op.

Source code in dcs_simulation_engine/core/game.py
743
744
745
746
747
748
749
750
751
async def on_finish_input(self, user_input: str) -> AsyncIterator[GameEvent]:
    """Handle user input during a multi-step finish flow.

    Override when the finish phase requires one or more follow-up inputs
    (e.g. a confidence-rating question after a prediction).  The default
    implementation is a no-op.
    """
    return
    yield  # pragma: no cover — makes this an async generator
parse_overrides(raw) classmethod

Validate and coerce a raw overrides dict from the run config.

Source code in dcs_simulation_engine/core/game.py
176
177
178
179
180
181
@classmethod
def parse_overrides(cls, raw: dict[str, Any]) -> "Game.Overrides":
    """Validate and coerce a raw overrides dict from the run config."""
    overrides = cls.Overrides.model_validate(raw)
    cls._validate_common_overrides(overrides)
    return overrides
step(user_input=None) async

Advance the game one turn, yielding one or more GameEvents.

Routing order: 1. Exited → no-op. 2. First call → setup phase (welcome message + opening scene). 3. In finish flow → delegate to on_finish_input(). 4. Command (starts with /) → dispatch to command handler. 5. Normal input → length check, then engine step (validate + update).

Source code in dcs_simulation_engine/core/game.py
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
async def step(self, user_input: str | None = None) -> AsyncIterator[GameEvent]:
    """Advance the game one turn, yielding one or more GameEvents.

    Routing order:
    1. Exited → no-op.
    2. First call → setup phase (welcome message + opening scene).
    3. In finish flow → delegate to on_finish_input().
    4. Command (starts with ``/``) → dispatch to command handler.
    5. Normal input → length check, then engine step (validate + update).
    """
    if self._exited:
        return

    if not self._entered:
        self._entered = True
        yield GameEvent.now(type="info", content=self.get_setup_content())
        try:
            opening = await self._engine.chat(None)
        except ModelProviderError as exc:
            logger.error("Opening scene failed due to model provider error: {}", exc.user_message)
            self.exit(self.MODEL_PROVIDER_ERROR_REASON)
            yield self._model_provider_error_event(exc)
            return
        self._consume_model_metadata(stage="opening", metadata=opening.metadata)
        self._filtered_transcript_buffer.append(f"{self.OPENING_PREFIX}{opening.content}")
        yield GameEvent.now(type=opening.type, content=opening.content)
        return

    if not user_input:
        return

    if self._in_finish_flow:
        async for event in self.on_finish_input(user_input):
            yield event
        return

    if user_input.strip().startswith("/"):
        async for event in self._dispatch_command(user_input):
            yield 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

    try:
        result = await self._engine.step(user_input)
    except ModelProviderError as exc:
        logger.error("Simulator turn failed due to model provider error: {}", exc.user_message)
        self.exit(self.MODEL_PROVIDER_ERROR_REASON)
        yield self._model_provider_error_event(exc)
        return
    self._consume_turn_metadata(result)
    if result.ok:
        self._simulator_recovery_failures = 0
        self._filtered_transcript_buffer.append(f"{self.PLAYER_PREFIX} ({self._pc.hid}): {user_input}")
        self._filtered_transcript_buffer.append(f"{self.SIMULATOR_PREFIX}{result.simulator_response}")
        yield GameEvent.now(type="ai", content=result.simulator_response)
        return

    failure_type = getattr(result, "failure_type", None) or PLAYER_TURN_VALIDATION_FAILED
    if failure_type == PLAYER_TURN_VALIDATION_FAILED:
        self._player_retry_budget -= 1
        logger.debug(f"Player validation failed. Retry budget remaining: {self._player_retry_budget}")
        if self._player_retry_budget <= 0:
            self.exit(self.PLAYER_VALIDATION_EXHAUSTED_REASON)
            yield GameEvent.now(
                type="error",
                content="Error: Too many failed attempts. Game over.",
                failure_type=failure_type,
                retries_remaining=0,
                exit_reason=self.PLAYER_VALIDATION_EXHAUSTED_REASON,
            )
            return

        remaining_label = "attempt" if self._player_retry_budget == 1 else "attempts"
        yield GameEvent.now(
            type="error",
            content=(
                f"That action was blocked: {result.error_message or 'Invalid action.'}\n\n"
                f"Please try a different action. Failed attempts remaining: {self._player_retry_budget} {remaining_label}."
            ),
            failure_type=failure_type,
            retries_remaining=self._player_retry_budget,
        )
        return

    if failure_type == SIMULATOR_TURN_VALIDATION_RETRY_EXHAUSTED:
        self._simulator_recovery_failures += 1
        remaining = self._simulator_recovery_budget - self._simulator_recovery_failures
        logger.warning(
            "Simulator validation exhausted turn retries. Consecutive failures: {}/{}. Check validation_violation events for details.",
            self._simulator_recovery_failures,
            self._simulator_recovery_budget,
        )
        if remaining > 0:
            attempt_label = "attempt" if remaining == 1 else "attempts"
            yield GameEvent.now(
                type="error",
                content=f"{self.SIMULATOR_RETRY_MESSAGE} Simulator recovery attempts remaining: {remaining} {attempt_label}.",
                failure_type=failure_type,
                retries_remaining=remaining,
            )
            return

        logger.error("Simulator recovery budget exhausted; ending game without scoring.")
        self.exit(self.SIMULATOR_RECOVERY_BUDGET_EXHAUSTED_REASON)
        yield GameEvent.now(
            type="error",
            content=self.SIMULATOR_RECOVERY_BUDGET_EXHAUSTED_MESSAGE,
            failure_type=SIMULATOR_RECOVERY_BUDGET_EXHAUSTED,
            retries_remaining=0,
            exit_reason=self.SIMULATOR_RECOVERY_BUDGET_EXHAUSTED_REASON,
        )
        return
    else:
        logger.error("Internal simulator error; ending game without scoring.")
        self.exit(self.INTERNAL_ERROR_REASON)
        exit_reason = self.INTERNAL_ERROR_REASON

    yield GameEvent.now(
        type="error",
        content=self.INTERNAL_ERROR_MESSAGE,
        failure_type=failure_type,
        exit_reason=exit_reason,
    )
GameEvent

Bases: NamedTuple

A single event yielded by a game step.

Source code in dcs_simulation_engine/core/game.py
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
class GameEvent(NamedTuple):
    """A single event yielded by a game step."""

    type: str
    content: str
    event_ts: datetime
    command_response: bool = False
    failure_type: str | None = None
    retries_remaining: int | None = None
    exit_reason: str | None = None
    provider: str | None = None
    provider_status_code: int | None = None
    provider_code: str | None = None

    @classmethod
    def now(
        cls,
        *,
        type: str,
        content: str,
        command_response: bool = False,
        failure_type: str | None = None,
        retries_remaining: int | None = None,
        exit_reason: str | None = None,
        provider: str | None = None,
        provider_status_code: int | None = None,
        provider_code: str | None = None,
    ) -> "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,
            failure_type=failure_type,
            retries_remaining=retries_remaining,
            exit_reason=exit_reason,
            provider=provider,
            provider_status_code=provider_status_code,
            provider_code=provider_code,
        )
now(*, type, content, command_response=False, failure_type=None, retries_remaining=None, exit_reason=None, provider=None, provider_status_code=None, provider_code=None) classmethod

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

Source code in dcs_simulation_engine/core/game.py
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
@classmethod
def now(
    cls,
    *,
    type: str,
    content: str,
    command_response: bool = False,
    failure_type: str | None = None,
    retries_remaining: int | None = None,
    exit_reason: str | None = None,
    provider: str | None = None,
    provider_status_code: int | None = None,
    provider_code: str | None = None,
) -> "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,
        failure_type=failure_type,
        retries_remaining=retries_remaining,
        exit_reason=exit_reason,
        provider=provider,
        provider_status_code=provider_status_code,
        provider_code=provider_code,
    )

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
 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
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[Form] = Field(default_factory=list)
    overrides: Dict[str, Any] = Field(default_factory=dict)

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

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

    def get_game_class_instance(self) -> Any:
        """Dynamically import and instantiate the game engine class."""
        return self.get_game_class()()

    @classmethod
    def from_game_class(cls, game_cls: Any, *, overrides: dict[str, Any] | None = None) -> "GameConfig":
        """Build a GameConfig from a concrete Game class and optional run overrides."""
        raw_overrides = dict(overrides or {})
        parsed_overrides = game_cls.parse_overrides(raw_overrides)
        max_turns = parsed_overrides.max_turns if parsed_overrides.max_turns is not None else game_cls.DEFAULT_MAX_TURNS
        max_playtime = (
            parsed_overrides.max_playtime if parsed_overrides.max_playtime is not None else game_cls.DEFAULT_MAX_PLAYTIME
        )
        return cls(
            name=game_cls.GAME_NAME,
            description=game_cls.GAME_DESCRIPTION,
            version="1.0.0",
            authors=["DCS"],
            stopping_conditions={
                "runtime_seconds": [f">={max_playtime}"],
                "turns": [f">={max_turns}"],
            },
            game_class=f"{game_cls.__module__}.{game_cls.__name__}",
            overrides=raw_overrides,
        )

    @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, pc_eligible_only
        game_cls = self.get_game_class()
        overrides = game_cls.parse_overrides(self.overrides)
        filters = game_cls.build_base_init_kwargs(overrides)
        pc_filter = filters["pcs_allowed"]
        npc_filter = filters["npcs_allowed"]
        pc_chars = pc_filter.get_characters(provider=provider)
        npc_chars = npc_filter.get_characters(provider=provider)
        pc_choices = [(record.hid, record.hid) for record in pc_chars]
        npc_choices = [(record.hid, record.hid) for record in npc_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, pc_eligible_only
        chars = await maybe_await(provider.get_characters())
        character_provider = _StaticCharacterProvider(chars)
        game_cls = self.get_game_class()
        overrides = game_cls.parse_overrides(self.overrides)
        filters = game_cls.build_base_init_kwargs(overrides)
        pc_filter = filters["pcs_allowed"]
        npc_filter = filters["npcs_allowed"]
        pc_chars = pc_filter.get_characters(provider=character_provider)
        npc_chars = npc_filter.get_characters(provider=character_provider)
        pc_choices = [(record.hid, record.hid) for record in pc_chars]
        npc_choices = [(record.hid, record.hid) for record in npc_chars]
        return pc_choices, npc_choices
from_game_class(game_cls, *, overrides=None) classmethod

Build a GameConfig from a concrete Game class and optional run overrides.

Source code in dcs_simulation_engine/core/game_config.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
@classmethod
def from_game_class(cls, game_cls: Any, *, overrides: dict[str, Any] | None = None) -> "GameConfig":
    """Build a GameConfig from a concrete Game class and optional run overrides."""
    raw_overrides = dict(overrides or {})
    parsed_overrides = game_cls.parse_overrides(raw_overrides)
    max_turns = parsed_overrides.max_turns if parsed_overrides.max_turns is not None else game_cls.DEFAULT_MAX_TURNS
    max_playtime = (
        parsed_overrides.max_playtime if parsed_overrides.max_playtime is not None else game_cls.DEFAULT_MAX_PLAYTIME
    )
    return cls(
        name=game_cls.GAME_NAME,
        description=game_cls.GAME_DESCRIPTION,
        version="1.0.0",
        authors=["DCS"],
        stopping_conditions={
            "runtime_seconds": [f">={max_playtime}"],
            "turns": [f">={max_turns}"],
        },
        game_class=f"{game_cls.__module__}.{game_cls.__name__}",
        overrides=raw_overrides,
    )
get_game_class()

Dynamically import and return the configured game class.

Source code in dcs_simulation_engine/core/game_config.py
43
44
45
46
47
def get_game_class(self) -> Any:
    """Dynamically import and return the configured game class."""
    module_path, class_name = self.game_class.rsplit(".", 1)
    module = importlib.import_module(module_path)
    return getattr(module, class_name)
get_game_class_instance()

Dynamically import and instantiate the game engine class.

Source code in dcs_simulation_engine/core/game_config.py
49
50
51
def get_game_class_instance(self) -> Any:
    """Dynamically import and instantiate the game engine class."""
    return self.get_game_class()()
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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
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, pc_eligible_only
    game_cls = self.get_game_class()
    overrides = game_cls.parse_overrides(self.overrides)
    filters = game_cls.build_base_init_kwargs(overrides)
    pc_filter = filters["pcs_allowed"]
    npc_filter = filters["npcs_allowed"]
    pc_chars = pc_filter.get_characters(provider=provider)
    npc_chars = npc_filter.get_characters(provider=provider)
    pc_choices = [(record.hid, record.hid) for record in pc_chars]
    npc_choices = [(record.hid, record.hid) for record in npc_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
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
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, pc_eligible_only
    chars = await maybe_await(provider.get_characters())
    character_provider = _StaticCharacterProvider(chars)
    game_cls = self.get_game_class()
    overrides = game_cls.parse_overrides(self.overrides)
    filters = game_cls.build_base_init_kwargs(overrides)
    pc_filter = filters["pcs_allowed"]
    npc_filter = filters["npcs_allowed"]
    pc_chars = pc_filter.get_characters(provider=character_provider)
    npc_chars = npc_filter.get_characters(provider=character_provider)
    pc_choices = [(record.hid, record.hid) for record in pc_chars]
    npc_choices = [(record.hid, record.hid) for record in npc_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
75
76
77
78
@classmethod
def load(cls, path: Any) -> "GameConfig":
    """Load a GameConfig from a YAML file."""
    return cls.from_yaml(path)

run_config

Run configuration models and static validation helpers.

RunConfig

Bases: SerdeMixin, BaseModel

Top-level run configuration.

Source code in dcs_simulation_engine/core/run_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
 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
class RunConfig(SerdeMixin, BaseModel):
    """Top-level run configuration."""

    model_config = ConfigDict(extra="forbid")

    name: str
    description: str = ""
    seed: int | None = None
    ui: RunConfigUI = Field(default_factory=RunConfigUI)
    games: list[RunConfigGame] = Field(default_factory=list)
    next_game_strategy: RunConfigNextGameStrategy
    forms: list[Form] = Field(default_factory=list)

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

    @model_validator(mode="after")
    def validate_forms(self) -> "RunConfig":
        """Fail loudly when form names collide after normalization."""
        form_names = [form.name for form in self.forms]
        if len(form_names) != len(set(form_names)):
            raise ValueError("Run config form names must be unique.")
        return self

    @classmethod
    def load(cls, path: str | Path) -> "RunConfig":
        """Load a run config from YAML."""
        return cls.from_yaml(path)

    def forms_for_trigger(self, trigger: str | None = None, *, event: str | None = None) -> list[Form]:
        """Return forms matching one trigger event string."""
        event_name = event if event is not None else trigger
        return [form for form in self.forms if form.trigger.event == event_name]

    def form_groups_for_trigger(self, *, event: str) -> list[dict[str, Any]]:
        """Return configured form groups for one trigger event."""
        forms = self.forms_for_trigger(event)
        if not forms:
            return []
        return [
            {
                "trigger": {"event": event, "match": None},
                "forms": forms,
            }
        ]

    @property
    def assignment_strategy(self) -> SimpleNamespace:
        """Expose the existing assignment-strategy view for run configs."""
        strategy = self.next_game_strategy.strategy
        values = strategy.model_dump()
        values["strategy"] = values.pop("id")
        values["games"] = self.game_names
        values.setdefault("player_characters", None)
        values.setdefault("non_player_characters", None)
        values.setdefault("quota_per_game", None)
        values.setdefault("max_assignments_per_player", None)
        values.setdefault("seed", self.seed)
        values.setdefault("pc_eligible_only", False)
        values.setdefault("allow_choice_if_multiple", False)
        values.setdefault("require_completion", True)
        return SimpleNamespace(**values)

    @property
    def registration_required(self) -> bool:
        """Return whether human players must register before playing."""
        return self.ui.registration_required

    @property
    def game_names(self) -> list[str]:
        """Return configured game names in declaration order."""
        return [game.name for game in self.games]
assignment_strategy property

Expose the existing assignment-strategy view for run configs.

game_names property

Return configured game names in declaration order.

registration_required property

Return whether human players must register before playing.

form_groups_for_trigger(*, event)

Return configured form groups for one trigger event.

Source code in dcs_simulation_engine/core/run_config.py
88
89
90
91
92
93
94
95
96
97
98
def form_groups_for_trigger(self, *, event: str) -> list[dict[str, Any]]:
    """Return configured form groups for one trigger event."""
    forms = self.forms_for_trigger(event)
    if not forms:
        return []
    return [
        {
            "trigger": {"event": event, "match": None},
            "forms": forms,
        }
    ]
forms_for_trigger(trigger=None, *, event=None)

Return forms matching one trigger event string.

Source code in dcs_simulation_engine/core/run_config.py
83
84
85
86
def forms_for_trigger(self, trigger: str | None = None, *, event: str | None = None) -> list[Form]:
    """Return forms matching one trigger event string."""
    event_name = event if event is not None else trigger
    return [form for form in self.forms if form.trigger.event == event_name]
load(path) classmethod

Load a run config from YAML.

Source code in dcs_simulation_engine/core/run_config.py
78
79
80
81
@classmethod
def load(cls, path: str | Path) -> "RunConfig":
    """Load a run config from YAML."""
    return cls.from_yaml(path)
normalize_forms(value) classmethod

Accept null forms and mapping-style form declarations.

Source code in dcs_simulation_engine/core/run_config.py
58
59
60
61
62
63
64
65
66
67
68
@field_validator("forms", mode="before")
@classmethod
def normalize_forms(cls, value: Any) -> Any:
    """Accept null forms and mapping-style form declarations."""
    if value is None:
        return []
    if isinstance(value, dict):
        value = [{"name": name, **payload} for name, payload in value.items()]
    if not isinstance(value, list):
        raise ValueError("forms must be null, a list, or a mapping of form names to definitions.")
    return value
validate_forms()

Fail loudly when form names collide after normalization.

Source code in dcs_simulation_engine/core/run_config.py
70
71
72
73
74
75
76
@model_validator(mode="after")
def validate_forms(self) -> "RunConfig":
    """Fail loudly when form names collide after normalization."""
    form_names = [form.name for form in self.forms]
    if len(form_names) != len(set(form_names)):
        raise ValueError("Run config form names must be unique.")
    return self
RunConfigGame

Bases: BaseModel

One game included in a run, plus game-owned overrides.

Source code in dcs_simulation_engine/core/run_config.py
20
21
22
23
24
25
26
class RunConfigGame(BaseModel):
    """One game included in a run, plus game-owned overrides."""

    model_config = ConfigDict(extra="forbid")

    name: str
    overrides: dict[str, Any] = Field(default_factory=dict)
RunConfigNextGameStrategy

Bases: BaseModel

Next-game assignment strategy configuration.

Source code in dcs_simulation_engine/core/run_config.py
37
38
39
40
41
42
class RunConfigNextGameStrategy(BaseModel):
    """Next-game assignment strategy configuration."""

    model_config = ConfigDict(extra="forbid")

    strategy: RunConfigStrategy
RunConfigStrategy

Bases: BaseModel

Assignment strategy selector plus strategy-specific parameters.

Source code in dcs_simulation_engine/core/run_config.py
29
30
31
32
33
34
class RunConfigStrategy(BaseModel):
    """Assignment strategy selector plus strategy-specific parameters."""

    model_config = ConfigDict(extra="allow")

    id: str
RunConfigUI

Bases: BaseModel

User-interface options for a run.

Source code in dcs_simulation_engine/core/run_config.py
12
13
14
15
16
17
class RunConfigUI(BaseModel):
    """User-interface options for a run."""

    model_config = ConfigDict(extra="forbid")

    registration_required: bool = True
validate_run_config_references(config)

Validate static references in a run config without runtime side effects.

Source code in dcs_simulation_engine/core/run_config.py
128
129
130
131
132
133
134
135
136
137
138
def validate_run_config_references(config: RunConfig) -> None:
    """Validate static references in a run config without runtime side effects."""
    from dcs_simulation_engine.core.assignment_strategies import get_assignment_strategy
    from dcs_simulation_engine.core.session_manager import SessionManager

    get_assignment_strategy(config.next_game_strategy.strategy.id).validate_config(config=config)

    for game in config.games:
        game_config = SessionManager.get_game_config_cached(game.name)
        game_cls = game_config.get_game_class()
        game_cls.parse_overrides(game.overrides)

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
256
257
258
259
260
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
 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
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,
        resume: bool = False,
    ) -> 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,
        )
        # On resume the document already exists; seed _seq from last_seq so new
        # events get monotonically increasing sequence numbers.
        self._seq = int(session_doc.get(MongoColumns.LAST_SEQ, 0))
        self._resume = resume
        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__()
        if not self._resume:
            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,
        failure_type: str | None = None,
        retries_remaining: int | None = None,
        exit_reason: str | None = None,
        provider: str | None = None,
        provider_status_code: int | None = None,
        provider_code: str | 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,
            failure_type=failure_type,
            retries_remaining=retries_remaining,
            exit_reason=exit_reason,
            provider=provider,
            provider_status_code=provider_status_code,
            provider_code=provider_code,
        )

    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,
        failure_type: str | None = None,
        retries_remaining: int | None = None,
        exit_reason: str | None = None,
        provider: str | None = None,
        provider_status_code: int | None = None,
        provider_code: str | None = None,
    ) -> "RecordedSessionEvent":
        _validate_event_classification(
            direction=direction,
            event_source=event_source,
            event_type=event_type,
        )
        ts = event_ts or utc_now()
        seq = self._next_seq()
        event_id = str(uuid4())
        doc = {
            MongoColumns.SESSION_ID: self._session_id,
            MongoColumns.SEQ: 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,
        }
        if failure_type is not None:
            doc[MongoColumns.FAILURE_TYPE] = failure_type
        if retries_remaining is not None:
            doc[MongoColumns.RETRIES_REMAINING] = retries_remaining
        if exit_reason is not None:
            doc[MongoColumns.EXIT_REASON] = exit_reason
        if provider is not None:
            doc[MongoColumns.PROVIDER] = provider
        if provider_status_code is not None:
            doc[MongoColumns.PROVIDER_STATUS_CODE] = provider_status_code
        if provider_code is not None:
            doc[MongoColumns.PROVIDER_CODE] = provider_code
        await self._writer.enqueue(doc)
        return RecordedSessionEvent(event_id=event_id, seq=seq)

    def _next_seq(self) -> int:
        self._seq += 1
        return self._seq
flush_pending() async

Force currently buffered event docs to Mongo.

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

Bases: SessionEventRecorder

Sidecar recorder for validation violations that shares event sequencing.

Source code in dcs_simulation_engine/core/session_event_recorder.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
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
class ValidationEventRecorder(SessionEventRecorder):
    """Sidecar recorder for validation violations that shares event sequencing."""

    def __init__(
        self,
        *,
        db: AsyncDatabase[Any],
        session_doc: dict[str, Any],
        primary: SessionEventRecorder,
        batch_size: int = 20,
        flush_interval_ms: int = 200,
        max_queue_size: int = 1000,
    ) -> None:
        super().__init__(
            db=db,
            session_doc=session_doc,
            batch_size=batch_size,
            flush_interval_ms=flush_interval_ms,
            max_queue_size=max_queue_size,
        )
        self._primary = primary

    async def __aenter__(self) -> "ValidationEventRecorder":
        if self._entered:
            return self
        self._entered = True
        await self._writer.__aenter__()
        return self

    async def finalize(
        self,
        *,
        termination_reason: str,
        status: str,
        turns_completed: int,
    ) -> None:
        _ = (termination_reason, status, turns_completed)
        return

    def _next_seq(self) -> int:
        self._primary._seq += 1
        self._seq = self._primary._seq
        return self._seq

    async def record_violation(
        self,
        *,
        event_source: str,
        validator_name: str,
        stage: str,
        message: str,
        response: str,
        raw_result: dict[str, Any],
        turn_index: int,
    ) -> RecordedSessionEvent:
        payload = json.dumps(
            {
                "validator_name": validator_name,
                "stage": stage,
                "message": message,
                "response": response,
                "raw_result": raw_result,
            }
        )
        return await self._enqueue_event(
            direction="internal",
            event_source=event_source,
            event_type="validation_violation",
            content=payload,
            content_format="json",
            turn_index=turn_index,
            command_name=None,
            command_args=None,
            event_ts=None,
        )

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
 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
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
class SessionManager:
    """Manages a single session of a Game."""

    _game_config_cache: dict[str, GameConfig] = {}
    _run_config: "RunConfig | None" = None

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

    @classmethod
    def configure_run_config(cls, run_config: "RunConfig") -> None:
        """Install the active run config used by this single engine run."""
        cls._run_config = run_config
        cls._game_config_cache.clear()

    @classmethod
    def _builtin_game_classes(cls) -> dict[str, type[Game]]:
        """Return built-in game classes keyed by normalized game name."""
        from dcs_simulation_engine.games.explore import ExploreGame
        from dcs_simulation_engine.games.foresight import ForesightGame
        from dcs_simulation_engine.games.goal_horizon import GoalHorizonGame
        from dcs_simulation_engine.games.infer_intent import InferIntentGame
        from dcs_simulation_engine.games.teamwork import TeamworkGame

        classes: list[type[Game]] = [
            ExploreGame,
            InferIntentGame,
            ForesightGame,
            GoalHorizonGame,
            TeamworkGame,
        ]
        return {_normalize_game_ref(game_cls.GAME_NAME): game_cls for game_cls in classes}

    @classmethod
    def _run_overrides_for_game(cls, game: str) -> dict[str, Any]:
        if cls._run_config is None:
            return {}
        normalized = _normalize_game_ref(game)
        for item in cls._run_config.games:
            if _normalize_game_ref(item.name) == normalized:
                return dict(item.overrides)
        return {}

    @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_classes = cls._builtin_game_classes()
        try:
            game_cls = game_classes[cache_key]
        except KeyError as exc:
            found = sorted(game_cls.GAME_NAME for game_cls in game_classes.values())
            raise FileNotFoundError(f"No game config matching {game!r} found. Found built-ins: {found}") from exc
        cls._game_config_cache[cache_key] = GameConfig.from_game_class(
            game_cls,
            overrides=cls._run_overrides_for_game(game),
        )
        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._validation_recorder: ValidationEventRecorder | 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 built-in game configs into the in-memory cache."""
        loaded = 0
        for game_cls in cls._builtin_game_classes().values():
            try:
                if cls._load_game_config_into_cache(game_cls.GAME_NAME):
                    loaded += 1
            except Exception:
                logger.debug("Skipping invalid game config '{}'", game_cls.GAME_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, **game_config.overrides)

    @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, "_engine", None), "updater_model", None),
                "validator_model": getattr(getattr(self.game, "_engine", None), "validator_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._validation_recorder = ValidationEventRecorder(
            db=db,
            session_doc=session_doc,
            primary=self._recorder,
        )
        await self._validation_recorder.__aenter__()
        self._recorder_open = True
        engine = getattr(self.game, "_engine", None)
        if engine is not None and hasattr(engine, "attach_recorder"):
            engine.attach_recorder(
                self._validation_recorder,
                turn_index_provider=lambda: self._turn_count + 1,
            )
        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 self._is_error_termination(normalized) else "closed"
            if self._validation_recorder is not None:
                await self._validation_recorder.flush_pending()
            await self._recorder.finalize(
                termination_reason=normalized,
                status=status,
                turns_completed=self.turns,
            )
            if self._validation_recorder is not None:
                await self._validation_recorder.__aexit__(None, None, None)
                self._validation_recorder = None
            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()
        if self._validation_recorder is not None:
            await self._validation_recorder.flush_pending()

    async def persist_runtime_snapshot_async(self) -> None:
        """Persist the current runtime snapshot for resume/branch consumers."""
        await self._persist_runtime_snapshot()

    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.")

    def export_snapshot(self) -> dict[str, Any]:
        """Return a JSON-serialisable runtime snapshot for durable resume.

        The snapshot captures everything needed to reconstruct this session
        after a process restart: turn count, session lifecycle flags, and the
        full game state including UpdaterClient conversation history.
        """
        return {
            "schema_version": 1,
            "turn_count": self._turn_count,
            "exited": self._exited,
            "exit_reason": self._exit_reason,
            "game_state": self.game.export_state(),
        }

    @classmethod
    async def create_from_snapshot(
        cls,
        *,
        snapshot: dict[str, Any],
        session_record: Any,
        provider: Any,
    ) -> "SessionManager":
        """Reconstruct a SessionManager from a persisted runtime snapshot.

        ``session_record`` is a ``SessionRecord`` from the DB.  The game
        instance is built fresh via ``create_from_context`` and then hydrated
        from the snapshot so its state machine, retry budget, and LLM history
        are fully restored.

        Raises ``ValueError`` if the snapshot ``schema_version`` is unknown;
        the caller should catch this and fall back to starting a new session.
        """
        schema_version = snapshot.get("schema_version", 0)
        if schema_version != 1:
            raise ValueError(f"Unsupported snapshot schema_version={schema_version!r}; cannot restore session — start a new one instead.")

        game_name = session_record.game_name
        game_config = cls.get_game_config_cached(game_name)

        pc_hid = session_record.data.get(MongoColumns.PC_HID)
        npc_hid = session_record.data.get(MongoColumns.NPC_HID)
        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,
        )
        game_state = snapshot.get("game_state", {})
        game_instance.import_state(game_state)

        source = session_record.data.get(MongoColumns.SOURCE, "unknown")
        session = cls._build_session(
            game_config=game_config,
            game_instance=game_instance,
            provider=provider,
            source=source,
            player_id=session_record.player_id,
        )

        # Restore manager-level counters and flags.
        session._turn_count = int(snapshot.get("turn_count", 0))
        session._exited = bool(snapshot.get("exited", False))
        session._exit_reason = str(snapshot.get("exit_reason", ""))
        # Re-attach the known session_id so persistence writes target the right doc.
        session._session_id = session_record.session_id

        # Re-open a lightweight recorder tied to the existing session doc so
        # subsequent turns append events correctly.  We skip the insert_one
        # guard here because the document was already created in start_persistence.
        get_db = getattr(provider, "get_db", None)
        if get_db is not None:
            db = get_db()
            insert_one = getattr(db[MongoColumns.SESSIONS], "insert_one", None)
            if insert_one is not None and inspect.iscoroutinefunction(insert_one):
                last_seq = int(session_record.data.get(MongoColumns.LAST_SEQ, 0))
                session._recorder = SessionEventRecorder(
                    db=db,
                    session_doc={
                        MongoColumns.SESSION_ID: session_record.session_id,
                        MongoColumns.PLAYER_ID: session_record.player_id,
                        MongoColumns.LAST_SEQ: last_seq,
                    },
                    resume=True,
                )
                await session._recorder.__aenter__()
                session._recorder_open = True

        logger.info(
            "SessionManager restored from snapshot: session_id={}, turns={}, game={}",
            session_record.session_id,
            session._turn_count,
            game_name,
        )
        return session

    async def _persist_runtime_snapshot(self) -> None:
        """Write the current runtime snapshot to the session document."""
        if self._session_id is None:
            return
        save_fn = getattr(self._provider, "save_runtime_state", None)
        if save_fn is None:
            return
        try:
            await maybe_await(save_fn(session_id=self._session_id, runtime_state=self.export_snapshot()))
        except Exception:
            logger.exception("Failed to persist runtime snapshot for session {}", self._session_id)

    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}
            if event.failure_type is not None:
                payload["failure_type"] = event.failure_type
            if event.retries_remaining is not None:
                payload["retries_remaining"] = event.retries_remaining
            if event.exit_reason is not None:
                payload["exit_reason"] = event.exit_reason
            if event.provider is not None:
                payload["provider"] = event.provider
            if event.provider_status_code is not None:
                payload["provider_status_code"] = event.provider_status_code
            if event.provider_code is not None:
                payload["provider_code"] = event.provider_code
            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,
                    failure_type=event.failure_type,
                    retries_remaining=event.retries_remaining,
                    exit_reason=event.exit_reason,
                    provider=event.provider,
                    provider_status_code=event.provider_status_code,
                    provider_code=event.provider_code,
                )
                payload["event_id"] = recorded.event_id
            emitted.append(payload)

        if yielded_ai:
            self._turn_count += 1
            await self._persist_runtime_snapshot()

        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", "player_finished"}:
            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 {"player_validation_retry_exhausted"}:
            return "player_validation_retry_exhausted"
        if reason_l in {"simulator_validation_retry_exhausted"}:
            return "simulator_validation_retry_exhausted"
        if reason_l in {"simulator_recovery_budget_exhausted"}:
            return "simulator_recovery_budget_exhausted"
        if reason_l in {"internal_error"}:
            return "internal_error"
        if reason_l in {"model_provider_error"}:
            return "model_provider_error"
        if reason_l in {"server_error", "internal_server_error"}:
            return "server_error"
        return reason_l

    def _is_error_termination(self, reason: str) -> bool:
        return reason in {
            "server_error",
            "internal_error",
            "simulator_validation_retry_exhausted",
            "simulator_recovery_budget_exhausted",
            "model_provider_error",
        }
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
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
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._validation_recorder: ValidationEventRecorder | None = None
    self._recorder_open = False
    self._finalized = False
    self.stopping_conditions: Dict[str, List[str]] = stopping_conditions or {
        "turns": [">500"],
        "runtime_seconds": [">3600"],
    }
configure_run_config(run_config) classmethod

Install the active run config used by this single engine run.

Source code in dcs_simulation_engine/core/session_manager.py
57
58
59
60
61
@classmethod
def configure_run_config(cls, run_config: "RunConfig") -> None:
    """Install the active run config used by this single engine run."""
    cls._run_config = run_config
    cls._game_config_cache.clear()
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
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
@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
create_from_snapshot(*, snapshot, session_record, provider) async classmethod

Reconstruct a SessionManager from a persisted runtime snapshot.

session_record is a SessionRecord from the DB. The game instance is built fresh via create_from_context and then hydrated from the snapshot so its state machine, retry budget, and LLM history are fully restored.

Raises ValueError if the snapshot schema_version is unknown; the caller should catch this and fall back to starting a new session.

Source code in dcs_simulation_engine/core/session_manager.py
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
@classmethod
async def create_from_snapshot(
    cls,
    *,
    snapshot: dict[str, Any],
    session_record: Any,
    provider: Any,
) -> "SessionManager":
    """Reconstruct a SessionManager from a persisted runtime snapshot.

    ``session_record`` is a ``SessionRecord`` from the DB.  The game
    instance is built fresh via ``create_from_context`` and then hydrated
    from the snapshot so its state machine, retry budget, and LLM history
    are fully restored.

    Raises ``ValueError`` if the snapshot ``schema_version`` is unknown;
    the caller should catch this and fall back to starting a new session.
    """
    schema_version = snapshot.get("schema_version", 0)
    if schema_version != 1:
        raise ValueError(f"Unsupported snapshot schema_version={schema_version!r}; cannot restore session — start a new one instead.")

    game_name = session_record.game_name
    game_config = cls.get_game_config_cached(game_name)

    pc_hid = session_record.data.get(MongoColumns.PC_HID)
    npc_hid = session_record.data.get(MongoColumns.NPC_HID)
    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,
    )
    game_state = snapshot.get("game_state", {})
    game_instance.import_state(game_state)

    source = session_record.data.get(MongoColumns.SOURCE, "unknown")
    session = cls._build_session(
        game_config=game_config,
        game_instance=game_instance,
        provider=provider,
        source=source,
        player_id=session_record.player_id,
    )

    # Restore manager-level counters and flags.
    session._turn_count = int(snapshot.get("turn_count", 0))
    session._exited = bool(snapshot.get("exited", False))
    session._exit_reason = str(snapshot.get("exit_reason", ""))
    # Re-attach the known session_id so persistence writes target the right doc.
    session._session_id = session_record.session_id

    # Re-open a lightweight recorder tied to the existing session doc so
    # subsequent turns append events correctly.  We skip the insert_one
    # guard here because the document was already created in start_persistence.
    get_db = getattr(provider, "get_db", None)
    if get_db is not None:
        db = get_db()
        insert_one = getattr(db[MongoColumns.SESSIONS], "insert_one", None)
        if insert_one is not None and inspect.iscoroutinefunction(insert_one):
            last_seq = int(session_record.data.get(MongoColumns.LAST_SEQ, 0))
            session._recorder = SessionEventRecorder(
                db=db,
                session_doc={
                    MongoColumns.SESSION_ID: session_record.session_id,
                    MongoColumns.PLAYER_ID: session_record.player_id,
                    MongoColumns.LAST_SEQ: last_seq,
                },
                resume=True,
            )
            await session._recorder.__aenter__()
            session._recorder_open = True

    logger.info(
        "SessionManager restored from snapshot: session_id={}, turns={}, game={}",
        session_record.session_id,
        session._turn_count,
        game_name,
    )
    return session
exit_async(reason) async

Mark session ended, finalize persistence, and close recorder.

Source code in dcs_simulation_engine/core/session_manager.py
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
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 self._is_error_termination(normalized) else "closed"
        if self._validation_recorder is not None:
            await self._validation_recorder.flush_pending()
        await self._recorder.finalize(
            termination_reason=normalized,
            status=status,
            turns_completed=self.turns,
        )
        if self._validation_recorder is not None:
            await self._validation_recorder.__aexit__(None, None, None)
            self._validation_recorder = None
        await self._recorder.__aexit__(None, None, None)
        self._recorder_open = False
    self._finalized = True
    logger.info("Session exited. Reason: {}", reason)
export_snapshot()

Return a JSON-serialisable runtime snapshot for durable resume.

The snapshot captures everything needed to reconstruct this session after a process restart: turn count, session lifecycle flags, and the full game state including UpdaterClient conversation history.

Source code in dcs_simulation_engine/core/session_manager.py
399
400
401
402
403
404
405
406
407
408
409
410
411
412
def export_snapshot(self) -> dict[str, Any]:
    """Return a JSON-serialisable runtime snapshot for durable resume.

    The snapshot captures everything needed to reconstruct this session
    after a process restart: turn count, session lifecycle flags, and the
    full game state including UpdaterClient conversation history.
    """
    return {
        "schema_version": 1,
        "turn_count": self._turn_count,
        "exited": self._exited,
        "exit_reason": self._exit_reason,
        "game_state": self.game.export_state(),
    }
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
383
384
385
386
387
388
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()
    if self._validation_recorder is not None:
        await self._validation_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
109
110
111
112
113
@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)
persist_runtime_snapshot_async() async

Persist the current runtime snapshot for resume/branch consumers.

Source code in dcs_simulation_engine/core/session_manager.py
390
391
392
async def persist_runtime_snapshot_async(self) -> None:
    """Persist the current runtime snapshot for resume/branch consumers."""
    await self._persist_runtime_snapshot()
preload_game_configs() classmethod

Load all built-in game configs into the in-memory cache.

Source code in dcs_simulation_engine/core/session_manager.py
198
199
200
201
202
203
204
205
206
207
208
209
210
211
@classmethod
def preload_game_configs(cls) -> int:
    """Load all built-in game configs into the in-memory cache."""
    loaded = 0
    for game_cls in cls._builtin_game_classes().values():
        try:
            if cls._load_game_config_into_cache(game_cls.GAME_NAME):
                loaded += 1
        except Exception:
            logger.debug("Skipping invalid game config '{}'", game_cls.GAME_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
394
395
396
397
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
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
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, "_engine", None), "updater_model", None),
            "validator_model": getattr(getattr(self.game, "_engine", None), "validator_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._validation_recorder = ValidationEventRecorder(
        db=db,
        session_doc=session_doc,
        primary=self._recorder,
    )
    await self._validation_recorder.__aenter__()
    self._recorder_open = True
    engine = getattr(self.game, "_engine", None)
    if engine is not None and hasattr(engine, "attach_recorder"):
        engine.attach_recorder(
            self._validation_recorder,
            turn_index_provider=lambda: self._turn_count + 1,
        )
    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
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
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}
        if event.failure_type is not None:
            payload["failure_type"] = event.failure_type
        if event.retries_remaining is not None:
            payload["retries_remaining"] = event.retries_remaining
        if event.exit_reason is not None:
            payload["exit_reason"] = event.exit_reason
        if event.provider is not None:
            payload["provider"] = event.provider
        if event.provider_status_code is not None:
            payload["provider_status_code"] = event.provider_status_code
        if event.provider_code is not None:
            payload["provider_code"] = event.provider_code
        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,
                failure_type=event.failure_type,
                retries_remaining=event.retries_remaining,
                exit_reason=event.exit_reason,
                provider=event.provider,
                provider_status_code=event.provider_status_code,
                provider_code=event.provider_code,
            )
            payload["event_id"] = recorded.event_id
        emitted.append(payload)

    if yielded_ai:
        self._turn_count += 1
        await self._persist_runtime_snapshot()

    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 assignment row.

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

    assignment_id: str
    player_id: str
    game_name: str
    pc_hid: str
    npc_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
 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
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 save_runtime_state(self, *, session_id: str, runtime_state: dict[str, Any]) -> None:
        """Upsert the resumable runtime snapshot on a session document."""
        raise NotImplementedError

    def branch_session(
        self,
        *,
        session_id: str,
        player_id: str | None,
        branched_at: Any,
    ) -> SessionRecord:
        """Clone a persisted session into a new paused child session."""
        raise NotImplementedError

    def get_resumable_session(
        self,
        *,
        player_id: str,
        game_name: str,
        pc_hid: str,
        npc_hid: str,
    ) -> SessionRecord | None:
        """Return the most recent paused session for this player/game/character combo, or None."""
        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_run(self) -> RunRecord | None:
        """Return the singleton persisted run record."""
        raise NotImplementedError

    def upsert_run(
        self,
        *,
        name: str,
        description: str,
        config_snapshot: dict[str, Any],
        progress: dict[str, Any],
    ) -> RunRecord:
        """Create or update a persisted run metadata record."""
        raise NotImplementedError

    def set_run_progress(
        self,
        *,
        progress: dict[str, Any],
    ) -> RunRecord | None:
        """Persist the latest run progress snapshot."""
        raise NotImplementedError

    def create_assignment(self, *, assignment_doc: dict[str, Any], allow_concurrent: bool = False) -> AssignmentRecord:
        """Persist a new run 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, *, player_id: str) -> AssignmentRecord | None:
        """Return the current active assignment for one player."""
        raise NotImplementedError

    def get_latest_assignment_for_player(self, *, player_id: str) -> AssignmentRecord | None:
        """Return the newest assignment for one player."""
        raise NotImplementedError

    def list_assignments(
        self,
        *,
        player_id: str | None = None,
        statuses: list[str] | None = None,
        game_name: str | None = None,
    ) -> list[AssignmentRecord]:
        """List run 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 run form response payload on an assignment row."""
        raise NotImplementedError

    def set_player_form_response(
        self,
        *,
        player_id: str,
        form_key: str,
        response: dict[str, Any],
    ) -> PlayerFormsRecord | None:
        """Store one player-scoped form response in the forms collection."""
        raise NotImplementedError

    def get_player_forms(
        self,
        *,
        player_id: str,
    ) -> PlayerFormsRecord | None:
        """Return player-scoped form responses for a player."""
        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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
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
branch_session(*, session_id, player_id, branched_at)

Clone a persisted session into a new paused child session.

Source code in dcs_simulation_engine/dal/base.py
146
147
148
149
150
151
152
153
154
def branch_session(
    self,
    *,
    session_id: str,
    player_id: str | None,
    branched_at: Any,
) -> SessionRecord:
    """Clone a persisted session into a new paused child 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
198
199
200
201
202
203
204
205
206
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 run assignment row.

Source code in dcs_simulation_engine/dal/base.py
231
232
233
def create_assignment(self, *, assignment_doc: dict[str, Any], allow_concurrent: bool = False) -> AssignmentRecord:
    """Persist a new run 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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
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
130
131
132
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
134
135
136
def delete_player(self, player_id: str) -> None:
    """Delete a player by id."""
    raise NotImplementedError
get_active_assignment(*, player_id)

Return the current active assignment for one player.

Source code in dcs_simulation_engine/dal/base.py
239
240
241
def get_active_assignment(self, *, player_id: str) -> AssignmentRecord | None:
    """Return the current active assignment for one player."""
    raise NotImplementedError
get_assignment(*, assignment_id)

Return one assignment row by assignment id.

Source code in dcs_simulation_engine/dal/base.py
235
236
237
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
87
88
89
90
91
92
93
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
95
96
97
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_latest_assignment_for_player(*, player_id)

Return the newest assignment for one player.

Source code in dcs_simulation_engine/dal/base.py
243
244
245
def get_latest_assignment_for_player(self, *, player_id: str) -> AssignmentRecord | None:
    """Return the newest assignment for one player."""
    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
103
104
105
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)

Return player-scoped form responses for a player.

Source code in dcs_simulation_engine/dal/base.py
287
288
289
290
291
292
293
def get_player_forms(
    self,
    *,
    player_id: str,
) -> PlayerFormsRecord | None:
    """Return player-scoped form responses for a player."""
    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
122
123
124
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_resumable_session(*, player_id, game_name, pc_hid, npc_hid)

Return the most recent paused session for this player/game/character combo, or None.

Source code in dcs_simulation_engine/dal/base.py
156
157
158
159
160
161
162
163
164
165
def get_resumable_session(
    self,
    *,
    player_id: str,
    game_name: str,
    pc_hid: str,
    npc_hid: str,
) -> SessionRecord | None:
    """Return the most recent paused session for this player/game/character combo, or None."""
    raise NotImplementedError
get_run()

Return the singleton persisted run record.

Source code in dcs_simulation_engine/dal/base.py
208
209
210
def get_run(self) -> RunRecord | None:
    """Return the singleton persisted run record."""
    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
138
139
140
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(*, player_id=None, statuses=None, game_name=None)

List run assignments matching the provided filters.

Source code in dcs_simulation_engine/dal/base.py
247
248
249
250
251
252
253
254
255
def list_assignments(
    self,
    *,
    player_id: str | None = None,
    statuses: list[str] | None = None,
    game_name: str | None = None,
) -> list[AssignmentRecord]:
    """List run assignments matching the provided filters."""
    raise NotImplementedError
list_characters()

Return all characters.

Source code in dcs_simulation_engine/dal/base.py
 99
100
101
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
167
168
169
def list_session_events(self, *, session_id: str) -> list[SessionEventRecord]:
    """Return ordered persisted events for a session."""
    raise NotImplementedError
save_runtime_state(*, session_id, runtime_state)

Upsert the resumable runtime snapshot on a session document.

Source code in dcs_simulation_engine/dal/base.py
142
143
144
def save_runtime_state(self, *, session_id: str, runtime_state: dict[str, Any]) -> None:
    """Upsert the resumable runtime snapshot on a session document."""
    raise NotImplementedError
set_assignment_form_response(*, assignment_id, form_key, response)

Store one run form response payload on an assignment row.

Source code in dcs_simulation_engine/dal/base.py
267
268
269
270
271
272
273
274
275
def set_assignment_form_response(
    self,
    *,
    assignment_id: str,
    form_key: str,
    response: dict[str, Any],
) -> AssignmentRecord | None:
    """Store one run form response payload on an assignment row."""
    raise NotImplementedError
set_player_form_response(*, player_id, form_key, response)

Store one player-scoped form response in the forms collection.

Source code in dcs_simulation_engine/dal/base.py
277
278
279
280
281
282
283
284
285
def set_player_form_response(
    self,
    *,
    player_id: str,
    form_key: str,
    response: dict[str, Any],
) -> PlayerFormsRecord | None:
    """Store one player-scoped form response in the forms collection."""
    raise NotImplementedError
set_run_progress(*, progress)

Persist the latest run progress snapshot.

Source code in dcs_simulation_engine/dal/base.py
223
224
225
226
227
228
229
def set_run_progress(
    self,
    *,
    progress: dict[str, Any],
) -> RunRecord | None:
    """Persist the latest run progress snapshot."""
    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
187
188
189
190
191
192
193
194
195
196
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
257
258
259
260
261
262
263
264
265
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
126
127
128
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_run(*, name, description, config_snapshot, progress)

Create or update a persisted run metadata record.

Source code in dcs_simulation_engine/dal/base.py
212
213
214
215
216
217
218
219
220
221
def upsert_run(
    self,
    *,
    name: str,
    description: str,
    config_snapshot: dict[str, Any],
    progress: dict[str, Any],
) -> RunRecord:
    """Create or update a persisted run metadata record."""
    raise NotImplementedError
PlayerFormsRecord

Bases: NamedTuple

Before-play form responses for a player.

Source code in dcs_simulation_engine/dal/base.py
58
59
60
61
62
63
64
class PlayerFormsRecord(NamedTuple):
    """Before-play form responses for a player."""

    player_id: 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]
RunRecord

Bases: NamedTuple

A persisted run metadata record.

Source code in dcs_simulation_engine/dal/base.py
49
50
51
52
53
54
55
class RunRecord(NamedTuple):
    """A persisted run metadata record."""

    name: str
    created_at: Any
    updated_at: Any
    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]

character_filters

Character filter registry.

get_character_filter(name)

Resolve a registered character filter by name.

Source code in dcs_simulation_engine/dal/character_filters/__init__.py
34
35
36
37
38
39
def get_character_filter(name: str) -> CharacterFilter:
    """Resolve a registered character filter by name."""
    try:
        return _FILTERS[name]
    except KeyError as exc:
        raise ValueError(f"Unknown character filter: {name!r}. Valid: {sorted(_FILTERS)}") from exc
list_character_filter_names()

Return all registered character filter names in a stable order.

Source code in dcs_simulation_engine/dal/character_filters/__init__.py
42
43
44
def list_character_filter_names() -> tuple[str, ...]:
    """Return all registered character filter names in a stable order."""
    return tuple(sorted(_FILTERS))
all

Character filter: all characters.

AllCharactersFilter

Returns every character from the database with no filtering.

Source code in dcs_simulation_engine/dal/character_filters/all.py
 8
 9
10
11
12
13
14
15
class AllCharactersFilter:
    """Returns every character from the database with no filtering."""

    name = "all"

    def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
        """Return all characters from the provider without filtering."""
        return list(provider.get_characters())
get_characters(*, provider)

Return all characters from the provider without filtering.

Source code in dcs_simulation_engine/dal/character_filters/all.py
13
14
15
def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
    """Return all characters from the provider without filtering."""
    return list(provider.get_characters())
base

Character filter protocol.

CharacterFilter

Bases: Protocol

Contract for character filter implementations.

Source code in dcs_simulation_engine/dal/character_filters/base.py
 9
10
11
12
13
14
15
class CharacterFilter(Protocol):
    """Contract for character filter implementations."""

    name: str

    def get_characters(self, *, provider: Any) -> "list[CharacterRecord]":
        """Return all characters from provider that match this filter."""
get_characters(*, provider)

Return all characters from provider that match this filter.

Source code in dcs_simulation_engine/dal/character_filters/base.py
14
15
def get_characters(self, *, provider: Any) -> "list[CharacterRecord]":
    """Return all characters from provider that match this filter."""
divergent

Character filter: characters with any non-normative HSN divergence.

DivergentFilter

Returns characters with at least one non-normative HSN divergence value.

Source code in dcs_simulation_engine/dal/character_filters/divergent.py
 9
10
11
12
13
14
15
16
class DivergentFilter:
    """Returns characters with at least one non-normative HSN divergence value."""

    name = "divergent"

    def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
        """Return characters with any non-normative HSN divergence."""
        return [r for r in provider.get_characters() if has_any_non_normative_hsn_divergence(r.data.get("hsn_divergence"))]
get_characters(*, provider)

Return characters with any non-normative HSN divergence.

Source code in dcs_simulation_engine/dal/character_filters/divergent.py
14
15
16
def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
    """Return characters with any non-normative HSN divergence."""
    return [r for r in provider.get_characters() if has_any_non_normative_hsn_divergence(r.data.get("hsn_divergence"))]
human

Character filter: human characters.

HumanFilter

Returns only human characters.

Source code in dcs_simulation_engine/dal/character_filters/human.py
 8
 9
10
11
12
13
14
15
class HumanFilter:
    """Returns only human characters."""

    name = "human"

    def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
        """Return characters whose is_human flag is truthy."""
        return [r for r in provider.get_characters() if r.data.get("is_human", False)]
get_characters(*, provider)

Return characters whose is_human flag is truthy.

Source code in dcs_simulation_engine/dal/character_filters/human.py
13
14
15
def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
    """Return characters whose is_human flag is truthy."""
    return [r for r in provider.get_characters() if r.data.get("is_human", False)]
human_normative

Character filter: human-normative characters.

HumanNormativeFilter

Returns human characters with a neurotypical/normative profile.

Matches characters where is_human=True and 'neurotypical' appears in common_labels (e.g. NA, NB).

Source code in dcs_simulation_engine/dal/character_filters/human_normative.py
 8
 9
10
11
12
13
14
15
16
17
18
19
class HumanNormativeFilter:
    """Returns human characters with a neurotypical/normative profile.

    Matches characters where is_human=True and 'neurotypical' appears in
    common_labels (e.g. NA, NB).
    """

    name = "human-normative"

    def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
        """Return human characters labelled as neurotypical."""
        return [r for r in provider.get_characters() if r.data.get("is_human", False) and "neurotypical" in r.data.get("common_labels", [])]
get_characters(*, provider)

Return human characters labelled as neurotypical.

Source code in dcs_simulation_engine/dal/character_filters/human_normative.py
17
18
19
def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
    """Return human characters labelled as neurotypical."""
    return [r for r in provider.get_characters() if r.data.get("is_human", False) and "neurotypical" in r.data.get("common_labels", [])]
hypersensitive

Character filter: hypersensitive characters.

HypersensitiveFilter

Returns characters with sensory hypersensitivity profiles.

Matches any character whose common_labels overlap with the hypersensitive label set (e.g. DS, JW, JAB, WS).

Source code in dcs_simulation_engine/dal/character_filters/hypersensitive.py
18
19
20
21
22
23
24
25
26
27
28
29
class HypersensitiveFilter:
    """Returns characters with sensory hypersensitivity profiles.

    Matches any character whose common_labels overlap with the
    hypersensitive label set (e.g. DS, JW, JAB, WS).
    """

    name = "hypersensitive"

    def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
        """Return characters whose labels match the hypersensitive set."""
        return [r for r in provider.get_characters() if _HYPERSENSITIVE_LABELS & set(r.data.get("common_labels", []))]
get_characters(*, provider)

Return characters whose labels match the hypersensitive set.

Source code in dcs_simulation_engine/dal/character_filters/hypersensitive.py
27
28
29
def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
    """Return characters whose labels match the hypersensitive set."""
    return [r for r in provider.get_characters() if _HYPERSENSITIVE_LABELS & set(r.data.get("common_labels", []))]
hyposensitive

Character filter: hyposensitive characters.

HyposensitiveFilter

Returns characters with sensory hyposensitivity profiles.

Matches any character whose common_labels overlap with the hyposensitive label set (e.g. KAT).

Source code in dcs_simulation_engine/dal/character_filters/hyposensitive.py
15
16
17
18
19
20
21
22
23
24
25
26
class HyposensitiveFilter:
    """Returns characters with sensory hyposensitivity profiles.

    Matches any character whose common_labels overlap with the
    hyposensitive label set (e.g. KAT).
    """

    name = "hyposensitive"

    def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
        """Return characters whose labels match the hyposensitive set."""
        return [r for r in provider.get_characters() if _HYPOSENSITIVE_LABELS & set(r.data.get("common_labels", []))]
get_characters(*, provider)

Return characters whose labels match the hyposensitive set.

Source code in dcs_simulation_engine/dal/character_filters/hyposensitive.py
24
25
26
def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
    """Return characters whose labels match the hyposensitive set."""
    return [r for r in provider.get_characters() if _HYPOSENSITIVE_LABELS & set(r.data.get("common_labels", []))]
neurodivergent

Character filter: characters with neuro-style HSN divergence.

NeurodivergentFilter

Returns characters with non-normative cognitive or communication HSN divergence.

Source code in dcs_simulation_engine/dal/character_filters/neurodivergent.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
class NeurodivergentFilter:
    """Returns characters with non-normative cognitive or communication HSN divergence."""

    name = "neurodivergent"

    def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
        """Return characters with non-normative cognitive/perceptual or neurotypical-communication divergence."""
        results: list[CharacterRecord] = []
        for record in provider.get_characters():
            divergence = record.data.get("hsn_divergence")
            if not isinstance(divergence, dict):
                continue
            if section_has_non_normative_assumption(divergence.get("cognitive_and_perceptual_assumptions")):
                results.append(record)
                continue
            social_section = divergence.get("social_and_communicative_assumptions")
            if not isinstance(social_section, dict):
                continue
            communication = social_section.get("neurotypical_communication")
            if isinstance(communication, dict):
                value = communication.get("value")
                if value is not None and value != "normative":
                    results.append(record)
        return results
get_characters(*, provider)

Return characters with non-normative cognitive/perceptual or neurotypical-communication divergence.

Source code in dcs_simulation_engine/dal/character_filters/neurodivergent.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
    """Return characters with non-normative cognitive/perceptual or neurotypical-communication divergence."""
    results: list[CharacterRecord] = []
    for record in provider.get_characters():
        divergence = record.data.get("hsn_divergence")
        if not isinstance(divergence, dict):
            continue
        if section_has_non_normative_assumption(divergence.get("cognitive_and_perceptual_assumptions")):
            results.append(record)
            continue
        social_section = divergence.get("social_and_communicative_assumptions")
        if not isinstance(social_section, dict):
            continue
        communication = social_section.get("neurotypical_communication")
        if isinstance(communication, dict):
            value = communication.get("value")
            if value is not None and value != "normative":
                results.append(record)
    return results
neurotypical

Character filter: neurotypical characters.

NeurotypicalFilter

Returns characters labelled neurotypical (human or non-human).

Matches any character where 'neurotypical' appears in common_labels (e.g. NA, NB).

Source code in dcs_simulation_engine/dal/character_filters/neurotypical.py
 8
 9
10
11
12
13
14
15
16
17
18
19
class NeurotypicalFilter:
    """Returns characters labelled neurotypical (human or non-human).

    Matches any character where 'neurotypical' appears in common_labels
    (e.g. NA, NB).
    """

    name = "neurotypical"

    def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
        """Return characters labelled as neurotypical."""
        return [r for r in provider.get_characters() if "neurotypical" in r.data.get("common_labels", [])]
get_characters(*, provider)

Return characters labelled as neurotypical.

Source code in dcs_simulation_engine/dal/character_filters/neurotypical.py
17
18
19
def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
    """Return characters labelled as neurotypical."""
    return [r for r in provider.get_characters() if "neurotypical" in r.data.get("common_labels", [])]
non_human

Character filter: non-human characters.

NonHumanFilter

Returns only non-human characters.

Source code in dcs_simulation_engine/dal/character_filters/non_human.py
 8
 9
10
11
12
13
14
15
class NonHumanFilter:
    """Returns only non-human characters."""

    name = "non-human"

    def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
        """Return characters whose is_human flag is falsy."""
        return [r for r in provider.get_characters() if not r.data.get("is_human", False)]
get_characters(*, provider)

Return characters whose is_human flag is falsy.

Source code in dcs_simulation_engine/dal/character_filters/non_human.py
13
14
15
def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
    """Return characters whose is_human flag is falsy."""
    return [r for r in provider.get_characters() if not r.data.get("is_human", False)]
pc_eligible

Character filter: PC-eligible characters.

PcEligibleFilter

Returns characters that are eligible to be used as PCs.

Source code in dcs_simulation_engine/dal/character_filters/pc_eligible.py
 8
 9
10
11
12
13
14
15
class PcEligibleFilter:
    """Returns characters that are eligible to be used as PCs."""

    name = "pc-eligible"

    def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
        """Return characters whose pc_eligible flag is truthy."""
        return [r for r in provider.get_characters() if r.data.get("pc_eligible", False)]
get_characters(*, provider)

Return characters whose pc_eligible flag is truthy.

Source code in dcs_simulation_engine/dal/character_filters/pc_eligible.py
13
14
15
def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
    """Return characters whose pc_eligible flag is truthy."""
    return [r for r in provider.get_characters() if r.data.get("pc_eligible", False)]
physical_divergence

Character filter: characters with physical-ability HSN divergence.

PhysicalDivergenceFilter

Returns characters with non-normative physical-ability HSN divergence.

Source code in dcs_simulation_engine/dal/character_filters/physical_divergence.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
class PhysicalDivergenceFilter:
    """Returns characters with non-normative physical-ability HSN divergence."""

    name = "physical-divergence"

    def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
        """Return characters with non-normative physical-ability divergence."""
        return [
            r
            for r in provider.get_characters()
            if isinstance(r.data.get("hsn_divergence"), dict)
            and section_has_non_normative_assumption(r.data["hsn_divergence"].get("physical_ability_assumptions"))
        ]
get_characters(*, provider)

Return characters with non-normative physical-ability divergence.

Source code in dcs_simulation_engine/dal/character_filters/physical_divergence.py
14
15
16
17
18
19
20
21
def get_characters(self, *, provider: Any) -> list[CharacterRecord]:
    """Return characters with non-normative physical-ability divergence."""
    return [
        r
        for r in provider.get_characters()
        if isinstance(r.data.get("hsn_divergence"), dict)
        and section_has_non_normative_assumption(r.data["hsn_divergence"].get("physical_ability_assumptions"))
    ]

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
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
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
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
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."""
        query: dict[str, Any] = {MongoColumns.SESSION_ID: session_id}
        if player_id is not None:
            query[MongoColumns.PLAYER_ID] = player_id
        doc = await maybe_await(
            self._db[MongoColumns.SESSIONS].find_one(
                query
            )
        )
        if not doc:
            return None
        return _to_session_record(doc)

    async def save_runtime_state(self, *, session_id: str, runtime_state: dict) -> None:
        """Upsert the resumable runtime snapshot on a session document."""
        await maybe_await(
            self._db[MongoColumns.SESSIONS].update_one(
                {MongoColumns.SESSION_ID: session_id},
                {
                    "$set": {
                        MongoColumns.RUNTIME_STATE: runtime_state,
                        MongoColumns.UPDATED_AT: utc_now(),
                    }
                },
            )
        )

    async def branch_session(
        self,
        *,
        session_id: str,
        player_id: str | None,
        branched_at: Any,
    ) -> SessionRecord:
        """Clone a persisted session into a new paused child session."""
        query: dict[str, Any] = {MongoColumns.SESSION_ID: session_id}
        if player_id is not None:
            query[MongoColumns.PLAYER_ID] = player_id

        root_doc = await maybe_await(self._db[MongoColumns.SESSIONS].find_one(query))
        if not root_doc:
            raise ValueError(f"Session {session_id!r} not found")

        runtime_state = root_doc.get(MongoColumns.RUNTIME_STATE)
        if not runtime_state:
            raise ValueError(f"Session {session_id!r} has no runtime_state snapshot")

        root_status = str(root_doc.get(MongoColumns.STATUS, ""))
        if root_status not in {"active", "paused"}:
            raise ValueError(f"Session {session_id!r} with status={root_status!r} is not branchable")

        child_session_id = str(uuid4())
        child_doc = deepcopy(root_doc)
        child_doc.pop(MongoColumns.ID, None)
        child_doc[MongoColumns.SESSION_ID] = child_session_id
        child_doc[MongoColumns.STATUS] = "paused"
        child_doc[MongoColumns.BRANCH_FROM_SESSION_ID] = str(root_doc.get(MongoColumns.SESSION_ID, session_id))
        child_doc[MongoColumns.SESSION_STARTED_AT] = branched_at
        child_doc[MongoColumns.SESSION_ENDED_AT] = None
        child_doc[MongoColumns.TERMINATION_REASON] = None
        child_doc[MongoColumns.CREATED_AT] = branched_at
        child_doc[MongoColumns.UPDATED_AT] = branched_at
        child_doc["paused_at"] = branched_at
        child_doc.pop("resumed_at", None)

        await maybe_await(self._db[MongoColumns.SESSIONS].insert_one(child_doc))

        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)
        root_events = await _cursor_to_docs(cursor)
        if root_events:
            copied_events: list[dict[str, Any]] = []
            for event_doc in root_events:
                copied = deepcopy(event_doc)
                copied.pop(MongoColumns.ID, None)
                copied[MongoColumns.SESSION_ID] = child_session_id
                copied[MongoColumns.EVENT_ID] = str(uuid4())
                copied_events.append(copied)
            await maybe_await(self._db[MongoColumns.SESSION_EVENTS].insert_many(copied_events))

        child = await maybe_await(
            self._db[MongoColumns.SESSIONS].find_one({MongoColumns.SESSION_ID: child_session_id})
        )
        if child is None:
            raise ValueError(f"Failed to create branched child for session {session_id!r}")
        return _to_session_record(child)

    async def get_resumable_session(
        self,
        *,
        player_id: str,
        game_name: str,
        pc_hid: str,
        npc_hid: str,
    ) -> SessionRecord | None:
        """Return the most recent paused session for this player/game/character combo."""
        cursor = self._db[MongoColumns.SESSIONS].find(
            {
                MongoColumns.PLAYER_ID: player_id,
                MongoColumns.GAME_NAME: game_name,
                MongoColumns.PC_HID: pc_hid,
                MongoColumns.NPC_HID: npc_hid,
                MongoColumns.STATUS: "paused",
            }
        )
        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_session_record(docs[0])

    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_run(self) -> RunRecord | None:
        """Return the singleton persisted run metadata record."""
        doc = await maybe_await(self._db[MongoColumns.RUNS].find_one({MongoColumns.ID: RUN_METADATA_ID}))
        if not doc:
            return None
        return _to_run_record(doc)

    async def upsert_run(
        self,
        *,
        name: str,
        description: str,
        config_snapshot: dict[str, Any],
        progress: dict[str, Any],
    ) -> RunRecord:
        """Create or update the singleton run metadata row."""
        now = utc_now()
        await maybe_await(
            self._db[MongoColumns.RUNS].update_one(
                {MongoColumns.ID: RUN_METADATA_ID},
                {
                    "$set": {
                        MongoColumns.NAME: 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_run()
        if record is None:
            raise ValueError(f"Run metadata {name!r} was not persisted")
        return record

    async def set_run_progress(
        self,
        *,
        progress: dict[str, Any],
    ) -> RunRecord | None:
        """Persist the latest run progress snapshot."""
        now = utc_now()
        await maybe_await(
            self._db[MongoColumns.RUNS].update_one(
                {MongoColumns.ID: RUN_METADATA_ID},
                {
                    "$set": {
                        MongoColumns.PROGRESS: progress,
                        MongoColumns.UPDATED_AT: now,
                    }
                },
            )
        )
        return await self.get_run()

    async def create_assignment(self, *, assignment_doc: dict[str, Any], allow_concurrent: bool = False) -> AssignmentRecord:
        """Persist a new assignment row."""
        player_id = str(assignment_doc.get(MongoColumns.PLAYER_ID) or "")
        if not player_id:
            raise ValueError("assignment_doc must include player_id")

        if not allow_concurrent:
            existing = await self.get_active_assignment(player_id=player_id)
            if existing is not None:
                raise ValueError("Player already has an active assignment")

        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, *, player_id: str) -> AssignmentRecord | None:
        """Return the current active assignment for one player."""
        cursor = self._db[MongoColumns.ASSIGNMENTS].find(
            {
                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_assignment_for_player(self, *, player_id: str) -> AssignmentRecord | None:
        """Return the newest 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,
        *,
        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] = {}
        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,
        form_key: str,
        response: dict[str, Any],
    ) -> PlayerFormsRecord | None:
        """Upsert one player-scoped form response into the forms collection."""
        now = utc_now()
        await maybe_await(
            self._db[MongoColumns.FORMS].update_one(
                {MongoColumns.PLAYER_ID: player_id},
                {
                    "$set": {f"data.{form_key}": response, MongoColumns.UPDATED_AT: now},
                    "$setOnInsert": {
                        MongoColumns.PLAYER_ID: player_id,
                        MongoColumns.CREATED_AT: now,
                    },
                },
                upsert=True,
            )
        )
        return await self.get_player_forms(player_id=player_id)

    async def get_player_forms(
        self,
        *,
        player_id: str,
    ) -> PlayerFormsRecord | None:
        """Return player-scoped form responses for a player."""
        doc = await maybe_await(
            self._db[MongoColumns.FORMS].find_one({MongoColumns.PLAYER_ID: player_id})
        )
        if not doc:
            return None
        return PlayerFormsRecord(
            player_id=doc[MongoColumns.PLAYER_ID],
            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
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
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)
branch_session(*, session_id, player_id, branched_at) async

Clone a persisted session into a new paused child session.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
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
async def branch_session(
    self,
    *,
    session_id: str,
    player_id: str | None,
    branched_at: Any,
) -> SessionRecord:
    """Clone a persisted session into a new paused child session."""
    query: dict[str, Any] = {MongoColumns.SESSION_ID: session_id}
    if player_id is not None:
        query[MongoColumns.PLAYER_ID] = player_id

    root_doc = await maybe_await(self._db[MongoColumns.SESSIONS].find_one(query))
    if not root_doc:
        raise ValueError(f"Session {session_id!r} not found")

    runtime_state = root_doc.get(MongoColumns.RUNTIME_STATE)
    if not runtime_state:
        raise ValueError(f"Session {session_id!r} has no runtime_state snapshot")

    root_status = str(root_doc.get(MongoColumns.STATUS, ""))
    if root_status not in {"active", "paused"}:
        raise ValueError(f"Session {session_id!r} with status={root_status!r} is not branchable")

    child_session_id = str(uuid4())
    child_doc = deepcopy(root_doc)
    child_doc.pop(MongoColumns.ID, None)
    child_doc[MongoColumns.SESSION_ID] = child_session_id
    child_doc[MongoColumns.STATUS] = "paused"
    child_doc[MongoColumns.BRANCH_FROM_SESSION_ID] = str(root_doc.get(MongoColumns.SESSION_ID, session_id))
    child_doc[MongoColumns.SESSION_STARTED_AT] = branched_at
    child_doc[MongoColumns.SESSION_ENDED_AT] = None
    child_doc[MongoColumns.TERMINATION_REASON] = None
    child_doc[MongoColumns.CREATED_AT] = branched_at
    child_doc[MongoColumns.UPDATED_AT] = branched_at
    child_doc["paused_at"] = branched_at
    child_doc.pop("resumed_at", None)

    await maybe_await(self._db[MongoColumns.SESSIONS].insert_one(child_doc))

    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)
    root_events = await _cursor_to_docs(cursor)
    if root_events:
        copied_events: list[dict[str, Any]] = []
        for event_doc in root_events:
            copied = deepcopy(event_doc)
            copied.pop(MongoColumns.ID, None)
            copied[MongoColumns.SESSION_ID] = child_session_id
            copied[MongoColumns.EVENT_ID] = str(uuid4())
            copied_events.append(copied)
        await maybe_await(self._db[MongoColumns.SESSION_EVENTS].insert_many(copied_events))

    child = await maybe_await(
        self._db[MongoColumns.SESSIONS].find_one({MongoColumns.SESSION_ID: child_session_id})
    )
    if child is None:
        raise ValueError(f"Failed to create branched child for session {session_id!r}")
    return _to_session_record(child)
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
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
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 assignment row.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
async def create_assignment(self, *, assignment_doc: dict[str, Any], allow_concurrent: bool = False) -> AssignmentRecord:
    """Persist a new assignment row."""
    player_id = str(assignment_doc.get(MongoColumns.PLAYER_ID) or "")
    if not player_id:
        raise ValueError("assignment_doc must include player_id")

    if not allow_concurrent:
        existing = await self.get_active_assignment(player_id=player_id)
        if existing is not None:
            raise ValueError("Player already has an active assignment")

    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
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
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
318
319
320
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
307
308
309
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
311
312
313
314
315
316
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
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
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(*, player_id) async

Return the current active assignment for one player.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
async def get_active_assignment(self, *, player_id: str) -> AssignmentRecord | None:
    """Return the current active assignment for one player."""
    cursor = self._db[MongoColumns.ASSIGNMENTS].find(
        {
            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
750
751
752
753
754
755
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
773
774
775
776
777
778
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
158
159
160
161
162
163
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
145
146
147
148
149
150
151
152
153
154
155
156
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_latest_assignment_for_player(*, player_id) async

Return the newest assignment for one player.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
780
781
782
783
784
785
786
787
788
789
async def get_latest_assignment_for_player(self, *, player_id: str) -> AssignmentRecord | None:
    """Return the newest 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
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
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) async

Return player-scoped form responses for a player.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
async def get_player_forms(
    self,
    *,
    player_id: str,
) -> PlayerFormsRecord | None:
    """Return player-scoped form responses for a player."""
    doc = await maybe_await(
        self._db[MongoColumns.FORMS].find_one({MongoColumns.PLAYER_ID: player_id})
    )
    if not doc:
        return None
    return PlayerFormsRecord(
        player_id=doc[MongoColumns.PLAYER_ID],
        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
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
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_resumable_session(*, player_id, game_name, pc_hid, npc_hid) async

Return the most recent paused session for this player/game/character combo.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
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
async def get_resumable_session(
    self,
    *,
    player_id: str,
    game_name: str,
    pc_hid: str,
    npc_hid: str,
) -> SessionRecord | None:
    """Return the most recent paused session for this player/game/character combo."""
    cursor = self._db[MongoColumns.SESSIONS].find(
        {
            MongoColumns.PLAYER_ID: player_id,
            MongoColumns.GAME_NAME: game_name,
            MongoColumns.PC_HID: pc_hid,
            MongoColumns.NPC_HID: npc_hid,
            MongoColumns.STATUS: "paused",
        }
    )
    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_session_record(docs[0])
get_run() async

Return the singleton persisted run metadata record.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
668
669
670
671
672
673
async def get_run(self) -> RunRecord | None:
    """Return the singleton persisted run metadata record."""
    doc = await maybe_await(self._db[MongoColumns.RUNS].find_one({MongoColumns.ID: RUN_METADATA_ID}))
    if not doc:
        return None
    return _to_run_record(doc)
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
380
381
382
383
384
385
386
387
388
389
390
391
392
async def get_session(self, *, session_id: str, player_id: str | None) -> SessionRecord | None:
    """Return a single persisted session record for the player."""
    query: dict[str, Any] = {MongoColumns.SESSION_ID: session_id}
    if player_id is not None:
        query[MongoColumns.PLAYER_ID] = player_id
    doc = await maybe_await(
        self._db[MongoColumns.SESSIONS].find_one(
            query
        )
    )
    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
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
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(*, 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
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
async def list_assignments(
    self,
    *,
    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] = {}
    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
165
166
167
168
169
170
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
496
497
498
499
500
501
502
503
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
349
350
351
352
353
354
355
356
357
358
359
360
361
362
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
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
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": ""},
            },
        )
    )
save_runtime_state(*, session_id, runtime_state) async

Upsert the resumable runtime snapshot on a session document.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
394
395
396
397
398
399
400
401
402
403
404
405
406
async def save_runtime_state(self, *, session_id: str, runtime_state: dict) -> None:
    """Upsert the resumable runtime snapshot on a session document."""
    await maybe_await(
        self._db[MongoColumns.SESSIONS].update_one(
            {MongoColumns.SESSION_ID: session_id},
            {
                "$set": {
                    MongoColumns.RUNTIME_STATE: runtime_state,
                    MongoColumns.UPDATED_AT: utc_now(),
                }
            },
        )
    )
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
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
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_player_form_response(*, player_id, form_key, response) async

Upsert one player-scoped form response into the forms collection.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
async def set_player_form_response(
    self,
    *,
    player_id: str,
    form_key: str,
    response: dict[str, Any],
) -> PlayerFormsRecord | None:
    """Upsert one player-scoped form response into the forms collection."""
    now = utc_now()
    await maybe_await(
        self._db[MongoColumns.FORMS].update_one(
            {MongoColumns.PLAYER_ID: player_id},
            {
                "$set": {f"data.{form_key}": response, MongoColumns.UPDATED_AT: now},
                "$setOnInsert": {
                    MongoColumns.PLAYER_ID: player_id,
                    MongoColumns.CREATED_AT: now,
                },
            },
            upsert=True,
        )
    )
    return await self.get_player_forms(player_id=player_id)
set_run_progress(*, progress) async

Persist the latest run progress snapshot.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
async def set_run_progress(
    self,
    *,
    progress: dict[str, Any],
) -> RunRecord | None:
    """Persist the latest run progress snapshot."""
    now = utc_now()
    await maybe_await(
        self._db[MongoColumns.RUNS].update_one(
            {MongoColumns.ID: RUN_METADATA_ID},
            {
                "$set": {
                    MongoColumns.PROGRESS: progress,
                    MongoColumns.UPDATED_AT: now,
                }
            },
        )
    )
    return await self.get_run()
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
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
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
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
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
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
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_run(*, name, description, config_snapshot, progress) async

Create or update the singleton run metadata row.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
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
async def upsert_run(
    self,
    *,
    name: str,
    description: str,
    config_snapshot: dict[str, Any],
    progress: dict[str, Any],
) -> RunRecord:
    """Create or update the singleton run metadata row."""
    now = utc_now()
    await maybe_await(
        self._db[MongoColumns.RUNS].update_one(
            {MongoColumns.ID: RUN_METADATA_ID},
            {
                "$set": {
                    MongoColumns.NAME: 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_run()
    if record is None:
        raise ValueError(f"Run metadata {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
 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
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 '{}'; 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 {}: {} (unique={})", 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 in SEED_METADATA_FILENAMES 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
26
27
28
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
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
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
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
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
92
93
94
95
96
97
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
131
132
133
134
135
136
137
138
139
140
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 {}: {} (unique={})", 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
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
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
117
118
119
120
121
122
123
124
125
126
127
128
129
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 '{}'; 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
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
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 in SEED_METADATA_FILENAMES 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
 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
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 '{}'; 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 {}: {} (unique={})", 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 in SEED_METADATA_FILENAMES 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
26
27
28
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
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
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
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
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
92
93
94
95
96
97
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
131
132
133
134
135
136
137
138
139
140
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 {}: {} (unique={})", 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
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
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
117
118
119
120
121
122
123
124
125
126
127
128
129
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 '{}'; 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
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
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 in SEED_METADATA_FILENAMES 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
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
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
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
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."""
        query: dict[str, Any] = {MongoColumns.SESSION_ID: session_id}
        if player_id is not None:
            query[MongoColumns.PLAYER_ID] = player_id
        doc = await maybe_await(
            self._db[MongoColumns.SESSIONS].find_one(
                query
            )
        )
        if not doc:
            return None
        return _to_session_record(doc)

    async def save_runtime_state(self, *, session_id: str, runtime_state: dict) -> None:
        """Upsert the resumable runtime snapshot on a session document."""
        await maybe_await(
            self._db[MongoColumns.SESSIONS].update_one(
                {MongoColumns.SESSION_ID: session_id},
                {
                    "$set": {
                        MongoColumns.RUNTIME_STATE: runtime_state,
                        MongoColumns.UPDATED_AT: utc_now(),
                    }
                },
            )
        )

    async def branch_session(
        self,
        *,
        session_id: str,
        player_id: str | None,
        branched_at: Any,
    ) -> SessionRecord:
        """Clone a persisted session into a new paused child session."""
        query: dict[str, Any] = {MongoColumns.SESSION_ID: session_id}
        if player_id is not None:
            query[MongoColumns.PLAYER_ID] = player_id

        root_doc = await maybe_await(self._db[MongoColumns.SESSIONS].find_one(query))
        if not root_doc:
            raise ValueError(f"Session {session_id!r} not found")

        runtime_state = root_doc.get(MongoColumns.RUNTIME_STATE)
        if not runtime_state:
            raise ValueError(f"Session {session_id!r} has no runtime_state snapshot")

        root_status = str(root_doc.get(MongoColumns.STATUS, ""))
        if root_status not in {"active", "paused"}:
            raise ValueError(f"Session {session_id!r} with status={root_status!r} is not branchable")

        child_session_id = str(uuid4())
        child_doc = deepcopy(root_doc)
        child_doc.pop(MongoColumns.ID, None)
        child_doc[MongoColumns.SESSION_ID] = child_session_id
        child_doc[MongoColumns.STATUS] = "paused"
        child_doc[MongoColumns.BRANCH_FROM_SESSION_ID] = str(root_doc.get(MongoColumns.SESSION_ID, session_id))
        child_doc[MongoColumns.SESSION_STARTED_AT] = branched_at
        child_doc[MongoColumns.SESSION_ENDED_AT] = None
        child_doc[MongoColumns.TERMINATION_REASON] = None
        child_doc[MongoColumns.CREATED_AT] = branched_at
        child_doc[MongoColumns.UPDATED_AT] = branched_at
        child_doc["paused_at"] = branched_at
        child_doc.pop("resumed_at", None)

        await maybe_await(self._db[MongoColumns.SESSIONS].insert_one(child_doc))

        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)
        root_events = await _cursor_to_docs(cursor)
        if root_events:
            copied_events: list[dict[str, Any]] = []
            for event_doc in root_events:
                copied = deepcopy(event_doc)
                copied.pop(MongoColumns.ID, None)
                copied[MongoColumns.SESSION_ID] = child_session_id
                copied[MongoColumns.EVENT_ID] = str(uuid4())
                copied_events.append(copied)
            await maybe_await(self._db[MongoColumns.SESSION_EVENTS].insert_many(copied_events))

        child = await maybe_await(
            self._db[MongoColumns.SESSIONS].find_one({MongoColumns.SESSION_ID: child_session_id})
        )
        if child is None:
            raise ValueError(f"Failed to create branched child for session {session_id!r}")
        return _to_session_record(child)

    async def get_resumable_session(
        self,
        *,
        player_id: str,
        game_name: str,
        pc_hid: str,
        npc_hid: str,
    ) -> SessionRecord | None:
        """Return the most recent paused session for this player/game/character combo."""
        cursor = self._db[MongoColumns.SESSIONS].find(
            {
                MongoColumns.PLAYER_ID: player_id,
                MongoColumns.GAME_NAME: game_name,
                MongoColumns.PC_HID: pc_hid,
                MongoColumns.NPC_HID: npc_hid,
                MongoColumns.STATUS: "paused",
            }
        )
        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_session_record(docs[0])

    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_run(self) -> RunRecord | None:
        """Return the singleton persisted run metadata record."""
        doc = await maybe_await(self._db[MongoColumns.RUNS].find_one({MongoColumns.ID: RUN_METADATA_ID}))
        if not doc:
            return None
        return _to_run_record(doc)

    async def upsert_run(
        self,
        *,
        name: str,
        description: str,
        config_snapshot: dict[str, Any],
        progress: dict[str, Any],
    ) -> RunRecord:
        """Create or update the singleton run metadata row."""
        now = utc_now()
        await maybe_await(
            self._db[MongoColumns.RUNS].update_one(
                {MongoColumns.ID: RUN_METADATA_ID},
                {
                    "$set": {
                        MongoColumns.NAME: 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_run()
        if record is None:
            raise ValueError(f"Run metadata {name!r} was not persisted")
        return record

    async def set_run_progress(
        self,
        *,
        progress: dict[str, Any],
    ) -> RunRecord | None:
        """Persist the latest run progress snapshot."""
        now = utc_now()
        await maybe_await(
            self._db[MongoColumns.RUNS].update_one(
                {MongoColumns.ID: RUN_METADATA_ID},
                {
                    "$set": {
                        MongoColumns.PROGRESS: progress,
                        MongoColumns.UPDATED_AT: now,
                    }
                },
            )
        )
        return await self.get_run()

    async def create_assignment(self, *, assignment_doc: dict[str, Any], allow_concurrent: bool = False) -> AssignmentRecord:
        """Persist a new assignment row."""
        player_id = str(assignment_doc.get(MongoColumns.PLAYER_ID) or "")
        if not player_id:
            raise ValueError("assignment_doc must include player_id")

        if not allow_concurrent:
            existing = await self.get_active_assignment(player_id=player_id)
            if existing is not None:
                raise ValueError("Player already has an active assignment")

        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, *, player_id: str) -> AssignmentRecord | None:
        """Return the current active assignment for one player."""
        cursor = self._db[MongoColumns.ASSIGNMENTS].find(
            {
                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_assignment_for_player(self, *, player_id: str) -> AssignmentRecord | None:
        """Return the newest 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,
        *,
        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] = {}
        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,
        form_key: str,
        response: dict[str, Any],
    ) -> PlayerFormsRecord | None:
        """Upsert one player-scoped form response into the forms collection."""
        now = utc_now()
        await maybe_await(
            self._db[MongoColumns.FORMS].update_one(
                {MongoColumns.PLAYER_ID: player_id},
                {
                    "$set": {f"data.{form_key}": response, MongoColumns.UPDATED_AT: now},
                    "$setOnInsert": {
                        MongoColumns.PLAYER_ID: player_id,
                        MongoColumns.CREATED_AT: now,
                    },
                },
                upsert=True,
            )
        )
        return await self.get_player_forms(player_id=player_id)

    async def get_player_forms(
        self,
        *,
        player_id: str,
    ) -> PlayerFormsRecord | None:
        """Return player-scoped form responses for a player."""
        doc = await maybe_await(
            self._db[MongoColumns.FORMS].find_one({MongoColumns.PLAYER_ID: player_id})
        )
        if not doc:
            return None
        return PlayerFormsRecord(
            player_id=doc[MongoColumns.PLAYER_ID],
            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
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
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)
branch_session(*, session_id, player_id, branched_at) async

Clone a persisted session into a new paused child session.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
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
async def branch_session(
    self,
    *,
    session_id: str,
    player_id: str | None,
    branched_at: Any,
) -> SessionRecord:
    """Clone a persisted session into a new paused child session."""
    query: dict[str, Any] = {MongoColumns.SESSION_ID: session_id}
    if player_id is not None:
        query[MongoColumns.PLAYER_ID] = player_id

    root_doc = await maybe_await(self._db[MongoColumns.SESSIONS].find_one(query))
    if not root_doc:
        raise ValueError(f"Session {session_id!r} not found")

    runtime_state = root_doc.get(MongoColumns.RUNTIME_STATE)
    if not runtime_state:
        raise ValueError(f"Session {session_id!r} has no runtime_state snapshot")

    root_status = str(root_doc.get(MongoColumns.STATUS, ""))
    if root_status not in {"active", "paused"}:
        raise ValueError(f"Session {session_id!r} with status={root_status!r} is not branchable")

    child_session_id = str(uuid4())
    child_doc = deepcopy(root_doc)
    child_doc.pop(MongoColumns.ID, None)
    child_doc[MongoColumns.SESSION_ID] = child_session_id
    child_doc[MongoColumns.STATUS] = "paused"
    child_doc[MongoColumns.BRANCH_FROM_SESSION_ID] = str(root_doc.get(MongoColumns.SESSION_ID, session_id))
    child_doc[MongoColumns.SESSION_STARTED_AT] = branched_at
    child_doc[MongoColumns.SESSION_ENDED_AT] = None
    child_doc[MongoColumns.TERMINATION_REASON] = None
    child_doc[MongoColumns.CREATED_AT] = branched_at
    child_doc[MongoColumns.UPDATED_AT] = branched_at
    child_doc["paused_at"] = branched_at
    child_doc.pop("resumed_at", None)

    await maybe_await(self._db[MongoColumns.SESSIONS].insert_one(child_doc))

    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)
    root_events = await _cursor_to_docs(cursor)
    if root_events:
        copied_events: list[dict[str, Any]] = []
        for event_doc in root_events:
            copied = deepcopy(event_doc)
            copied.pop(MongoColumns.ID, None)
            copied[MongoColumns.SESSION_ID] = child_session_id
            copied[MongoColumns.EVENT_ID] = str(uuid4())
            copied_events.append(copied)
        await maybe_await(self._db[MongoColumns.SESSION_EVENTS].insert_many(copied_events))

    child = await maybe_await(
        self._db[MongoColumns.SESSIONS].find_one({MongoColumns.SESSION_ID: child_session_id})
    )
    if child is None:
        raise ValueError(f"Failed to create branched child for session {session_id!r}")
    return _to_session_record(child)
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
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
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 assignment row.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
async def create_assignment(self, *, assignment_doc: dict[str, Any], allow_concurrent: bool = False) -> AssignmentRecord:
    """Persist a new assignment row."""
    player_id = str(assignment_doc.get(MongoColumns.PLAYER_ID) or "")
    if not player_id:
        raise ValueError("assignment_doc must include player_id")

    if not allow_concurrent:
        existing = await self.get_active_assignment(player_id=player_id)
        if existing is not None:
            raise ValueError("Player already has an active assignment")

    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
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
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
318
319
320
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
307
308
309
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
311
312
313
314
315
316
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
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
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(*, player_id) async

Return the current active assignment for one player.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
async def get_active_assignment(self, *, player_id: str) -> AssignmentRecord | None:
    """Return the current active assignment for one player."""
    cursor = self._db[MongoColumns.ASSIGNMENTS].find(
        {
            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
750
751
752
753
754
755
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
773
774
775
776
777
778
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
158
159
160
161
162
163
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
145
146
147
148
149
150
151
152
153
154
155
156
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_latest_assignment_for_player(*, player_id) async

Return the newest assignment for one player.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
780
781
782
783
784
785
786
787
788
789
async def get_latest_assignment_for_player(self, *, player_id: str) -> AssignmentRecord | None:
    """Return the newest 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
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
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) async

Return player-scoped form responses for a player.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
async def get_player_forms(
    self,
    *,
    player_id: str,
) -> PlayerFormsRecord | None:
    """Return player-scoped form responses for a player."""
    doc = await maybe_await(
        self._db[MongoColumns.FORMS].find_one({MongoColumns.PLAYER_ID: player_id})
    )
    if not doc:
        return None
    return PlayerFormsRecord(
        player_id=doc[MongoColumns.PLAYER_ID],
        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
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
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_resumable_session(*, player_id, game_name, pc_hid, npc_hid) async

Return the most recent paused session for this player/game/character combo.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
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
async def get_resumable_session(
    self,
    *,
    player_id: str,
    game_name: str,
    pc_hid: str,
    npc_hid: str,
) -> SessionRecord | None:
    """Return the most recent paused session for this player/game/character combo."""
    cursor = self._db[MongoColumns.SESSIONS].find(
        {
            MongoColumns.PLAYER_ID: player_id,
            MongoColumns.GAME_NAME: game_name,
            MongoColumns.PC_HID: pc_hid,
            MongoColumns.NPC_HID: npc_hid,
            MongoColumns.STATUS: "paused",
        }
    )
    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_session_record(docs[0])
get_run() async

Return the singleton persisted run metadata record.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
668
669
670
671
672
673
async def get_run(self) -> RunRecord | None:
    """Return the singleton persisted run metadata record."""
    doc = await maybe_await(self._db[MongoColumns.RUNS].find_one({MongoColumns.ID: RUN_METADATA_ID}))
    if not doc:
        return None
    return _to_run_record(doc)
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
380
381
382
383
384
385
386
387
388
389
390
391
392
async def get_session(self, *, session_id: str, player_id: str | None) -> SessionRecord | None:
    """Return a single persisted session record for the player."""
    query: dict[str, Any] = {MongoColumns.SESSION_ID: session_id}
    if player_id is not None:
        query[MongoColumns.PLAYER_ID] = player_id
    doc = await maybe_await(
        self._db[MongoColumns.SESSIONS].find_one(
            query
        )
    )
    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
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
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(*, 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
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
async def list_assignments(
    self,
    *,
    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] = {}
    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
165
166
167
168
169
170
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
496
497
498
499
500
501
502
503
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
349
350
351
352
353
354
355
356
357
358
359
360
361
362
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
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
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": ""},
            },
        )
    )
save_runtime_state(*, session_id, runtime_state) async

Upsert the resumable runtime snapshot on a session document.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
394
395
396
397
398
399
400
401
402
403
404
405
406
async def save_runtime_state(self, *, session_id: str, runtime_state: dict) -> None:
    """Upsert the resumable runtime snapshot on a session document."""
    await maybe_await(
        self._db[MongoColumns.SESSIONS].update_one(
            {MongoColumns.SESSION_ID: session_id},
            {
                "$set": {
                    MongoColumns.RUNTIME_STATE: runtime_state,
                    MongoColumns.UPDATED_AT: utc_now(),
                }
            },
        )
    )
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
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
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_player_form_response(*, player_id, form_key, response) async

Upsert one player-scoped form response into the forms collection.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
async def set_player_form_response(
    self,
    *,
    player_id: str,
    form_key: str,
    response: dict[str, Any],
) -> PlayerFormsRecord | None:
    """Upsert one player-scoped form response into the forms collection."""
    now = utc_now()
    await maybe_await(
        self._db[MongoColumns.FORMS].update_one(
            {MongoColumns.PLAYER_ID: player_id},
            {
                "$set": {f"data.{form_key}": response, MongoColumns.UPDATED_AT: now},
                "$setOnInsert": {
                    MongoColumns.PLAYER_ID: player_id,
                    MongoColumns.CREATED_AT: now,
                },
            },
            upsert=True,
        )
    )
    return await self.get_player_forms(player_id=player_id)
set_run_progress(*, progress) async

Persist the latest run progress snapshot.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
async def set_run_progress(
    self,
    *,
    progress: dict[str, Any],
) -> RunRecord | None:
    """Persist the latest run progress snapshot."""
    now = utc_now()
    await maybe_await(
        self._db[MongoColumns.RUNS].update_one(
            {MongoColumns.ID: RUN_METADATA_ID},
            {
                "$set": {
                    MongoColumns.PROGRESS: progress,
                    MongoColumns.UPDATED_AT: now,
                }
            },
        )
    )
    return await self.get_run()
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
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
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
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
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
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
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_run(*, name, description, config_snapshot, progress) async

Create or update the singleton run metadata row.

Source code in dcs_simulation_engine/dal/mongo/async_provider.py
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
async def upsert_run(
    self,
    *,
    name: str,
    description: str,
    config_snapshot: dict[str, Any],
    progress: dict[str, Any],
) -> RunRecord:
    """Create or update the singleton run metadata row."""
    now = utc_now()
    await maybe_await(
        self._db[MongoColumns.RUNS].update_one(
            {MongoColumns.ID: RUN_METADATA_ID},
            {
                "$set": {
                    MongoColumns.NAME: 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_run()
    if record is None:
        raise ValueError(f"Run metadata {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
86
87
88
89
90
91
92
class MongoColumns:
    """Namespace for MongoDB collection and field name constants."""

    CHARACTERS = "characters"
    PLAYERS = "players"
    PII = "pii"
    SESSIONS = "sessions"
    SESSION_EVENTS = "session_events"
    LOGS = "logs"
    RUNS = "runs"
    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"
    ACTIVE_SESSION_ID = "active_session_id"
    BRANCH_FROM_SESSION_ID = "branch_from_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"
    FAILURE_TYPE = "failure_type"
    RETRIES_REMAINING = "retries_remaining"
    EXIT_REASON = "exit_reason"
    PROVIDER = "provider"
    PROVIDER_STATUS_CODE = "provider_status_code"
    PROVIDER_CODE = "provider_code"
    COMMAND_NAME = "command_name"
    COMMAND_ARGS = "command_args"
    VISIBLE_TO_USER = "visible_to_user"
    METADATA = "metadata"
    RUNTIME_STATE = "runtime_state"
    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",
    }
log_events

Mongo-backed writer for persisted engine log events.

MongoLogEventWriter

Buffer persisted log events and write them to Mongo in small batches.

Source code in dcs_simulation_engine/dal/mongo/log_events.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
 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
class MongoLogEventWriter:
    """Buffer persisted log events and write them to Mongo in small batches."""

    def __init__(
        self,
        *,
        db: Any,
        batch_size: int = 20,
        flush_interval_ms: int = 200,
        max_queue_size: int = 1000,
    ) -> None:
        """Bind the writer to Mongo and configure batching limits."""
        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 = db[MongoColumns.LOGS]
        self._batch_size = batch_size
        self._flush_interval_s = flush_interval_ms / 1000.0
        self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=max_queue_size)
        self._flush_requested = asyncio.Event()
        self._closed = False
        self._started = False
        self._task: asyncio.Task[None] | None = None

    async def start(self) -> None:
        """Start the background batch writer."""
        if self._started:
            return
        self._started = True
        self._task = asyncio.create_task(self._worker_loop())

    def enqueue_nowait(self, doc: dict[str, Any]) -> bool:
        """Queue a log event without blocking application logging."""
        if self._closed or not self._started:
            return False
        try:
            self._queue.put_nowait(doc)
        except asyncio.QueueFull:
            return False
        if self._queue.qsize() >= self._batch_size:
            self._flush_requested.set()
        return True

    async def flush(self) -> None:
        """Flush all queued log events."""
        if not self._started:
            return
        self._flush_requested.set()
        await self._queue.join()

    async def close(self) -> None:
        """Flush and stop the background writer."""
        if self._closed:
            return
        self._closed = True
        self._flush_requested.set()
        if self._task is not None:
            await self._task
            self._task = None

    async def _worker_loop(self) -> None:
        while not self._closed or not self._queue.empty():
            try:
                await asyncio.wait_for(self._flush_requested.wait(), timeout=self._flush_interval_s)
            except asyncio.TimeoutError:
                pass
            self._flush_requested.clear()

            batch = self._drain_batch()
            if batch:
                await self._write_batch(batch)

    def _drain_batch(self) -> list[dict[str, Any]]:
        batch: list[dict[str, Any]] = []
        while len(batch) < self._batch_size:
            try:
                batch.append(self._queue.get_nowait())
            except asyncio.QueueEmpty:
                break
        return batch

    async def _write_batch(self, batch: list[dict[str, Any]]) -> None:
        persisted_at = utc_now()
        for doc in batch:
            doc.setdefault(MongoColumns.PERSISTED_AT, persisted_at)

        try:
            await maybe_await(self._collection.insert_many(batch, ordered=False))
        except Exception as exc:
            sys.stderr.write(f"Failed to persist {len(batch)} log event(s): {exc}\n")
        finally:
            for _ in batch:
                self._queue.task_done()
__init__(*, db, batch_size=20, flush_interval_ms=200, max_queue_size=1000)

Bind the writer to Mongo and configure batching limits.

Source code in dcs_simulation_engine/dal/mongo/log_events.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def __init__(
    self,
    *,
    db: Any,
    batch_size: int = 20,
    flush_interval_ms: int = 200,
    max_queue_size: int = 1000,
) -> None:
    """Bind the writer to Mongo and configure batching limits."""
    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 = db[MongoColumns.LOGS]
    self._batch_size = batch_size
    self._flush_interval_s = flush_interval_ms / 1000.0
    self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=max_queue_size)
    self._flush_requested = asyncio.Event()
    self._closed = False
    self._started = False
    self._task: asyncio.Task[None] | None = None
close() async

Flush and stop the background writer.

Source code in dcs_simulation_engine/dal/mongo/log_events.py
66
67
68
69
70
71
72
73
74
async def close(self) -> None:
    """Flush and stop the background writer."""
    if self._closed:
        return
    self._closed = True
    self._flush_requested.set()
    if self._task is not None:
        await self._task
        self._task = None
enqueue_nowait(doc)

Queue a log event without blocking application logging.

Source code in dcs_simulation_engine/dal/mongo/log_events.py
47
48
49
50
51
52
53
54
55
56
57
def enqueue_nowait(self, doc: dict[str, Any]) -> bool:
    """Queue a log event without blocking application logging."""
    if self._closed or not self._started:
        return False
    try:
        self._queue.put_nowait(doc)
    except asyncio.QueueFull:
        return False
    if self._queue.qsize() >= self._batch_size:
        self._flush_requested.set()
    return True
flush() async

Flush all queued log events.

Source code in dcs_simulation_engine/dal/mongo/log_events.py
59
60
61
62
63
64
async def flush(self) -> None:
    """Flush all queued log events."""
    if not self._started:
        return
    self._flush_requested.set()
    await self._queue.join()
start() async

Start the background batch writer.

Source code in dcs_simulation_engine/dal/mongo/log_events.py
40
41
42
43
44
45
async def start(self) -> None:
    """Start the background batch writer."""
    if self._started:
        return
    self._started = True
    self._task = asyncio.create_task(self._worker_loop())
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
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.SESSIONS].create_index([(MongoColumns.BRANCH_FROM_SESSION_ID, 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.LOGS].create_index(MongoColumns.EVENT_TS)
    db[MongoColumns.LOGS].create_index([("level_no", DESCENDING), (MongoColumns.EVENT_TS, DESCENDING)])
    db[MongoColumns.LOGS].create_index([(MongoColumns.SESSION_ID, ASCENDING), (MongoColumns.EVENT_TS, DESCENDING)], sparse=True)
    db[MongoColumns.LOGS].create_index([("fingerprint", ASCENDING), (MongoColumns.EVENT_TS, DESCENDING)])
    db[MongoColumns.LOGS].create_index([(MongoColumns.SOURCE, ASCENDING), (MongoColumns.EVENT_TS, DESCENDING)])
    db[MongoColumns.ASSIGNMENTS].create_index(MongoColumns.ASSIGNMENT_ID, unique=True)
    db[MongoColumns.ASSIGNMENTS].create_index(
        [
            (MongoColumns.PLAYER_ID, ASCENDING),
            (MongoColumns.UPDATED_AT, DESCENDING),
        ]
    )
    db[MongoColumns.ASSIGNMENTS].create_index(
        [
            (MongoColumns.STATUS, ASCENDING),
            (MongoColumns.UPDATED_AT, DESCENDING),
        ]
    )
    db[MongoColumns.ASSIGNMENTS].create_index(
        [
            (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, 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
 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
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.SESSIONS].create_index(
        [(MongoColumns.BRANCH_FROM_SESSION_ID, 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.LOGS].create_index(MongoColumns.EVENT_TS)
    await db[MongoColumns.LOGS].create_index([("level_no", DESCENDING), (MongoColumns.EVENT_TS, DESCENDING)])
    await db[MongoColumns.LOGS].create_index([(MongoColumns.SESSION_ID, ASCENDING), (MongoColumns.EVENT_TS, DESCENDING)], sparse=True)
    await db[MongoColumns.LOGS].create_index([("fingerprint", ASCENDING), (MongoColumns.EVENT_TS, DESCENDING)])
    await db[MongoColumns.LOGS].create_index([(MongoColumns.SOURCE, ASCENDING), (MongoColumns.EVENT_TS, DESCENDING)])
    await db[MongoColumns.ASSIGNMENTS].create_index(MongoColumns.ASSIGNMENT_ID, unique=True)
    await db[MongoColumns.ASSIGNMENTS].create_index(
        [
            (MongoColumns.PLAYER_ID, ASCENDING),
            (MongoColumns.UPDATED_AT, DESCENDING),
        ]
    )
    await db[MongoColumns.ASSIGNMENTS].create_index(
        [
            (MongoColumns.STATUS, ASCENDING),
            (MongoColumns.UPDATED_AT, DESCENDING),
        ]
    )
    await db[MongoColumns.ASSIGNMENTS].create_index(
        [
            (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, 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
6
7
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
10
11
class GameValidationError(APIRequestError):
    """Raised when a game config fails validation."""

ModelOutputContractError dataclass

Bases: APIRequestError

Raised when a model response does not match the expected payload contract.

Source code in dcs_simulation_engine/errors.py
65
66
67
68
69
70
71
72
73
74
75
76
@dataclass
class ModelOutputContractError(APIRequestError):
    """Raised when a model response does not match the expected payload contract."""

    component: str
    model: str
    detail: str
    raw_response: str | None = None

    def __post_init__(self) -> None:
        """Generate a user-friendly message."""
        super().__init__(f"{self.component} returned invalid model output for {self.model}: {self.detail}")
__post_init__()

Generate a user-friendly message.

Source code in dcs_simulation_engine/errors.py
74
75
76
def __post_init__(self) -> None:
    """Generate a user-friendly message."""
    super().__init__(f"{self.component} returned invalid model output for {self.model}: {self.detail}")

ModelProviderError dataclass

Bases: APIRequestError

Raised when an upstream model provider rejects or cannot serve a request.

Source code in dcs_simulation_engine/errors.py
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
@dataclass
class ModelProviderError(APIRequestError):
    """Raised when an upstream model provider rejects or cannot serve a request."""

    provider: str
    model: str
    status_code: int | None = None
    provider_code: str | None = None
    provider_message: str = ""
    user_message: str = ""
    retryable: bool = False

    def __post_init__(self) -> None:
        """Generate a user-friendly message if one isn't provided."""
        if not self.user_message:
            self.user_message = _default_user_message(
                provider=self.provider,
                model=self.model,
                status_code=self.status_code,
                provider_message=self.provider_message,
            )
        super().__init__(self.user_message)

    def metadata(self) -> dict[str, str | int | None]:
        """Return public structured fields safe to send to API clients."""
        return {
            "provider": self.provider,
            "provider_status_code": self.status_code,
            "provider_code": self.provider_code,
        }
__post_init__()

Generate a user-friendly message if one isn't provided.

Source code in dcs_simulation_engine/errors.py
26
27
28
29
30
31
32
33
34
35
def __post_init__(self) -> None:
    """Generate a user-friendly message if one isn't provided."""
    if not self.user_message:
        self.user_message = _default_user_message(
            provider=self.provider,
            model=self.model,
            status_code=self.status_code,
            provider_message=self.provider_message,
        )
    super().__init__(self.user_message)
metadata()

Return public structured fields safe to send to API clients.

Source code in dcs_simulation_engine/errors.py
37
38
39
40
41
42
43
def metadata(self) -> dict[str, str | int | None]:
    """Return public structured fields safe to send to API clients."""
    return {
        "provider": self.provider,
        "provider_status_code": self.status_code,
        "provider_code": self.provider_code,
    }

games

Built-in game class definitions.

ai_client

Async AI client.

ParsedSimulatorResponse

Bases: NamedTuple

Normalized model response with primary content plus optional metadata.

Source code in dcs_simulation_engine/games/ai_client.py
498
499
500
501
502
503
504
class ParsedSimulatorResponse(NamedTuple):
    """Normalized model response with primary content plus optional metadata."""

    type: str
    content: str
    metadata: dict[str, Any]
    raw_response: str
ScorerClient

One-shot stateless client that executes a rendered scoring prompt.

Source code in dcs_simulation_engine/games/ai_client.py
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
class ScorerClient:
    """One-shot stateless client that executes a rendered scoring prompt."""

    def __init__(self, model: str = DEFAULT_MODEL) -> None:
        """Initialise with model identifier only."""
        self._model = model

    def _parse_score_response(self, raw: str) -> ScorerResult:
        if not isinstance(raw, str):
            raise ModelOutputContractError(
                component="scorer",
                model=self._model,
                detail="response content must be a string",
            )
        stripped = _strip_json_fences(raw)
        parsed = _parse_json_response(raw)
        if parsed.get("type") == "error":
            raise ModelOutputContractError(
                component="scorer",
                model=self._model,
                detail="response was not valid JSON",
                raw_response=raw,
            )
        try:
            result = _normalize_evaluation(parsed)
        except ValueError as exc:
            raise ModelOutputContractError(
                component="scorer",
                model=self._model,
                detail=str(exc),
                raw_response=raw,
            ) from exc
        logger.debug(f"ScorerClient result: {result}")
        return ScorerResult(evaluation=result, raw_json=stripped)

    async def score(self, *, prompt: str, transcript: str) -> ScorerResult:
        """Execute a rendered scoring prompt and return parsed + raw JSON results."""
        if not prompt.strip():
            raise ValueError("Scoring prompt must be non-empty.")
        if not transcript.strip():
            raise ValueError("Scoring transcript must be non-empty.")

        try:
            raw = await _call_openrouter_with_retry([{"role": "user", "content": prompt}], self._model)
            return self._parse_score_response(raw)
        except ModelOutputContractError as exc:
            _log_model_output_contract_failure(exc, operation="scorer", attempt=1, will_retry=True)
            retry_prompt = f"{prompt}\n\n{_contract_retry_instruction('scorer', exc)}"
            try:
                retry_raw = await _call_openrouter_with_retry(
                    [{"role": "user", "content": retry_prompt}],
                    self._model,
                )
                return self._parse_score_response(retry_raw)
            except ModelOutputContractError as retry_exc:
                raise _model_output_provider_error(retry_exc, operation="scorer", attempt=2) from retry_exc
__init__(model=DEFAULT_MODEL)

Initialise with model identifier only.

Source code in dcs_simulation_engine/games/ai_client.py
1138
1139
1140
def __init__(self, model: str = DEFAULT_MODEL) -> None:
    """Initialise with model identifier only."""
    self._model = model
score(*, prompt, transcript) async

Execute a rendered scoring prompt and return parsed + raw JSON results.

Source code in dcs_simulation_engine/games/ai_client.py
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
async def score(self, *, prompt: str, transcript: str) -> ScorerResult:
    """Execute a rendered scoring prompt and return parsed + raw JSON results."""
    if not prompt.strip():
        raise ValueError("Scoring prompt must be non-empty.")
    if not transcript.strip():
        raise ValueError("Scoring transcript must be non-empty.")

    try:
        raw = await _call_openrouter_with_retry([{"role": "user", "content": prompt}], self._model)
        return self._parse_score_response(raw)
    except ModelOutputContractError as exc:
        _log_model_output_contract_failure(exc, operation="scorer", attempt=1, will_retry=True)
        retry_prompt = f"{prompt}\n\n{_contract_retry_instruction('scorer', exc)}"
        try:
            retry_raw = await _call_openrouter_with_retry(
                [{"role": "user", "content": retry_prompt}],
                self._model,
            )
            return self._parse_score_response(retry_raw)
        except ModelOutputContractError as retry_exc:
            raise _model_output_provider_error(retry_exc, operation="scorer", attempt=2) from retry_exc
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
1128
1129
1130
1131
1132
class ScorerResult(NamedTuple):
    """Parsed evaluation payload plus the raw JSON text returned by the scorer."""

    evaluation: dict[str, Any]
    raw_json: str
SimulatorClient

Thin orchestrator around configurable validation and simulator advancement.

Source code in dcs_simulation_engine/games/ai_client.py
 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
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
class SimulatorClient:
    """Thin orchestrator around configurable validation and simulator advancement."""

    _OPENING_SCENE_PREFIX = "Opening scene: "

    def __init__(
        self,
        *,
        pc: CharacterRecord,
        npc: CharacterRecord,
        player_turn_validators: list[str] | None = None,
        simulator_turn_validators: list[str] | None = None,
        opener_template: str | None = None,
        updater_template: str | None = None,
        opener_model: str = DEFAULT_MODEL,
        updater_model: str = DEFAULT_MODEL,
        validator_model: str = DEFAULT_MODEL,
    ) -> None:
        """Initialize a simulator client with characters, prompts, validators, and models."""
        self._pc = pc
        self._npc = npc
        self._history: list[str] = []
        self._transcript_events: list[str] = []
        self._opening_metadata: dict[str, Any] = {}
        self._opening_scenes: list[str] = []

        self._player_turn_validators = (
            list(player_turn_validators) if player_turn_validators is not None else list(DEFAULT_PLAYER_TURN_VALIDATORS)
        )
        self._simulator_turn_validators = (
            list(simulator_turn_validators) if simulator_turn_validators is not None else list(DEFAULT_SIMULATOR_TURN_VALIDATORS)
        )

        self._opener_template = opener_template or OPENER
        self._updater_template = updater_template or UPDATER

        self._opener_model = opener_model
        self._updater_model = updater_model
        self._validator_model = validator_model
        self._recorder: "ValidationEventRecorder | None" = None
        self._turn_index: Callable[[], int] = lambda: 0

    @property
    def scene_opener_model(self) -> str:
        """Return the scene-opener model identifier for metadata recording."""
        return self._opener_model

    @property
    def updater_model(self) -> str:
        """Return the updater model identifier for metadata recording."""
        return self._updater_model

    @property
    def validator_model(self) -> str:
        """Return the validator model identifier for metadata recording."""
        return self._validator_model

    def export_history(self) -> list[str]:
        """Return a JSON-serialisable copy of the conversation history."""
        return list(self._history)

    def import_history(self, history: list[str]) -> None:
        """Restore conversation history from a snapshot."""
        self._history = list(history)
        self._transcript_events = list(history)
        self._opening_scenes = self._derive_opening_scenes(self._history)

    def export_state(self) -> dict[str, Any]:
        """Return all mutable simulator state needed for pause/resume."""
        return {
            "history": list(self._history),
            "transcript_events": list(self._transcript_events),
            "opening_metadata": dict(self._opening_metadata),
            "opening_scenes": list(self._opening_scenes),
        }

    def import_state(self, state: dict[str, Any]) -> None:
        """Restore mutable simulator state from a snapshot."""
        history = state.get("history", [])
        transcript_events = state.get("transcript_events", history)
        opening_metadata = state.get("opening_metadata", {})
        opening_scenes = state.get("opening_scenes")

        self._history = [str(entry) for entry in history] if isinstance(history, list) else []
        if isinstance(transcript_events, list):
            self._transcript_events = [str(entry) for entry in transcript_events]
        else:
            self._transcript_events = list(self._history)
        self._opening_metadata = dict(opening_metadata) if isinstance(opening_metadata, dict) else {}
        if isinstance(opening_scenes, list):
            self._opening_scenes = [str(scene) for scene in opening_scenes if str(scene).strip()]
        else:
            self._opening_scenes = self._derive_opening_scenes(self._history)

    @classmethod
    def _derive_opening_scenes(cls, history: list[str]) -> list[str]:
        """Recover opening scene text from legacy history-only snapshots."""
        scenes: list[str] = []
        for entry in history:
            if entry.startswith(cls._OPENING_SCENE_PREFIX):
                scene = entry.removeprefix(cls._OPENING_SCENE_PREFIX).strip()
                if scene:
                    scenes.append(scene)
        return scenes

    @property
    def player_turn_validators(self) -> list[str]:
        """Configured player-turn validators for this simulator instance."""
        return list(self._player_turn_validators)

    @property
    def simulator_turn_validators(self) -> list[str]:
        """Configured simulator-turn validators for this simulator instance."""
        return list(self._simulator_turn_validators)

    def attach_recorder(self, recorder: "ValidationEventRecorder", turn_index_provider: Callable[[], int]) -> None:
        """Attach a validation recorder and turn-index provider."""
        self._recorder = recorder
        self._turn_index = turn_index_provider

    def _transcript_context(self) -> str:
        if not self._transcript_events:
            return "[No prior scene context]"
        return "\n".join(self._transcript_events[-12:])

    def _validator_transcript(self, user_input: str) -> str:
        base = self._transcript_context()
        pending_turn = f"Player ({self._pc.hid}): {user_input}"
        return pending_turn if base == "[No prior scene context]" else f"{base}\n{pending_turn}"

    def _game_objective(self) -> str:
        shared_goal = self._opening_metadata.get("shared_goal")
        if isinstance(shared_goal, str) and shared_goal.strip():
            return shared_goal.strip()
        return ""

    @staticmethod
    def _validator_name(template: str, *, fallback: str) -> str:
        for line in template.splitlines():
            stripped = line.strip()
            if stripped.startswith("RULE:"):
                return stripped.removeprefix("RULE:").strip()
        for line in template.splitlines():
            stripped = line.strip()
            if stripped:
                return stripped[:80]
        return fallback

    async def _call_json_prompt(
        self,
        *,
        system_prompt: str,
        user_input: str | None,
        model: str,
        component: str,
        corrective_feedback: str | None = None,
    ) -> ParsedSimulatorResponse:
        """Execute a prompt and return normalized content plus optional metadata."""
        messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_input or "Begin."}]
        if corrective_feedback is not None:
            messages.append({"role": "user", "content": corrective_feedback})
        raw = await _call_openrouter_with_retry(messages, model)
        if not isinstance(raw, str):
            raise ModelOutputContractError(
                component=component,
                model=model,
                detail="response content must be a string",
            )
        parsed = _parse_json_response(raw)
        if parsed.get("type") == "error":
            raise ModelOutputContractError(
                component=component,
                model=model,
                detail="response was not valid JSON",
                raw_response=raw,
            )
        response_type = parsed.get("type", "ai")
        content = parsed.get("content")
        if not isinstance(response_type, str) or response_type not in {"ai", "info", "warning"}:
            raise ModelOutputContractError(
                component=component,
                model=model,
                detail="response type must be one of: ai, info, warning",
                raw_response=raw,
            )
        if not isinstance(content, str) or not content.strip():
            raise ModelOutputContractError(
                component=component,
                model=model,
                detail="response content must be a non-empty string",
                raw_response=raw,
            )
        return ParsedSimulatorResponse(
            type=response_type,
            content=content,
            metadata=_extract_response_metadata(parsed),
            raw_response=raw,
        )

    def _build_opening_scene_prompt(self) -> str:
        """Render the configured opening-scene template."""
        return build_opener_prompt(
            self._pc,
            self._npc,
            template=self._opener_template,
            excluded_scenes=self._opening_scenes,
        )

    def _build_updater_prompt(self, *, user_input: str) -> str:
        """Render the configured simulator updater prompt."""
        return build_updater_prompt(
            self._pc,
            self._npc,
            game_objective=self._game_objective(),
            transcript=self._transcript_context(),
            player_action=user_input,
            template=self._updater_template,
        )

    def _build_player_validator_prompt(self, *, validator_template: str, user_input: str) -> str:
        """Render one player-turn validator prompt."""
        return build_player_validator_prompt(
            self._pc,
            self._npc,
            player_action=user_input,
            transcript=self._transcript_context(),
            validator_template=validator_template,
        )

    def _build_simulator_validator_prompt(
        self,
        *,
        validator_template: str,
        user_input: str,
        simulator_response: str,
    ) -> str:
        """Render one simulator-response validator prompt."""
        return build_simulator_validator_prompt(
            self._pc,
            self._npc,
            simulator_response=simulator_response,
            transcript=self._validator_transcript(user_input),
            game_objective=self._game_objective(),
            validator_template=validator_template,
        )

    async def _run_validator_once(self, system_prompt: str, *, corrective_feedback: str | None = None) -> dict[str, Any]:
        messages = [{"role": "system", "content": system_prompt}]
        if corrective_feedback is not None:
            messages.append({"role": "user", "content": corrective_feedback})
        raw = await _call_openrouter_with_retry(messages, self._validator_model)
        if not isinstance(raw, str):
            raise ModelOutputContractError(
                component="validator",
                model=self._validator_model,
                detail="response content must be a string",
            )
        result = _parse_json_response(raw)
        if result.get("type") == "error":
            raise ModelOutputContractError(
                component="validator",
                model=self._validator_model,
                detail="response was not valid JSON",
                raw_response=raw,
            )
        if not isinstance(result.get("pass"), bool):
            raise ModelOutputContractError(
                component="validator",
                model=self._validator_model,
                detail="response must include a boolean pass field",
                raw_response=raw,
            )
        return result

    async def _run_validator(self, system_prompt: str) -> dict[str, Any]:
        try:
            return await self._run_validator_once(system_prompt)
        except ModelProviderError:
            raise
        except ModelOutputContractError as exc:
            _log_model_output_contract_failure(exc, operation="validator", attempt=1, will_retry=True)
            try:
                return await self._run_validator_once(
                    system_prompt,
                    corrective_feedback=_contract_retry_instruction("validator", exc),
                )
            except ModelOutputContractError as retry_exc:
                raise _model_output_provider_error(retry_exc, operation="validator", attempt=2) from retry_exc

    async def _run_player_validator(self, validator_template: str, user_input: str) -> tuple[str, dict[str, Any]]:
        """Execute one configured player-turn validator."""
        validator_name = self._validator_name(validator_template, fallback="player validator")
        return validator_name, await self._run_validator(
            self._build_player_validator_prompt(validator_template=validator_template, user_input=user_input)
        )

    async def _generate_simulator_response(
        self,
        *,
        user_input: str,
        corrective_feedback: str | None = None,
    ) -> ParsedSimulatorResponse:
        """Generate the next immediate simulator response."""
        response = await self._call_json_prompt(
            system_prompt=self._build_updater_prompt(user_input=user_input),
            user_input=user_input,
            model=self._updater_model,
            component="updater",
            corrective_feedback=corrective_feedback,
        )
        return response

    async def _generate_simulator_response_with_retry(self, *, user_input: str) -> ParsedSimulatorResponse:
        """Generate a simulator response, retrying one model-output contract failure."""
        try:
            return await self._generate_simulator_response(user_input=user_input)
        except ModelProviderError:
            raise
        except ModelOutputContractError as exc:
            _log_model_output_contract_failure(exc, operation="updater", attempt=1, will_retry=True)
            try:
                return await self._generate_simulator_response(
                    user_input=user_input,
                    corrective_feedback=_contract_retry_instruction("updater", exc),
                )
            except ModelOutputContractError as retry_exc:
                raise _model_output_provider_error(retry_exc, operation="updater", attempt=2) from retry_exc

    @staticmethod
    def _validation_error(result: dict[str, Any], *, default_message: str) -> str | None:
        if result.get("pass") is False:
            return str(result.get("reason") or result.get("content") or default_message)
        return None

    async def _collect_player_validation_failures(self, user_input: str) -> list[SimulatorValidationFailure]:
        """Run all configured player validators concurrently and return failures only."""
        validator_tasks = [
            asyncio.create_task(self._run_player_validator(validator_template, user_input))
            for validator_template in self._player_turn_validators
        ]
        failures: list[SimulatorValidationFailure] = []
        try:
            for task in asyncio.as_completed(validator_tasks):
                validator_name, result = await task
                logger.debug(
                    "Player validator result: {} pass={} result={}",
                    validator_name,
                    result.get("pass"),
                    result,
                )
                error_message = self._validation_error(result, default_message="Invalid action.")
                if error_message is not None:
                    logger.info(f"Player validation failed: {validator_name} - {error_message}")
                    failures.append(
                        SimulatorValidationFailure(
                            stage="player_validation",
                            validator_name=validator_name,
                            message=error_message,
                            raw_result=result,
                        )
                    )
                    for pending_task in validator_tasks:
                        if not pending_task.done():
                            pending_task.cancel()
                    break
        except Exception:
            for pending_task in validator_tasks:
                if not pending_task.done():
                    pending_task.cancel()
            raise
        finally:
            await asyncio.gather(*validator_tasks, return_exceptions=True)

        return failures

    async def _record_validation_failures(
        self,
        *,
        failures: list[SimulatorValidationFailure],
        event_source: str,
        response: str,
    ) -> None:
        """Persist validation failures when a recorder is attached."""
        if self._recorder is None or not failures:
            return

        turn_index = self._turn_index()
        for failure in failures:
            await self._recorder.record_violation(
                event_source=event_source,
                validator_name=failure.validator_name,
                stage=failure.stage,
                message=failure.message,
                response=response,
                raw_result=failure.raw_result,
                turn_index=turn_index,
            )

    async def _validate_simulator_response(
        self,
        *,
        user_input: str,
        simulator_response: str,
    ) -> list[SimulatorValidationFailure]:
        """Validate one generated simulator response against its configured validator ensemble."""
        validator_tasks = [
            asyncio.create_task(
                self._run_simulator_validator(
                    validator_template,
                    user_input=user_input,
                    simulator_response=simulator_response,
                    fallback=f"simulator validator {index}",
                )
            )
            for index, validator_template in enumerate(self._simulator_turn_validators, start=1)
        ]
        failures: list[SimulatorValidationFailure] = []
        try:
            for task in asyncio.as_completed(validator_tasks):
                validator_name, result = await task
                logger.debug(
                    "Simulator validator result: {} pass={} result={}",
                    validator_name,
                    result.get("pass"),
                    result,
                )
                error_message = self._validation_error(result, default_message="Invalid simulator response.")
                if error_message is not None:
                    logger.info(f"Simulator validation failed: {validator_name} - {error_message}")
                    failures.append(
                        SimulatorValidationFailure(
                            stage="simulator_validation",
                            validator_name=validator_name,
                            message=error_message,
                            raw_result=result,
                        )
                    )
                    for pending_task in validator_tasks:
                        if not pending_task.done():
                            pending_task.cancel()
                    break
        finally:
            await asyncio.gather(*validator_tasks, return_exceptions=True)

        return failures

    async def _run_simulator_validator(
        self,
        validator_template: str,
        *,
        user_input: str,
        simulator_response: str,
        fallback: str,
    ) -> tuple[str, dict[str, Any]]:
        """Execute one configured simulator-response validator."""
        validator_name = self._validator_name(validator_template, fallback=fallback)
        result = await self._run_validator(
            self._build_simulator_validator_prompt(
                validator_template=validator_template,
                user_input=user_input,
                simulator_response=simulator_response,
            )
        )
        return validator_name, result

    async def _cancel_tasks(self, *tasks: asyncio.Task[Any]) -> None:
        """Cancel unfinished tasks and absorb cancellation errors."""
        for task in tasks:
            if not task.done():
                task.cancel()
        await asyncio.gather(*tasks, return_exceptions=True)

    async def _run_updater_with_retry(
        self,
        *,
        user_input: str,
        initial_response: ParsedSimulatorResponse,
    ) -> SimulatorComponentResult:
        """Validate the updater output and retry once if validators fail."""
        response = initial_response
        retries_used = 0
        failures = await self._validate_simulator_response(
            user_input=user_input,
            simulator_response=response.content,
        )
        await self._record_validation_failures(
            failures=failures,
            event_source="simulator_validation",
            response=response.content,
        )
        if failures:
            retries_used = 1
            response = await self._generate_simulator_response_with_retry(user_input=user_input)
            failures = await self._validate_simulator_response(
                user_input=user_input,
                simulator_response=response.content,
            )
            await self._record_validation_failures(
                failures=failures,
                event_source="simulator_validation",
                response=response.content,
            )

        return SimulatorComponentResult(
            name="updater",
            content=response.content,
            ok=not failures,
            metadata=response.metadata,
            retries_used=retries_used,
            validation_failures=failures,
            raw_response=response.raw_response,
        )

    async def chat(self, user_input: str | None) -> ParsedSimulatorResponse:
        """Generate the opening scene without player-input validation."""
        try:
            opening = await self._call_json_prompt(
                system_prompt=self._build_opening_scene_prompt(),
                user_input=user_input,
                model=self._opener_model,
                component="opener",
            )
        except ModelOutputContractError as exc:
            _log_model_output_contract_failure(exc, operation="opener", attempt=1, will_retry=True)
            try:
                opening = await self._call_json_prompt(
                    system_prompt=self._build_opening_scene_prompt(),
                    user_input=user_input,
                    model=self._opener_model,
                    component="opener",
                    corrective_feedback=_contract_retry_instruction("opener", exc),
                )
            except ModelOutputContractError as retry_exc:
                raise _model_output_provider_error(retry_exc, operation="opener", attempt=2) from retry_exc
        self._opening_metadata = dict(opening.metadata)
        self._opening_scenes.append(opening.content)
        self._history.append(f"Opening scene: {opening.content}")
        self._transcript_events.append(f"Opening scene: {opening.content}")
        return opening

    async def step(self, user_input: str) -> SimulatorTurnResult:
        """Validate player input, then generate and validate one simulator response."""
        player_validation_task = asyncio.create_task(self._collect_player_validation_failures(user_input))
        updater_generation_task = asyncio.create_task(self._generate_simulator_response_with_retry(user_input=user_input))

        try:
            player_validation_failures = await player_validation_task
        except ModelProviderError:
            await self._cancel_tasks(updater_generation_task)
            raise
        except Exception:
            await self._cancel_tasks(updater_generation_task)
            logger.exception("Player validation failed due to LLM/runtime error.")
            return SimulatorTurnResult(
                ok=False,
                error_message="The simulation engine hit an internal problem while validating the action.",
                failure_type=INTERNAL_ERROR,
            )

        if player_validation_failures:
            await self._cancel_tasks(updater_generation_task)
            await self._record_validation_failures(
                failures=player_validation_failures,
                event_source="player_validation",
                response=user_input,
            )
            return SimulatorTurnResult(
                ok=False,
                error_message=player_validation_failures[0].message,
                failure_type=PLAYER_TURN_VALIDATION_FAILED,
                pc_validation_failures=player_validation_failures,
            )

        try:
            initial_response = await updater_generation_task
            updater_result = await self._run_updater_with_retry(
                user_input=user_input,
                initial_response=initial_response,
            )
        except ModelProviderError:
            raise
        except Exception:
            logger.exception("Simulator updater failed due to LLM/runtime error.")
            return SimulatorTurnResult(
                ok=False,
                error_message="The simulation engine hit an internal problem while producing a simulator response.",
                failure_type=INTERNAL_ERROR,
                pc_validation_failures=player_validation_failures,
            )

        if not updater_result.ok:
            return SimulatorTurnResult(
                ok=False,
                error_message="I couldn't produce a valid simulator response. Please retry your action.",
                failure_type=SIMULATOR_TURN_VALIDATION_RETRY_EXHAUSTED,
                simulator_response="",
                pc_validation_failures=player_validation_failures,
                updater_result=updater_result,
            )

        self._transcript_events.extend(
            [
                f"Player ({self._pc.hid}): {user_input}",
                f"Simulator: {updater_result.content}",
            ]
        )
        self._history.extend(
            [
                f"Player ({self._pc.hid}): {user_input}",
                f"Simulator: {updater_result.content}",
            ]
        )
        logger.debug(f"SimulatorClient simulator reply ({len(updater_result.content)} chars)")
        return SimulatorTurnResult(
            ok=True,
            simulator_response=updater_result.content,
            pc_validation_failures=player_validation_failures,
            updater_result=updater_result,
        )
player_turn_validators property

Configured player-turn validators for this simulator instance.

scene_opener_model property

Return the scene-opener model identifier for metadata recording.

simulator_turn_validators property

Configured simulator-turn validators for this simulator instance.

updater_model property

Return the updater model identifier for metadata recording.

validator_model property

Return the validator model identifier for metadata recording.

__init__(*, pc, npc, player_turn_validators=None, simulator_turn_validators=None, opener_template=None, updater_template=None, opener_model=DEFAULT_MODEL, updater_model=DEFAULT_MODEL, validator_model=DEFAULT_MODEL)

Initialize a simulator client with characters, prompts, validators, and models.

Source code in dcs_simulation_engine/games/ai_client.py
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
def __init__(
    self,
    *,
    pc: CharacterRecord,
    npc: CharacterRecord,
    player_turn_validators: list[str] | None = None,
    simulator_turn_validators: list[str] | None = None,
    opener_template: str | None = None,
    updater_template: str | None = None,
    opener_model: str = DEFAULT_MODEL,
    updater_model: str = DEFAULT_MODEL,
    validator_model: str = DEFAULT_MODEL,
) -> None:
    """Initialize a simulator client with characters, prompts, validators, and models."""
    self._pc = pc
    self._npc = npc
    self._history: list[str] = []
    self._transcript_events: list[str] = []
    self._opening_metadata: dict[str, Any] = {}
    self._opening_scenes: list[str] = []

    self._player_turn_validators = (
        list(player_turn_validators) if player_turn_validators is not None else list(DEFAULT_PLAYER_TURN_VALIDATORS)
    )
    self._simulator_turn_validators = (
        list(simulator_turn_validators) if simulator_turn_validators is not None else list(DEFAULT_SIMULATOR_TURN_VALIDATORS)
    )

    self._opener_template = opener_template or OPENER
    self._updater_template = updater_template or UPDATER

    self._opener_model = opener_model
    self._updater_model = updater_model
    self._validator_model = validator_model
    self._recorder: "ValidationEventRecorder | None" = None
    self._turn_index: Callable[[], int] = lambda: 0
attach_recorder(recorder, turn_index_provider)

Attach a validation recorder and turn-index provider.

Source code in dcs_simulation_engine/games/ai_client.py
622
623
624
625
def attach_recorder(self, recorder: "ValidationEventRecorder", turn_index_provider: Callable[[], int]) -> None:
    """Attach a validation recorder and turn-index provider."""
    self._recorder = recorder
    self._turn_index = turn_index_provider
chat(user_input) async

Generate the opening scene without player-input validation.

Source code in dcs_simulation_engine/games/ai_client.py
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
async def chat(self, user_input: str | None) -> ParsedSimulatorResponse:
    """Generate the opening scene without player-input validation."""
    try:
        opening = await self._call_json_prompt(
            system_prompt=self._build_opening_scene_prompt(),
            user_input=user_input,
            model=self._opener_model,
            component="opener",
        )
    except ModelOutputContractError as exc:
        _log_model_output_contract_failure(exc, operation="opener", attempt=1, will_retry=True)
        try:
            opening = await self._call_json_prompt(
                system_prompt=self._build_opening_scene_prompt(),
                user_input=user_input,
                model=self._opener_model,
                component="opener",
                corrective_feedback=_contract_retry_instruction("opener", exc),
            )
        except ModelOutputContractError as retry_exc:
            raise _model_output_provider_error(retry_exc, operation="opener", attempt=2) from retry_exc
    self._opening_metadata = dict(opening.metadata)
    self._opening_scenes.append(opening.content)
    self._history.append(f"Opening scene: {opening.content}")
    self._transcript_events.append(f"Opening scene: {opening.content}")
    return opening
export_history()

Return a JSON-serialisable copy of the conversation history.

Source code in dcs_simulation_engine/games/ai_client.py
564
565
566
def export_history(self) -> list[str]:
    """Return a JSON-serialisable copy of the conversation history."""
    return list(self._history)
export_state()

Return all mutable simulator state needed for pause/resume.

Source code in dcs_simulation_engine/games/ai_client.py
574
575
576
577
578
579
580
581
def export_state(self) -> dict[str, Any]:
    """Return all mutable simulator state needed for pause/resume."""
    return {
        "history": list(self._history),
        "transcript_events": list(self._transcript_events),
        "opening_metadata": dict(self._opening_metadata),
        "opening_scenes": list(self._opening_scenes),
    }
import_history(history)

Restore conversation history from a snapshot.

Source code in dcs_simulation_engine/games/ai_client.py
568
569
570
571
572
def import_history(self, history: list[str]) -> None:
    """Restore conversation history from a snapshot."""
    self._history = list(history)
    self._transcript_events = list(history)
    self._opening_scenes = self._derive_opening_scenes(self._history)
import_state(state)

Restore mutable simulator state from a snapshot.

Source code in dcs_simulation_engine/games/ai_client.py
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
def import_state(self, state: dict[str, Any]) -> None:
    """Restore mutable simulator state from a snapshot."""
    history = state.get("history", [])
    transcript_events = state.get("transcript_events", history)
    opening_metadata = state.get("opening_metadata", {})
    opening_scenes = state.get("opening_scenes")

    self._history = [str(entry) for entry in history] if isinstance(history, list) else []
    if isinstance(transcript_events, list):
        self._transcript_events = [str(entry) for entry in transcript_events]
    else:
        self._transcript_events = list(self._history)
    self._opening_metadata = dict(opening_metadata) if isinstance(opening_metadata, dict) else {}
    if isinstance(opening_scenes, list):
        self._opening_scenes = [str(scene) for scene in opening_scenes if str(scene).strip()]
    else:
        self._opening_scenes = self._derive_opening_scenes(self._history)
step(user_input) async

Validate player input, then generate and validate one simulator response.

Source code in dcs_simulation_engine/games/ai_client.py
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
async def step(self, user_input: str) -> SimulatorTurnResult:
    """Validate player input, then generate and validate one simulator response."""
    player_validation_task = asyncio.create_task(self._collect_player_validation_failures(user_input))
    updater_generation_task = asyncio.create_task(self._generate_simulator_response_with_retry(user_input=user_input))

    try:
        player_validation_failures = await player_validation_task
    except ModelProviderError:
        await self._cancel_tasks(updater_generation_task)
        raise
    except Exception:
        await self._cancel_tasks(updater_generation_task)
        logger.exception("Player validation failed due to LLM/runtime error.")
        return SimulatorTurnResult(
            ok=False,
            error_message="The simulation engine hit an internal problem while validating the action.",
            failure_type=INTERNAL_ERROR,
        )

    if player_validation_failures:
        await self._cancel_tasks(updater_generation_task)
        await self._record_validation_failures(
            failures=player_validation_failures,
            event_source="player_validation",
            response=user_input,
        )
        return SimulatorTurnResult(
            ok=False,
            error_message=player_validation_failures[0].message,
            failure_type=PLAYER_TURN_VALIDATION_FAILED,
            pc_validation_failures=player_validation_failures,
        )

    try:
        initial_response = await updater_generation_task
        updater_result = await self._run_updater_with_retry(
            user_input=user_input,
            initial_response=initial_response,
        )
    except ModelProviderError:
        raise
    except Exception:
        logger.exception("Simulator updater failed due to LLM/runtime error.")
        return SimulatorTurnResult(
            ok=False,
            error_message="The simulation engine hit an internal problem while producing a simulator response.",
            failure_type=INTERNAL_ERROR,
            pc_validation_failures=player_validation_failures,
        )

    if not updater_result.ok:
        return SimulatorTurnResult(
            ok=False,
            error_message="I couldn't produce a valid simulator response. Please retry your action.",
            failure_type=SIMULATOR_TURN_VALIDATION_RETRY_EXHAUSTED,
            simulator_response="",
            pc_validation_failures=player_validation_failures,
            updater_result=updater_result,
        )

    self._transcript_events.extend(
        [
            f"Player ({self._pc.hid}): {user_input}",
            f"Simulator: {updater_result.content}",
        ]
    )
    self._history.extend(
        [
            f"Player ({self._pc.hid}): {user_input}",
            f"Simulator: {updater_result.content}",
        ]
    )
    logger.debug(f"SimulatorClient simulator reply ({len(updater_result.content)} chars)")
    return SimulatorTurnResult(
        ok=True,
        simulator_response=updater_result.content,
        pc_validation_failures=player_validation_failures,
        updater_result=updater_result,
    )
SimulatorComponentResult dataclass

Generation and validation metadata for one simulator output component.

Source code in dcs_simulation_engine/games/ai_client.py
473
474
475
476
477
478
479
480
481
482
483
@dataclass(frozen=True)
class SimulatorComponentResult:
    """Generation and validation metadata for one simulator output component."""

    name: str
    content: str
    ok: bool
    metadata: dict[str, Any] = field(default_factory=dict)
    retries_used: int = 0
    validation_failures: list[SimulatorValidationFailure] = field(default_factory=list)
    raw_response: str = ""
SimulatorTurnResult dataclass

Structured result for one attempted simulator turn.

Source code in dcs_simulation_engine/games/ai_client.py
486
487
488
489
490
491
492
493
494
495
@dataclass(frozen=True)
class SimulatorTurnResult:
    """Structured result for one attempted simulator turn."""

    ok: bool
    error_message: str | None = None
    failure_type: str | None = None
    simulator_response: str = ""
    pc_validation_failures: list[SimulatorValidationFailure] = field(default_factory=list)
    updater_result: SimulatorComponentResult | None = None
SimulatorValidationFailure dataclass

Normalized validation failure detail for a specific stage and validator.

Source code in dcs_simulation_engine/games/ai_client.py
463
464
465
466
467
468
469
470
@dataclass(frozen=True)
class SimulatorValidationFailure:
    """Normalized validation failure detail for a specific stage and validator."""

    stage: str
    validator_name: str
    message: str
    raw_result: dict[str, Any]
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
47
48
49
50
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
53
54
55
56
57
58
59
60
61
62
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

Constants for the games module.

Explore

String constants for the Explore game.

Source code in dcs_simulation_engine/games/const.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
class Explore:
    """String constants for the Explore game."""

    HELP_CONTENT = """\
**Player Character (you):** {pc_hid} ({pc_short_description})

**Simulator Character:** {npc_hid} ({npc_short_description})

---

**Player Objective:** No objective; open-ended.

**How to Play:** Describe your next action in first person (e.g. I <action>)) as {pc_hid}.

**How to Finish:** Type `/finish` to complete the game.

---

- Type `/abilities` for character abilities.
- Type `/help` at any time to see this message again.\
"""

    ABILITIES_CONTENT = DEFAULT_ABILITIES_CONTENT

    FINISH_CONTENT = DEFAULT_FINISH_CONTENT
Foresight

String constants for the Foresight game.

Source code in dcs_simulation_engine/games/const.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
class Foresight:
    """String constants for the Foresight game."""

    HELP_CONTENT = """\
**Player Character (you):** {pc_hid} ({pc_short_description})

**Simulator Character:** {npc_hid} ({npc_short_description})

---

**Player Objective:** Predict {npc_hid}'s response to your character's next action for each turn.

**How to Play:** Describe your next action in first person and how you think {npc_hid} will respond (e.g. "I <{pc_hid}'s action> and predict {npc_hid} will <{npc_hid}'s response>.").

**How to Finish:** Type `/finish` to get all of your predictions scored and complete the game.

---

- Type `/abilities` for character abilities.
- Type `/help` at any time to see this message again.\
"""

    ABILITIES_CONTENT = DEFAULT_ABILITIES_CONTENT

    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 = DEFAULT_FINISH_CONTENT
GoalHorizon

String constants for the Goal Horizon game.

Source code in dcs_simulation_engine/games/const.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
class GoalHorizon:
    """String constants for the Goal Horizon game."""

    HELP_CONTENT = """\
**Player Character (you):** {pc_hid} ({pc_short_description})

**Simulator Character:** {npc_hid} ({npc_short_description})

---

**Player Objective:** Interact with {npc_hid} over multiple scenarios until you understand upper bounds of the largest goals they are capable of pursuing. For example, can self-regulate? Can they modify their environment?Can they design and solve problems in abstract spaces?

**How to Play:** Describe your next action in first person (e.g. I <action>)) as {pc_hid}.

**How to Finish:** Type `/finish` to submit your prediction about the types of goals {npc_hid} is capable of pursuing, get scored, and complete the game.

---

- Type `/new-scene` to start a new scene.
- Type `/abilities` for character abilities.
- Type `/help` at any time to see this message again.\
"""

    ABILITIES_CONTENT = DEFAULT_ABILITIES_CONTENT

    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 = DEFAULT_FINISH_CONTENT
InferIntent

String constants for the Infer Intent game.

Source code in dcs_simulation_engine/games/const.py
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
class InferIntent:
    """String constants for the Infer Intent game."""

    HELP_CONTENT = """\
**Player Character (you):** {pc_hid} ({pc_short_description})

**Simulator Character:** {npc_hid} ({npc_short_description})

---

**Player Objective:** Interact with {npc_hid} in a scenario to understand their intention or goal.

**How to Play:** Describe your next action in first person (e.g. I <action>)) as {pc_hid}.

**How to Finish:** Type `/finish` to submit your prediction about {npc_hid}'s intention, get scored, and complete the game.

---

- Type `/abilities` for character abilities.
- Type `/help` at any time to see this message again.\
"""

    ABILITIES_CONTENT = DEFAULT_ABILITIES_CONTENT

    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 = DEFAULT_FINISH_CONTENT
Teamwork

String constants for the Teamwork game.

Source code in dcs_simulation_engine/games/const.py
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
class Teamwork:
    """String constants for the Teamwork game."""

    HELP_CONTENT = """\
**Player Character (you):** {pc_hid} ({pc_short_description})

**Simulator Character:** {npc_hid} ({npc_short_description})

---

**Player Objective:** Collaborate with {npc_hid} to achieve the shared goal: {shared_goal}

**How to Play:** Describe your next action in first person (e.g. I <action>)) as {pc_hid}.

**How to Finish:** Type `/finish` to complete the game and get scored.

---

- Type `/abilities` for character abilities.
- Type `/help` at any time to see this message again.\
"""

    ABILITIES_CONTENT = DEFAULT_ABILITIES_CONTENT

    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 = DEFAULT_FINISH_CONTENT

explore

Explore game.

ExploreGame

Bases: Game

Free-form exploration game: player describes actions, NPC reacts, no predefined goals.

Source code in dcs_simulation_engine/games/explore.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
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 ExploreGame(Game):
    """Free-form exploration game: player describes actions, NPC reacts, no predefined goals."""

    GAME_NAME = "Explore"
    GAME_DESCRIPTION = "Players are given no tasks -- an open-ended playground."

    class Overrides(Game.Overrides):
        """Run-config-overridable parameters for ExploreGame."""

        pass  # no additional overrides

    @classmethod
    def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "ExploreGame":
        """Factory called by SessionManager."""
        overrides = cls.parse_overrides(kwargs)
        engine = SimulatorClient(pc=pc, npc=npc)
        return cls(
            pc=pc,
            npc=npc,
            engine=engine,
            **cls.build_base_init_kwargs(overrides),
        )

    def get_help_content(self) -> str:
        """Return the /help message content."""
        return C.HELP_CONTENT.format(
            pc_hid=self._pc.hid,
            pc_short_description=self._pc.short_description.lower(),
            npc_hid=self._npc.hid,
            npc_short_description=self._npc.short_description.lower(),
        )

    def get_abilities_content(self) -> str:
        """Return the /abilities message content."""
        return 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", "")),
        )

    async def on_finish(self) -> AsyncIterator[GameEvent]:
        """Exit immediately and emit a closing message."""
        self.exit("player finished")
        yield GameEvent.now(
            type="info",
            content=C.FINISH_CONTENT.format(finish_reason="player finished"),
            command_response=True,
        )
Overrides

Bases: Overrides

Run-config-overridable parameters for ExploreGame.

Source code in dcs_simulation_engine/games/explore.py
18
19
20
21
class Overrides(Game.Overrides):
    """Run-config-overridable parameters for ExploreGame."""

    pass  # no additional overrides
create_from_context(pc, npc, **kwargs) classmethod

Factory called by SessionManager.

Source code in dcs_simulation_engine/games/explore.py
23
24
25
26
27
28
29
30
31
32
33
@classmethod
def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "ExploreGame":
    """Factory called by SessionManager."""
    overrides = cls.parse_overrides(kwargs)
    engine = SimulatorClient(pc=pc, npc=npc)
    return cls(
        pc=pc,
        npc=npc,
        engine=engine,
        **cls.build_base_init_kwargs(overrides),
    )
get_abilities_content()

Return the /abilities message content.

Source code in dcs_simulation_engine/games/explore.py
44
45
46
47
48
49
50
51
52
53
def get_abilities_content(self) -> str:
    """Return the /abilities message content."""
    return 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", "")),
    )
get_help_content()

Return the /help message content.

Source code in dcs_simulation_engine/games/explore.py
35
36
37
38
39
40
41
42
def get_help_content(self) -> str:
    """Return the /help message content."""
    return C.HELP_CONTENT.format(
        pc_hid=self._pc.hid,
        pc_short_description=self._pc.short_description.lower(),
        npc_hid=self._npc.hid,
        npc_short_description=self._npc.short_description.lower(),
    )
on_finish() async

Exit immediately and emit a closing message.

Source code in dcs_simulation_engine/games/explore.py
55
56
57
58
59
60
61
62
async def on_finish(self) -> AsyncIterator[GameEvent]:
    """Exit immediately and emit a closing message."""
    self.exit("player finished")
    yield GameEvent.now(
        type="info",
        content=C.FINISH_CONTENT.format(finish_reason="player finished"),
        command_response=True,
    )

foresight

Foresight game.

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
 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
class ForesightGame(Game):
    """Foresight game: player interacts with NPC and makes predictions embedded in their actions."""

    GAME_NAME = "Foresight"
    GAME_DESCRIPTION = "Players are tasked with predicting the next action of a character."

    # This game required players to describe their PC action but also how they expect the NPC to respond for each turn, so we need to remove the validators that enforce that players only describe their own character's actions. Instead, we use a validator that required the PC action and optional NPC prediction.
    # PLAYER_TURN_VALIDATORS = []

    DEFAULT_PCS_FILTER: CharacterFilter = get_character_filter("human-normative")

    class Overrides(BaseGameOverrides):
        """Run-config-overridable parameters for ForesightGame."""

        show_npc_details: bool = False
        show_final_score: bool = True

    def __init__(
        self,
        *,
        show_npc_details: bool,
        show_final_score: bool,
        scorer: ScorerClient | None = None,
        **kwargs: Any,  # kwargs for base args
    ) -> None:
        """Initialise with game-specific prediction state."""
        super().__init__(**kwargs)
        self._show_npc_details = show_npc_details
        self._show_final_score = show_final_score
        self._scorer = scorer or ScorerClient()
        self._predictions: dict[str, Any] = {}
        self._score: dict[str, Any] = {}

    @classmethod
    def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "ForesightGame":
        """Factory called by SessionManager."""
        scorer = kwargs.pop("scorer", None)
        overrides = cls.parse_overrides(kwargs)
        engine = SimulatorClient(
            pc=pc,
            npc=npc,
            # player_turn_validators=cls.PLAYER_TURN_VALIDATORS,
        )
        return cls(
            pc=pc,
            npc=npc,
            engine=engine,
            scorer=scorer,
            **cls.build_base_init_kwargs(overrides),
            show_npc_details=overrides.show_npc_details,
            show_final_score=overrides.show_final_score,
        )

    @property
    def predictions(self) -> dict[str, Any]:
        """Return the current predictions."""
        return self._predictions

    def _export_additional_state(self) -> dict[str, Any]:
        """Return foresight-specific mutable state."""
        return {
            "predictions": dict(self._predictions),
            "score": dict(self._score),
        }

    def _import_additional_state(self, state: dict[str, Any]) -> None:
        """Restore foresight-specific mutable state."""
        predictions = state.get("predictions", {})
        score = state.get("score", {})
        self._predictions = dict(predictions) if isinstance(predictions, dict) else {}
        self._score = dict(score) if isinstance(score, dict) else {}

    @property
    def score(self) -> dict[str, Any]:
        """Return the current score."""
        return self._score

    def get_help_content(self) -> str:
        """Return the /help message content."""
        return C.HELP_CONTENT.format(
            pc_hid=self._pc.hid,
            pc_short_description=self._pc.short_description.lower(),
            npc_hid=self._npc.hid,
            npc_short_description=(self._npc.short_description.lower() if self._show_npc_details else "*Details hidden.*"),
        )

    def get_abilities_content(self) -> str:
        """Return the /abilities message content."""
        npc_abilities = format_abilities_markdown(self._npc.data.get("abilities", "")) if self._show_npc_details else "*Details hidden.*"
        return 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 if self._show_npc_details else "*Details hidden.*"),
            npc_abilities=npc_abilities,
        )

    async def on_finish(self) -> AsyncIterator[GameEvent]:
        """Score the final prediction, then exit."""
        error_event = await self._run_finish_scoring(self._score_prediction)
        if error_event is not None:
            yield error_event
            return

        if self._show_final_score:
            yield GameEvent.now(type="info", content=format_score_markdown(self._score))

        self.exit("player finished")
        yield GameEvent.now(
            type="info",
            content=C.FINISH_CONTENT.format(finish_reason="player finished"),
            command_response=True,
        )

    async def _score_prediction(self) -> None:
        """Score the player's latest next-action prediction."""
        transcript = self.get_transcript().strip()
        if not transcript:
            self._score = self._zero_score("No interaction was recorded before finishing.")
            return

        guess = self._get_latest_prediction_guess(transcript)
        if not guess:
            self._score = self._zero_score("No next-action prediction was recorded before finishing.")
            return

        prompt = build_scorer_prompt(
            scoring_template=SCORER_NEXT_ACTION,
            npc=self._npc,
            transcript=transcript,
            guess=guess,
        )

        result = await self._scorer.score(prompt=prompt, transcript=transcript)
        self._score = result.evaluation or {}

    def _get_latest_prediction_guess(self, transcript: str) -> str:
        """Return the latest prediction from stored state or transcript."""
        if self._predictions:
            latest_prediction = next(reversed(self._predictions.values()))
            guess = self._coerce_prediction_guess(latest_prediction)
            if guess:
                return guess

        for line in reversed(transcript.splitlines()):
            stripped_line = line.strip()
            if not stripped_line:
                continue

            _, _, content = stripped_line.partition(":")
            candidate = content.strip() if content else stripped_line
            if not candidate:
                continue

            if candidate.lower().startswith("/predict-next"):
                return candidate[len("/predict-next") :].strip()

            if re.search(r"\bpredict(?:ion|ed|s)?\b", candidate, flags=re.IGNORECASE):
                return candidate

        return ""

    def _coerce_prediction_guess(self, prediction: Any) -> str:
        """Extract a string prediction from common payload shapes."""
        if isinstance(prediction, str):
            return prediction.strip()

        if isinstance(prediction, dict):
            for key in ("guess", "prediction", "content", "text", "value"):
                value = prediction.get(key)
                if isinstance(value, str) and value.strip():
                    return value.strip()

        return ""
predictions property

Return the current predictions.

score property

Return the current score.

Overrides

Bases: BaseGameOverrides

Run-config-overridable parameters for ForesightGame.

Source code in dcs_simulation_engine/games/foresight.py
30
31
32
33
34
class Overrides(BaseGameOverrides):
    """Run-config-overridable parameters for ForesightGame."""

    show_npc_details: bool = False
    show_final_score: bool = True
__init__(*, show_npc_details, show_final_score, scorer=None, **kwargs)

Initialise with game-specific prediction state.

Source code in dcs_simulation_engine/games/foresight.py
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
def __init__(
    self,
    *,
    show_npc_details: bool,
    show_final_score: bool,
    scorer: ScorerClient | None = None,
    **kwargs: Any,  # kwargs for base args
) -> None:
    """Initialise with game-specific prediction state."""
    super().__init__(**kwargs)
    self._show_npc_details = show_npc_details
    self._show_final_score = show_final_score
    self._scorer = scorer or ScorerClient()
    self._predictions: dict[str, Any] = {}
    self._score: dict[str, Any] = {}
create_from_context(pc, npc, **kwargs) classmethod

Factory called by SessionManager.

Source code in dcs_simulation_engine/games/foresight.py
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
@classmethod
def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "ForesightGame":
    """Factory called by SessionManager."""
    scorer = kwargs.pop("scorer", None)
    overrides = cls.parse_overrides(kwargs)
    engine = SimulatorClient(
        pc=pc,
        npc=npc,
        # player_turn_validators=cls.PLAYER_TURN_VALIDATORS,
    )
    return cls(
        pc=pc,
        npc=npc,
        engine=engine,
        scorer=scorer,
        **cls.build_base_init_kwargs(overrides),
        show_npc_details=overrides.show_npc_details,
        show_final_score=overrides.show_final_score,
    )
get_abilities_content()

Return the /abilities message content.

Source code in dcs_simulation_engine/games/foresight.py
105
106
107
108
109
110
111
112
113
114
115
def get_abilities_content(self) -> str:
    """Return the /abilities message content."""
    npc_abilities = format_abilities_markdown(self._npc.data.get("abilities", "")) if self._show_npc_details else "*Details hidden.*"
    return 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 if self._show_npc_details else "*Details hidden.*"),
        npc_abilities=npc_abilities,
    )
get_help_content()

Return the /help message content.

Source code in dcs_simulation_engine/games/foresight.py
 96
 97
 98
 99
100
101
102
103
def get_help_content(self) -> str:
    """Return the /help message content."""
    return C.HELP_CONTENT.format(
        pc_hid=self._pc.hid,
        pc_short_description=self._pc.short_description.lower(),
        npc_hid=self._npc.hid,
        npc_short_description=(self._npc.short_description.lower() if self._show_npc_details else "*Details hidden.*"),
    )
on_finish() async

Score the final prediction, then exit.

Source code in dcs_simulation_engine/games/foresight.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
async def on_finish(self) -> AsyncIterator[GameEvent]:
    """Score the final prediction, then exit."""
    error_event = await self._run_finish_scoring(self._score_prediction)
    if error_event is not None:
        yield error_event
        return

    if self._show_final_score:
        yield GameEvent.now(type="info", content=format_score_markdown(self._score))

    self.exit("player finished")
    yield GameEvent.now(
        type="info",
        content=C.FINISH_CONTENT.format(finish_reason="player finished"),
        command_response=True,
    )

goal_horizon

Goal Horizon game.

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
 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
 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
class GoalHorizonGame(Game):
    """Goal Horizon game: player interacts with NPC across scenes to understand their limits."""

    GAME_NAME = "Goal Horizon"
    GAME_DESCRIPTION = "Players are tasked with understanding the capabilities and limitations of another character."

    DEFAULT_PCS_FILTER: CharacterFilter = get_character_filter("human-normative")

    class Overrides(Game.Overrides):
        """Run-config-overridable parameters for GoalHorizonGame."""

        show_npc_details: bool = False
        show_final_score: bool = True

    def __init__(
        self,
        *,
        show_npc_details: bool,
        show_final_score: bool,
        scorer: ScorerClient | None = None,
        **kwargs: Any,  # kwargs for base args
    ) -> None:
        """Initialise with game-specific prediction state."""
        super().__init__(**kwargs)
        self._show_npc_details = show_npc_details
        self._show_final_score = show_final_score
        self._scorer = scorer or ScorerClient()
        self._capability_prediction = ""
        self._capability_prediction_confidence = ""
        self._score: dict[str, Any] = {}
        self._awaiting_confidence = False
        self._scene_count = 1

    @classmethod
    def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "GoalHorizonGame":
        """Factory called by SessionManager."""
        scorer = kwargs.pop("scorer", None)
        overrides = cls.parse_overrides(kwargs)
        engine = SimulatorClient(
            pc=pc,
            npc=npc,
        )
        return cls(
            pc=pc,
            npc=npc,
            engine=engine,
            scorer=scorer,
            **cls.build_base_init_kwargs(overrides),
            show_npc_details=overrides.show_npc_details,
            show_final_score=overrides.show_final_score,
        )

    def _export_additional_state(self) -> dict[str, Any]:
        """Return goal-horizon-specific mutable state."""
        return {
            "awaiting_confidence": self._awaiting_confidence,
            "capability_prediction": self._capability_prediction,
            "capability_prediction_confidence": self._capability_prediction_confidence,
            "score": dict(self._score),
            "scene_count": self._scene_count,
        }

    def _import_additional_state(self, state: dict[str, Any]) -> None:
        """Restore goal-horizon-specific mutable state."""
        legacy_awaiting_prediction = bool(state.get("awaiting_capability_prediction", False))
        legacy_awaiting_confidence = bool(state.get("awaiting_capability_confidence", False))
        if "in_finish_flow" not in state:
            self._in_finish_flow = legacy_awaiting_prediction or legacy_awaiting_confidence
        self._awaiting_confidence = bool(state.get("awaiting_confidence", legacy_awaiting_confidence))
        self._capability_prediction = str(state.get("capability_prediction", ""))
        self._capability_prediction_confidence = str(state.get("capability_prediction_confidence", ""))
        score = state.get("score", {})
        self._score = dict(score) if isinstance(score, dict) else {}
        self._scene_count = int(state.get("scene_count", 1))

    @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

    @property
    def score(self) -> dict[str, Any]:
        """Scorer result, or empty dict."""
        return self._score

    def get_command_handler(self, cmd: str):
        """Return a handler for the given command, or None if not recognized."""
        if cmd == "new-scene":
            return self._handle_new_scene
        return None

    async def _handle_new_scene(self) -> AsyncIterator[GameEvent]:
        self._scene_count += 1
        opening = await self._engine.chat(None)
        self._filtered_transcript_buffer.append(f"{self.OPENING_PREFIX}{opening.content}")
        yield GameEvent.now(type="ai", content=opening.content, command_response=True)

    def get_help_content(self) -> str:
        """Return the /help message content."""
        return C.HELP_CONTENT.format(
            pc_hid=self._pc.hid,
            pc_short_description=self._pc.short_description.lower(),
            npc_hid=self._npc.hid,
            npc_short_description=(self._npc.short_description.lower() if self._show_npc_details else "*Details hidden.*"),
        )

    def get_abilities_content(self) -> str:
        """Return the /abilities message content."""
        npc_abilities = format_abilities_markdown(self._npc.data.get("abilities", "")) if self._show_npc_details else "*Details hidden.*"
        return 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 if self._show_npc_details else "*Details hidden.*"),
            npc_abilities=npc_abilities,
        )

    async def on_finish(self) -> AsyncIterator[GameEvent]:
        """Start the capability prediction collection flow."""
        self._in_finish_flow = True
        self._awaiting_confidence = False
        yield GameEvent.now(
            type="info",
            content=C.CAPABILITY_PREDICTION_QUESTION.format(npc_hid=self._npc.hid),
            command_response=True,
        )

    async def on_finish_input(self, user_input: str) -> AsyncIterator[GameEvent]:
        """Collect prediction then confidence, then exit."""
        if not self._awaiting_confidence:
            self._capability_prediction = user_input
            self._awaiting_confidence = True
            yield GameEvent.now(type="info", content=C.CAPABILITY_PREDICTION_CONFIDENCE)
            return

        self._capability_prediction_confidence = user_input
        self._in_finish_flow = False

        error_event = await self._run_finish_scoring(self._score_capability_prediction)
        if error_event is not None:
            yield error_event
            return

        if self._show_final_score:
            yield GameEvent.now(type="info", content=format_score_markdown(self._score))

        self.exit("player finished")
        yield GameEvent.now(type="info", content=C.FINISH_CONTENT.format(finish_reason="player finished"))

    async def _score_capability_prediction(self) -> None:
        """Score the player's capability prediction."""
        transcript = self.get_transcript().strip()
        if not transcript:
            self._score = self._zero_score("No interaction was recorded before finishing.")
            return

        if not self._capability_prediction.strip():
            self._score = self._zero_score("No capability prediction was provided before finishing.")
            return

        prompt = build_scorer_prompt(
            scoring_template=SCORER_GOAL_BOUNDS,
            npc=self._npc,
            transcript=transcript,
            guess=self._capability_prediction,
        )

        result = await self._scorer.score(prompt=prompt, transcript=transcript)
        self._score = result.evaluation or {}
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.

score property

Scorer result, or empty dict.

Overrides

Bases: Overrides

Run-config-overridable parameters for GoalHorizonGame.

Source code in dcs_simulation_engine/games/goal_horizon.py
23
24
25
26
27
class Overrides(Game.Overrides):
    """Run-config-overridable parameters for GoalHorizonGame."""

    show_npc_details: bool = False
    show_final_score: bool = True
__init__(*, show_npc_details, show_final_score, scorer=None, **kwargs)

Initialise with game-specific prediction state.

Source code in dcs_simulation_engine/games/goal_horizon.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
def __init__(
    self,
    *,
    show_npc_details: bool,
    show_final_score: bool,
    scorer: ScorerClient | None = None,
    **kwargs: Any,  # kwargs for base args
) -> None:
    """Initialise with game-specific prediction state."""
    super().__init__(**kwargs)
    self._show_npc_details = show_npc_details
    self._show_final_score = show_final_score
    self._scorer = scorer or ScorerClient()
    self._capability_prediction = ""
    self._capability_prediction_confidence = ""
    self._score: dict[str, Any] = {}
    self._awaiting_confidence = False
    self._scene_count = 1
create_from_context(pc, npc, **kwargs) classmethod

Factory called by SessionManager.

Source code in dcs_simulation_engine/games/goal_horizon.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@classmethod
def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "GoalHorizonGame":
    """Factory called by SessionManager."""
    scorer = kwargs.pop("scorer", None)
    overrides = cls.parse_overrides(kwargs)
    engine = SimulatorClient(
        pc=pc,
        npc=npc,
    )
    return cls(
        pc=pc,
        npc=npc,
        engine=engine,
        scorer=scorer,
        **cls.build_base_init_kwargs(overrides),
        show_npc_details=overrides.show_npc_details,
        show_final_score=overrides.show_final_score,
    )
get_abilities_content()

Return the /abilities message content.

Source code in dcs_simulation_engine/games/goal_horizon.py
126
127
128
129
130
131
132
133
134
135
136
def get_abilities_content(self) -> str:
    """Return the /abilities message content."""
    npc_abilities = format_abilities_markdown(self._npc.data.get("abilities", "")) if self._show_npc_details else "*Details hidden.*"
    return 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 if self._show_npc_details else "*Details hidden.*"),
        npc_abilities=npc_abilities,
    )
get_command_handler(cmd)

Return a handler for the given command, or None if not recognized.

Source code in dcs_simulation_engine/games/goal_horizon.py
105
106
107
108
109
def get_command_handler(self, cmd: str):
    """Return a handler for the given command, or None if not recognized."""
    if cmd == "new-scene":
        return self._handle_new_scene
    return None
get_help_content()

Return the /help message content.

Source code in dcs_simulation_engine/games/goal_horizon.py
117
118
119
120
121
122
123
124
def get_help_content(self) -> str:
    """Return the /help message content."""
    return C.HELP_CONTENT.format(
        pc_hid=self._pc.hid,
        pc_short_description=self._pc.short_description.lower(),
        npc_hid=self._npc.hid,
        npc_short_description=(self._npc.short_description.lower() if self._show_npc_details else "*Details hidden.*"),
    )
on_finish() async

Start the capability prediction collection flow.

Source code in dcs_simulation_engine/games/goal_horizon.py
138
139
140
141
142
143
144
145
146
async def on_finish(self) -> AsyncIterator[GameEvent]:
    """Start the capability prediction collection flow."""
    self._in_finish_flow = True
    self._awaiting_confidence = False
    yield GameEvent.now(
        type="info",
        content=C.CAPABILITY_PREDICTION_QUESTION.format(npc_hid=self._npc.hid),
        command_response=True,
    )
on_finish_input(user_input) async

Collect prediction then confidence, then exit.

Source code in dcs_simulation_engine/games/goal_horizon.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
async def on_finish_input(self, user_input: str) -> AsyncIterator[GameEvent]:
    """Collect prediction then confidence, then exit."""
    if not self._awaiting_confidence:
        self._capability_prediction = user_input
        self._awaiting_confidence = True
        yield GameEvent.now(type="info", content=C.CAPABILITY_PREDICTION_CONFIDENCE)
        return

    self._capability_prediction_confidence = user_input
    self._in_finish_flow = False

    error_event = await self._run_finish_scoring(self._score_capability_prediction)
    if error_event is not None:
        yield error_event
        return

    if self._show_final_score:
        yield GameEvent.now(type="info", content=format_score_markdown(self._score))

    self.exit("player finished")
    yield GameEvent.now(type="info", content=C.FINISH_CONTENT.format(finish_reason="player finished"))

infer_intent

Infer Intent game.

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
 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
 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
class InferIntentGame(Game):
    """Infer Intent game: player interacts with NPC and infers their hidden goal."""

    GAME_NAME = "Infer Intent"
    GAME_DESCRIPTION = "Players are tasked with understanding the intention of another character."

    DEFAULT_PCS_FILTER: CharacterFilter = get_character_filter("human-normative")

    class Overrides(Game.Overrides):
        """Run-config-overridable parameters for InferIntentGame."""

        show_npc_details: bool = False
        show_final_score: bool = True

    def __init__(
        self,
        *,
        show_npc_details: bool,
        show_final_score: bool,
        scorer: ScorerClient | None = None,
        **kwargs: Any,  # kwargs for base args
    ) -> None:
        """Initialise with game-specific inference state."""
        super().__init__(**kwargs)
        self._show_npc_details = show_npc_details
        self._show_final_score = show_final_score
        self._scorer = scorer or ScorerClient()
        self._goal_inference = ""
        self._goal_inference_confidence = ""
        self._score: dict[str, Any] = {}
        self._awaiting_confidence = False

    @classmethod
    def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "InferIntentGame":
        """Factory called by SessionManager."""
        scorer = kwargs.pop("scorer", None)
        overrides = cls.parse_overrides(kwargs)
        engine = SimulatorClient(
            pc=pc,
            npc=npc,
        )
        return cls(
            pc=pc,
            npc=npc,
            engine=engine,
            scorer=scorer,
            **cls.build_base_init_kwargs(overrides),
            show_npc_details=overrides.show_npc_details,
            show_final_score=overrides.show_final_score,
        )

    def _export_additional_state(self) -> dict[str, Any]:
        """Return infer-intent-specific mutable state."""
        return {
            "awaiting_confidence": self._awaiting_confidence,
            "goal_inference": self._goal_inference,
            "goal_inference_confidence": self._goal_inference_confidence,
            "score": dict(self._score),
        }

    def _import_additional_state(self, state: dict[str, Any]) -> None:
        """Restore infer-intent-specific mutable state."""
        legacy_awaiting_inference = bool(state.get("awaiting_goal_inference", False))
        legacy_awaiting_confidence = bool(state.get("awaiting_goal_inference_confidence", False))
        if "in_finish_flow" not in state:
            self._in_finish_flow = legacy_awaiting_inference or legacy_awaiting_confidence
        self._awaiting_confidence = bool(state.get("awaiting_confidence", legacy_awaiting_confidence))
        self._goal_inference = str(state.get("goal_inference", ""))
        self._goal_inference_confidence = str(state.get("goal_inference_confidence", ""))
        score = state.get("score", state.get("evaluation", {}))
        self._score = dict(score) if isinstance(score, dict) else {}

    @property
    def goal_inference(self) -> str:
        """Player's inferred goal, 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 score(self) -> dict[str, Any]:
        """Scorer result, or empty dict."""
        return self._score

    def get_help_content(self) -> str:
        """Return the /help message content."""
        return C.HELP_CONTENT.format(
            pc_hid=self._pc.hid,
            pc_short_description=self._pc.short_description.lower(),
            npc_hid=self._npc.hid,
            npc_short_description=(self._npc.short_description.lower() if self._show_npc_details else "*Details hidden.*"),
        )

    def get_abilities_content(self) -> str:
        """Return the /abilities message content."""
        npc_abilities = format_abilities_markdown(self._npc.data.get("abilities", "")) if self._show_npc_details else "*Details hidden.*"
        return 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 if self._show_npc_details else "*Details hidden.*"),
            npc_abilities=npc_abilities,
        )

    async def on_finish(self) -> AsyncIterator[GameEvent]:
        """Start the inference collection flow."""
        self._in_finish_flow = True
        self._awaiting_confidence = False
        yield GameEvent.now(type="info", content=C.GOAL_INFERENCE_QUESTION, command_response=True)

    async def on_finish_input(self, user_input: str) -> AsyncIterator[GameEvent]:
        """Collect inference then confidence, score, then exit."""
        if not self._awaiting_confidence:
            self._goal_inference = user_input
            self._awaiting_confidence = True
            yield GameEvent.now(type="info", content=C.GOAL_INFERENCE_CONFIDENCE)
            return

        self._goal_inference_confidence = user_input
        self._in_finish_flow = False

        error_event = await self._run_finish_scoring(self._score_goal_inference)
        if error_event is not None:
            yield error_event
            return

        if self._show_final_score:
            yield GameEvent.now(type="info", content=format_score_markdown(self._score))

        self.exit("player finished")
        yield GameEvent.now(type="info", content=C.FINISH_CONTENT.format(finish_reason="player finished"))

    async def _score_goal_inference(self) -> None:
        """Score the player's goal inference."""
        transcript = self.get_transcript().strip()
        if not transcript:
            self._score = self._zero_score("No interaction was recorded before finishing.")
            return

        if not self._goal_inference.strip():
            self._score = self._zero_score("No goal inference was provided before finishing.")
            return

        prompt = build_scorer_prompt(
            scoring_template=SCORER_GOAL_INFERENCE,
            npc=self._npc,
            transcript=transcript,
            guess=self._goal_inference,
        )

        result = await self._scorer.score(prompt=prompt, transcript=transcript)
        self._score = result.evaluation or {}
goal_inference property

Player's inferred goal, or empty string.

goal_inference_confidence property

Player's confidence in their goal inference, or empty string.

score property

Scorer result, or empty dict.

Overrides

Bases: Overrides

Run-config-overridable parameters for InferIntentGame.

Source code in dcs_simulation_engine/games/infer_intent.py
23
24
25
26
27
class Overrides(Game.Overrides):
    """Run-config-overridable parameters for InferIntentGame."""

    show_npc_details: bool = False
    show_final_score: bool = True
__init__(*, show_npc_details, show_final_score, scorer=None, **kwargs)

Initialise with game-specific inference state.

Source code in dcs_simulation_engine/games/infer_intent.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def __init__(
    self,
    *,
    show_npc_details: bool,
    show_final_score: bool,
    scorer: ScorerClient | None = None,
    **kwargs: Any,  # kwargs for base args
) -> None:
    """Initialise with game-specific inference state."""
    super().__init__(**kwargs)
    self._show_npc_details = show_npc_details
    self._show_final_score = show_final_score
    self._scorer = scorer or ScorerClient()
    self._goal_inference = ""
    self._goal_inference_confidence = ""
    self._score: dict[str, Any] = {}
    self._awaiting_confidence = False
create_from_context(pc, npc, **kwargs) classmethod

Factory called by SessionManager.

Source code in dcs_simulation_engine/games/infer_intent.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
@classmethod
def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "InferIntentGame":
    """Factory called by SessionManager."""
    scorer = kwargs.pop("scorer", None)
    overrides = cls.parse_overrides(kwargs)
    engine = SimulatorClient(
        pc=pc,
        npc=npc,
    )
    return cls(
        pc=pc,
        npc=npc,
        engine=engine,
        scorer=scorer,
        **cls.build_base_init_kwargs(overrides),
        show_npc_details=overrides.show_npc_details,
        show_final_score=overrides.show_final_score,
    )
get_abilities_content()

Return the /abilities message content.

Source code in dcs_simulation_engine/games/infer_intent.py
111
112
113
114
115
116
117
118
119
120
121
def get_abilities_content(self) -> str:
    """Return the /abilities message content."""
    npc_abilities = format_abilities_markdown(self._npc.data.get("abilities", "")) if self._show_npc_details else "*Details hidden.*"
    return 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 if self._show_npc_details else "*Details hidden.*"),
        npc_abilities=npc_abilities,
    )
get_help_content()

Return the /help message content.

Source code in dcs_simulation_engine/games/infer_intent.py
102
103
104
105
106
107
108
109
def get_help_content(self) -> str:
    """Return the /help message content."""
    return C.HELP_CONTENT.format(
        pc_hid=self._pc.hid,
        pc_short_description=self._pc.short_description.lower(),
        npc_hid=self._npc.hid,
        npc_short_description=(self._npc.short_description.lower() if self._show_npc_details else "*Details hidden.*"),
    )
on_finish() async

Start the inference collection flow.

Source code in dcs_simulation_engine/games/infer_intent.py
123
124
125
126
127
async def on_finish(self) -> AsyncIterator[GameEvent]:
    """Start the inference collection flow."""
    self._in_finish_flow = True
    self._awaiting_confidence = False
    yield GameEvent.now(type="info", content=C.GOAL_INFERENCE_QUESTION, command_response=True)
on_finish_input(user_input) async

Collect inference then confidence, score, then exit.

Source code in dcs_simulation_engine/games/infer_intent.py
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
async def on_finish_input(self, user_input: str) -> AsyncIterator[GameEvent]:
    """Collect inference then confidence, score, then exit."""
    if not self._awaiting_confidence:
        self._goal_inference = user_input
        self._awaiting_confidence = True
        yield GameEvent.now(type="info", content=C.GOAL_INFERENCE_CONFIDENCE)
        return

    self._goal_inference_confidence = user_input
    self._in_finish_flow = False

    error_event = await self._run_finish_scoring(self._score_goal_inference)
    if error_event is not None:
        yield error_event
        return

    if self._show_final_score:
        yield GameEvent.now(type="info", content=format_score_markdown(self._score))

    self.exit("player finished")
    yield GameEvent.now(type="info", content=C.FINISH_CONTENT.format(finish_reason="player finished"))

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)
format_score_markdown(score, *, title='Final Score')

Render a scorer payload as readable markdown.

Source code in dcs_simulation_engine/games/markdown_helpers.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
def format_score_markdown(score: dict[str, Any], *, title: str = "Final Score") -> str:
    """Render a scorer payload as readable markdown."""
    if not score:
        return ""

    lines = [f"## {title}"]

    tier = score.get("tier")
    if tier is not None:
        lines.append(f"- Tier: {tier}")

    numeric_score = score.get("score")
    if numeric_score is not None:
        lines.append(f"- Score: {numeric_score}")

    reasoning = str(score.get("reasoning", "")).strip()
    if reasoning:
        lines.extend(["", "### Reasoning", reasoning])

    return "\n".join(lines)

prompts

Shared system prompt templates and builders for games.

build_opener_prompt(pc, npc, *, template=OPENER, excluded_scenes=None)

Render the opening-scene prompt.

Source code in dcs_simulation_engine/games/prompts.py
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
def build_opener_prompt(
    pc: CharacterRecord,
    npc: CharacterRecord,
    *,
    template: str = OPENER,
    excluded_scenes: list[str] | None = None,
) -> str:
    """Render the opening-scene prompt."""
    return _render_prompt(
        template,
        **_build_character_context(
            pc,
            npc,
            scene_exclusion_instructions=_build_scene_exclusion_instructions(excluded_scenes),
        ),
    )
build_player_validator_prompt(pc, npc, *, player_action, transcript, validator_template)

Render a named player-input validator prompt.

Source code in dcs_simulation_engine/games/prompts.py
897
898
899
900
901
902
903
904
905
906
907
908
909
def build_player_validator_prompt(
    pc: CharacterRecord, npc: CharacterRecord, *, player_action: str, transcript: str, validator_template: str
) -> str:
    """Render a named player-input validator prompt."""
    return _render_prompt(
        validator_template,
        **_build_character_context(
            pc,
            npc,
            player_action=player_action,
            transcript=transcript,
        ),
    )
build_scorer_prompt(*, scoring_template, npc, transcript, pc=None, **template_kwargs)

Render a scoring prompt from an explicit template and game-specific kwargs.

Source code in dcs_simulation_engine/games/prompts.py
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
def build_scorer_prompt(
    *,
    scoring_template: str,
    npc: CharacterRecord,
    transcript: str,
    pc: CharacterRecord | None = None,
    **template_kwargs: str,
) -> str:
    """Render a scoring prompt from an explicit template and game-specific kwargs."""
    context = _build_character_context(
        pc or npc,
        npc,
        transcript=transcript,
        **{key: _format_prompt_value(value) for key, value in template_kwargs.items()},
    )
    return _render_prompt(scoring_template, **context)
build_simulator_validator_prompt(pc, npc, *, simulator_response, transcript, game_objective, validator_template)

Render a named simulator response validator prompt.

Source code in dcs_simulation_engine/games/prompts.py
912
913
914
915
916
917
918
919
920
921
922
923
924
925
def build_simulator_validator_prompt(
    pc: CharacterRecord, npc: CharacterRecord, *, simulator_response: str, transcript: str, game_objective: str, validator_template: str
) -> str:
    """Render a named simulator response validator prompt."""
    return _render_prompt(
        validator_template,
        **_build_character_context(
            pc,
            npc,
            simulator_response=simulator_response,
            transcript=transcript,
            game_objective=game_objective,
        ),
    )
build_updater_prompt(pc, npc, *, game_objective, transcript, player_action, template=UPDATER)

Render the simulation updater prompt.

Source code in dcs_simulation_engine/games/prompts.py
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
def build_updater_prompt(
    pc: CharacterRecord,
    npc: CharacterRecord,
    *,
    game_objective: str,
    transcript: str,
    player_action: str,
    template: str = UPDATER,
) -> str:
    """Render the simulation updater prompt."""
    return _render_prompt(
        template,
        **_build_character_context(
            pc,
            npc,
            game_objective=game_objective,
            transcript=transcript,
            player_action=player_action,
        ),
    )

teamwork

Teamwork game.

TeamworkGame

Bases: Game

Teamwork game: player collaborates with NPC toward a shared goal.

Source code in dcs_simulation_engine/games/teamwork.py
 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
 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
class TeamworkGame(Game):
    """Teamwork game: player collaborates with NPC toward a shared goal."""

    GAME_NAME = "Teamwork"
    GAME_DESCRIPTION = "Players are tasked with collaborating with another character to achieve a shared goal."

    DEFAULT_PCS_FILTER: CharacterFilter = get_character_filter("human-normative")
    # Note: NPCs have to be able to move towards a textually described objective. All pc_eligible characters can do this.
    DEFAULT_NPCS_FILTER: CharacterFilter = get_character_filter("all")

    class Overrides(BaseGameOverrides):
        """Run-config-overridable parameters for TeamworkGame."""

        show_npc_details: bool = True
        show_final_score: bool = True

    def __init__(
        self,
        *,
        show_npc_details: bool,
        show_final_score: bool,
        scorer: ScorerClient | None = None,
        **kwargs: Any,  # kwargs for base args
    ) -> None:
        """Initialise with game-specific prediction state."""
        super().__init__(**kwargs)
        self._show_npc_details = show_npc_details
        self._show_final_score = show_final_score
        self._scorer = scorer or ScorerClient()
        self._challenges = ""
        self._shared_goal = ""
        self._score: dict[str, Any] = {}

    @classmethod
    def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "TeamworkGame":
        """Factory called by SessionManager."""
        scorer = kwargs.pop("scorer", None)
        overrides = cls.parse_overrides(kwargs)
        engine = SimulatorClient(pc=pc, npc=npc, opener_template=OPENER_WITH_SHARED_GOAL)
        return cls(
            pc=pc,
            npc=npc,
            engine=engine,
            scorer=scorer,
            **cls.build_base_init_kwargs(overrides),
            show_npc_details=overrides.show_npc_details,
            show_final_score=overrides.show_final_score,
        )

    def _export_additional_state(self) -> dict[str, Any]:
        """Return teamwork-specific mutable state."""
        return {
            "shared_goal": self._shared_goal,
            "challenges": self._challenges,
            "score": dict(self._score),
        }

    def _import_additional_state(self, state: dict[str, Any]) -> None:
        """Restore teamwork-specific mutable state."""
        if "in_finish_flow" not in state:
            self._in_finish_flow = bool(state.get("awaiting_challenges", False))

        self._shared_goal = str(state.get("shared_goal", ""))
        self._challenges = str(state.get("challenges", ""))
        score = state.get("score", {})
        self._score = dict(score) if isinstance(score, dict) else {}

        engine_opening_metadata = getattr(self._engine, "_opening_metadata", None)
        if isinstance(engine_opening_metadata, dict) and self._shared_goal and not engine_opening_metadata.get("shared_goal"):
            engine_opening_metadata["shared_goal"] = self._shared_goal

    @property
    def shared_goal(self) -> str:
        """The shared goal for this game instance."""
        return self._shared_goal

    @property
    def challenges(self) -> str:
        """Player's reflection on challenges, or empty string."""
        return self._challenges

    @property
    def score(self) -> dict[str, Any]:
        """Scorer result, or empty dict."""
        return self._score

    def _consume_model_metadata(self, *, stage: str, metadata: dict[str, Any]) -> None:
        """Persist shared goal metadata produced by the model."""
        if stage != "opening":
            return

        shared_goal = metadata.get("shared_goal")
        if isinstance(shared_goal, str) and shared_goal.strip():
            self._shared_goal = shared_goal.strip()

    def get_setup_content(self) -> str:
        """Return custom setup with goal."""
        return self.get_help_content()

    def get_help_content(self) -> str:
        """Return the /help message content."""
        return C.HELP_CONTENT.format(
            pc_hid=self._pc.hid,
            pc_short_description=self._pc.short_description.lower(),
            npc_hid=self._npc.hid,
            npc_short_description=(self._npc.short_description.lower() if self._show_npc_details else "*Details hidden.*"),
            shared_goal=self._shared_goal,
        )

    def get_abilities_content(self) -> str:
        """Return the /abilities message content."""
        npc_abilities = format_abilities_markdown(self._npc.data.get("abilities", "")) if self._show_npc_details else "*Details hidden.*"
        return 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 if self._show_npc_details else "*Details hidden.*"),
            npc_abilities=npc_abilities,
        )

    async def on_finish(self) -> AsyncIterator[GameEvent]:
        """Ask the challenges reflection question before exiting."""
        self._in_finish_flow = True
        yield GameEvent.now(type="info", content=C.CHALLENGES_QUESTION, command_response=True)

    async def on_finish_input(self, user_input: str) -> AsyncIterator[GameEvent]:
        """Store challenges answer, score, then exit."""
        self._challenges = user_input
        self._in_finish_flow = False

        error_event = await self._run_finish_scoring(self._score_teamwork)
        if error_event is not None:
            yield error_event
            return

        if self._show_final_score:
            yield GameEvent.now(type="info", content=format_score_markdown(self._score))

        self.exit("player finished")
        yield GameEvent.now(type="info", content=C.FINISH_CONTENT.format(finish_reason="player finished"))

    async def _score_teamwork(self) -> None:
        """Score the player's teamwork reflection against collaborative performance."""
        transcript = self.get_transcript().strip()
        if not transcript:
            self._score = self._zero_score("No interaction was recorded before finishing.")
            return

        if not self._challenges.strip():
            self._score = self._zero_score("No teamwork reflection was provided before finishing.")
            return

        prompt = build_scorer_prompt(
            scoring_template=SCORER_SHARED_GOAL,
            npc=self._npc,
            pc=self._pc,
            transcript=transcript,
            shared_goal=self._shared_goal,
            guess=self._challenges,
        )

        result = await self._scorer.score(prompt=prompt, transcript=transcript)
        self._score = result.evaluation or {}
challenges property

Player's reflection on challenges, or empty string.

score property

Scorer result, or empty dict.

shared_goal property

The shared goal for this game instance.

Overrides

Bases: BaseGameOverrides

Run-config-overridable parameters for TeamworkGame.

Source code in dcs_simulation_engine/games/teamwork.py
25
26
27
28
29
class Overrides(BaseGameOverrides):
    """Run-config-overridable parameters for TeamworkGame."""

    show_npc_details: bool = True
    show_final_score: bool = True
__init__(*, show_npc_details, show_final_score, scorer=None, **kwargs)

Initialise with game-specific prediction state.

Source code in dcs_simulation_engine/games/teamwork.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
def __init__(
    self,
    *,
    show_npc_details: bool,
    show_final_score: bool,
    scorer: ScorerClient | None = None,
    **kwargs: Any,  # kwargs for base args
) -> None:
    """Initialise with game-specific prediction state."""
    super().__init__(**kwargs)
    self._show_npc_details = show_npc_details
    self._show_final_score = show_final_score
    self._scorer = scorer or ScorerClient()
    self._challenges = ""
    self._shared_goal = ""
    self._score: dict[str, Any] = {}
create_from_context(pc, npc, **kwargs) classmethod

Factory called by SessionManager.

Source code in dcs_simulation_engine/games/teamwork.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
@classmethod
def create_from_context(cls, pc: CharacterRecord, npc: CharacterRecord, **kwargs: Any) -> "TeamworkGame":
    """Factory called by SessionManager."""
    scorer = kwargs.pop("scorer", None)
    overrides = cls.parse_overrides(kwargs)
    engine = SimulatorClient(pc=pc, npc=npc, opener_template=OPENER_WITH_SHARED_GOAL)
    return cls(
        pc=pc,
        npc=npc,
        engine=engine,
        scorer=scorer,
        **cls.build_base_init_kwargs(overrides),
        show_npc_details=overrides.show_npc_details,
        show_final_score=overrides.show_final_score,
    )
get_abilities_content()

Return the /abilities message content.

Source code in dcs_simulation_engine/games/teamwork.py
124
125
126
127
128
129
130
131
132
133
134
def get_abilities_content(self) -> str:
    """Return the /abilities message content."""
    npc_abilities = format_abilities_markdown(self._npc.data.get("abilities", "")) if self._show_npc_details else "*Details hidden.*"
    return 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 if self._show_npc_details else "*Details hidden.*"),
        npc_abilities=npc_abilities,
    )
get_help_content()

Return the /help message content.

Source code in dcs_simulation_engine/games/teamwork.py
114
115
116
117
118
119
120
121
122
def get_help_content(self) -> str:
    """Return the /help message content."""
    return C.HELP_CONTENT.format(
        pc_hid=self._pc.hid,
        pc_short_description=self._pc.short_description.lower(),
        npc_hid=self._npc.hid,
        npc_short_description=(self._npc.short_description.lower() if self._show_npc_details else "*Details hidden.*"),
        shared_goal=self._shared_goal,
    )
get_setup_content()

Return custom setup with goal.

Source code in dcs_simulation_engine/games/teamwork.py
110
111
112
def get_setup_content(self) -> str:
    """Return custom setup with goal."""
    return self.get_help_content()
on_finish() async

Ask the challenges reflection question before exiting.

Source code in dcs_simulation_engine/games/teamwork.py
136
137
138
139
async def on_finish(self) -> AsyncIterator[GameEvent]:
    """Ask the challenges reflection question before exiting."""
    self._in_finish_flow = True
    yield GameEvent.now(type="info", content=C.CHALLENGES_QUESTION, command_response=True)
on_finish_input(user_input) async

Store challenges answer, score, then exit.

Source code in dcs_simulation_engine/games/teamwork.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
async def on_finish_input(self, user_input: str) -> AsyncIterator[GameEvent]:
    """Store challenges answer, score, then exit."""
    self._challenges = user_input
    self._in_finish_flow = False

    error_event = await self._run_finish_scoring(self._score_teamwork)
    if error_event is not None:
        yield error_event
        return

    if self._show_final_score:
        yield GameEvent.now(type="info", content=format_score_markdown(self._score))

    self.exit("player finished")
    yield GameEvent.now(type="info", content=C.FINISH_CONTENT.format(finish_reason="player finished"))

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

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
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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 {} -> {}", template_path, dest)
    return dest
get_game_config(game, version='latest')

Return a YAML path for explicit custom configs; built-ins are class-backed.

Source code in dcs_simulation_engine/helpers/game_helpers.py
77
78
79
80
81
82
83
84
def get_game_config(game: str, version: str = "latest") -> str:
    """Return a YAML path for explicit custom configs; built-ins are class-backed."""
    _ = version
    possible_path = Path(game).expanduser()
    if possible_path.is_file() and possible_path.suffix.lower() in {".yaml", ".yml"}:
        return str(possible_path)
    config = SessionManager.get_game_config_cached(game)
    raise FileNotFoundError(f"{config.name!r} is a built-in class-backed game and no YAML config path exists.")
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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
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
41
42
43
44
45
46
47
48
49
50
51
52
def list_games(
    directory: str | Path | None = None,
) -> list[tuple[str, str, Path, str | None, str | None]]:
    """Return available games."""
    _ = directory
    results: list[tuple[str, str, Path, str | None, str | None]] = []
    for game_cls in SessionManager._builtin_game_classes().values():
        config = GameConfig.from_game_class(game_cls)
        author_str = ", ".join(config.authors or [])
        path = Path(f"<builtin:{config.name}>")
        results.append((config.name, author_str, path, config.version, config.description))
    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=(f"{{time:YYYY-MM-DD HH:mm:ss,SSS}} {source} {{level}} {{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."
    )

hitl

Data models for the human-in-the-loop (HITL) scenario testing pipeline.

Attempt

Bases: BaseModel

A single player message, the simulator's response, and evaluator feedback.

Source code in dcs_simulation_engine/hitl/__init__.py
25
26
27
28
29
30
31
32
class Attempt(BaseModel):
    """A single player message, the simulator's response, and evaluator feedback."""

    player_message: str
    simulator_response: str | None = None
    simulator_response_type: SimulatorResponseType | None = None
    simulator_extra_events: list[dict[str, str]] = Field(default_factory=list)
    evaluator_feedback: EvaluatorFeedback | None = None

EvaluatorFeedback

Bases: BaseModel

Evaluator rating for one simulator response.

Field names and semantics match the feedback object in session_events so the export pipeline can map these directly to report-compatible data.

Source code in dcs_simulation_engine/hitl/__init__.py
10
11
12
13
14
15
16
17
18
19
20
21
22
class EvaluatorFeedback(BaseModel):
    """Evaluator rating for one simulator response.

    Field names and semantics match the feedback object in session_events
    so the export pipeline can map these directly to report-compatible data.
    """

    liked: bool
    comment: str = ""
    doesnt_make_sense: bool = False
    out_of_character: bool = False
    other: bool = False
    submitted_at: str  # ISO-8601 timestamp

Scenario

Bases: BaseModel

One test scenario: a starting context and one or more player attempts.

Source code in dcs_simulation_engine/hitl/__init__.py
35
36
37
38
39
40
41
42
43
44
45
46
47
class Scenario(BaseModel):
    """One test scenario: a starting context and one or more player attempts."""

    id: str
    description: str
    game: str
    pc_hid: str
    parent_session_id: str | None = Field(
        default=None,
        validation_alias=AliasChoices("parent_session_id", "context_session_id"),
    )
    conversation_history: list[dict] = Field(default_factory=list)
    attempts: list[Attempt] = Field(default_factory=list)

ScenarioFile

Bases: BaseModel

Top-level container for a character's scenario test suite.

Source code in dcs_simulation_engine/hitl/__init__.py
60
61
62
63
64
65
class ScenarioFile(BaseModel):
    """Top-level container for a character's scenario test suite."""

    npc_hid: str
    generated_at: str  # ISO-8601 timestamp
    scenario_groups: list[ScenarioGroup] = Field(default_factory=list)

ScenarioGroup

Bases: BaseModel

Scenarios grouped by the expected failure mode they probe.

Source code in dcs_simulation_engine/hitl/__init__.py
50
51
52
53
54
55
56
57
class ScenarioGroup(BaseModel):
    """Scenarios grouped by the expected failure mode they probe."""

    group_id: str
    label: str
    expected_failure_mode: str
    pressure_category: str
    scenarios: list[Scenario] = Field(default_factory=list)

export

Export a completed test cases file to a results directory.

The output directory has the same structure that dcs generate report expects: sessions.json, session_events.json, characters.json, players.json, assignments.json, runs.json, and manifest.json.

Each Scenario becomes one session; each Attempt within a scenario becomes an inbound player message plus one or more outbound simulator/system events. The EvaluatorFeedback fields map 1-to-1 onto the session_events feedback object so the existing analysis pipeline can process them without any changes.

export_results(scenarios_path, *, evaluator_id='evaluator', output_dir=None)

Convert a completed test cases file to a results directory.

The results directory is placed next to the test cases file by default (<hid>-scenario-results/), or at output_dir if specified.

Parameters:

Name Type Description Default
scenarios_path Path

Path to the <hid>-test-cases.json file.

required
evaluator_id str

Name/ID recorded as the synthetic player.

'evaluator'
output_dir Path | None

Override the output directory path.

None

Returns:

Type Description
Path

The path to the created results directory.

Source code in dcs_simulation_engine/hitl/export.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
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
def export_results(
    scenarios_path: Path,
    *,
    evaluator_id: str = "evaluator",
    output_dir: Path | None = None,
) -> Path:
    """Convert a completed test cases file to a results directory.

    The results directory is placed next to the test cases file by default
    (``<hid>-scenario-results/``), or at ``output_dir`` if specified.

    Args:
        scenarios_path: Path to the ``<hid>-test-cases.json`` file.
        evaluator_id: Name/ID recorded as the synthetic player.
        output_dir: Override the output directory path.

    Returns:
        The path to the created results directory.
    """
    scenario_file: ScenarioFile = load_scenario_file(scenarios_path)
    npc_hid = scenario_file.npc_hid

    if output_dir is None:
        output_dir = scenarios_path.parent / f"{npc_hid}-scenario-results"
    output_dir.mkdir(parents=True, exist_ok=True)

    generated_at = scenario_file.generated_at
    player_id = f"evaluator-{evaluator_id}"

    sessions: list[dict] = []
    session_events: list[dict] = []
    total_source_scenarios = 0
    total_source_attempts = 0
    exported_scenarios = 0
    exported_attempts = 0

    for group in scenario_file.scenario_groups:
        for scenario in group.scenarios:
            total_source_scenarios += 1
            total_source_attempts += len(scenario.attempts)
            completed_attempts = [
                (turn_index, attempt) for turn_index, attempt in enumerate(scenario.attempts) if _is_completed_attempt(attempt)
            ]
            if not completed_attempts:
                continue

            session_id = str(uuid.uuid4())
            started_at = generated_at
            turns_completed = len(completed_attempts)
            exported_scenarios += 1
            exported_attempts += turns_completed

            sessions.append(
                {
                    "session_id": session_id,
                    "name": f"hitl-{npc_hid}-{scenario.id}",
                    "player_id": player_id,
                    "game_name": scenario.game,
                    "source": "hitl",
                    "pc_hid": scenario.pc_hid,
                    "npc_hid": npc_hid,
                    "session_started_at": started_at,
                    "session_ended_at": _now_iso(),
                    "termination_reason": "hitl_complete",
                    "status": "closed",
                    "turns_completed": turns_completed,
                    "model_profile": {
                        "updater_model": None,
                        "validator_model": None,
                        "scorer_model": None,
                    },
                    "game_config_snapshot": {
                        "pressure_category": group.pressure_category,
                        "expected_failure_mode": group.expected_failure_mode,
                    },
                    "last_seq": 0,
                    "created_at": started_at,
                    "updated_at": _now_iso(),
                }
            )

            seq = 0
            for turn_index, attempt in completed_attempts:
                ts = attempt.evaluator_feedback.submitted_at

                # Inbound player message
                seq += 1
                session_events.append(
                    {
                        "session_id": session_id,
                        "seq": seq,
                        "event_id": str(uuid.uuid4()),
                        "event_ts": ts,
                        "direction": "inbound",
                        "event_type": "message",
                        "event_source": "user",
                        "content": attempt.player_message,
                        "content_format": "plain_text",
                        "turn_index": turn_index,
                        "visible_to_user": True,
                    }
                )

                # Outbound simulator/system response(s)
                response_events = _attempt_response_events(attempt)
                for response_idx, (response_type, content) in enumerate(response_events):
                    event_type, event_source, content_format = _export_event_shape(response_type)
                    seq += 1
                    event_doc = {
                        "session_id": session_id,
                        "seq": seq,
                        "event_id": str(uuid.uuid4()),
                        "event_ts": ts,
                        "direction": "outbound",
                        "event_type": event_type,
                        "event_source": event_source,
                        "content": content,
                        "content_format": content_format,
                        "turn_index": turn_index,
                        "visible_to_user": True,
                    }
                    if response_idx == 0:
                        event_doc["feedback"] = _feedback_to_event_feedback(attempt.evaluator_feedback)
                    session_events.append(event_doc)

            sessions[-1]["last_seq"] = seq

    # characters.json — include the NPC character document
    char_doc = _load_character_doc(npc_hid)
    characters = [char_doc] if char_doc else []

    # players.json
    players = [
        {
            "player_id": player_id,
            "access_key": player_id,
            "source": "hitl",
            "created_at": generated_at,
        }
    ]

    # runs.json
    runs_metadata = [
        {
            "run_id": str(uuid.uuid4()),
            "name": f"{npc_hid} HITL Scenario Test",
            "run_config": {"source": "hitl", "npc_hid": npc_hid},
            "created_at": generated_at,
        }
    ]

    # __manifest__.json
    manifest = {
        "source": "scenario-testing",
        "npc_hid": npc_hid,
        "generated_at": generated_at,
        "scenarios_path": str(scenarios_path),
        "total_scenarios": exported_scenarios,
        "total_attempts": exported_attempts,
        "source_total_scenarios": total_source_scenarios,
        "source_total_attempts": total_source_attempts,
        "skipped_scenarios": total_source_scenarios - exported_scenarios,
        "skipped_attempts": total_source_attempts - exported_attempts,
    }

    # Write all files
    def _write(name: str, data) -> None:
        (output_dir / name).write_text(
            json.dumps(data, indent=2, ensure_ascii=False, default=str),
            encoding="utf-8",
        )

    _write("__manifest__.json", manifest)
    _write("sessions.json", sessions)
    _write("session_events.json", session_events)
    _write("characters.json", characters)
    _write("players.json", players)
    _write("runs.json", runs_metadata)
    _write("assignments.json", [])

    return output_dir

feedback

Interactive evaluator feedback collection for HITL scenarios.

Walks through every attempt that has a simulator_response but lacks evaluator_feedback, displays the scenario context and NPC output, then prompts the evaluator for a thumbs rating, flags, and an optional comment.

Saving is atomic after each entry so the session is interrupt-safe; re-running the command resumes from the first attempt without feedback.

collect_feedback(path, *, evaluator_id='', only=None, include_ids=None, exclude=None, console)

Walk through pending attempts and collect evaluator feedback.

Parameters:

Name Type Description Default
path Path

Path to the <hid>-test-cases.json file.

required
evaluator_id str

Optional string recorded in each feedback entry.

''
only list[str] | None

If set, process ONLY scenarios with these IDs.

None
include_ids list[str] | None

Force-include these scenario IDs even when only is set.

None
exclude list[str] | None

Skip scenarios with these IDs.

None
console Console

Rich Console for output.

required
Source code in dcs_simulation_engine/hitl/feedback.py
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
def collect_feedback(
    path: Path,
    *,
    evaluator_id: str = "",
    only: list[str] | None = None,
    include_ids: list[str] | None = None,
    exclude: list[str] | None = None,
    console: Console,
) -> None:
    """Walk through pending attempts and collect evaluator feedback.

    Args:
        path: Path to the ``<hid>-test-cases.json`` file.
        evaluator_id: Optional string recorded in each feedback entry.
        only: If set, process ONLY scenarios with these IDs.
        include_ids: Force-include these scenario IDs even when ``only`` is set.
        exclude: Skip scenarios with these IDs.
        console: Rich Console for output.
    """
    scenario_file = load_scenario_file(path)
    pending = _pending_attempts(scenario_file, only=only, include_ids=include_ids, exclude=exclude)

    awaiting = _count_awaiting_responses(scenario_file)
    if awaiting:
        console.print(f"[dim]{awaiting} attempt(s) still awaiting simulator responses — skipping those.[/dim]")

    if not pending:
        if awaiting:
            console.print("[success]All currently available feedback recorded.[/success]")
        else:
            console.print("[success]All feedback recorded.[/success]")
        return

    console.print(
        f"Collecting feedback for [bold]{len(pending)}[/bold] attempt(s). Press Ctrl-C to pause — progress is saved after each entry.\n"
    )
    if evaluator_id:
        console.print(f"Evaluator: [bold]{evaluator_id}[/bold]\n", style="dim")

    completed = 0
    try:
        for pos, (g_idx, s_idx, a_idx) in enumerate(pending):
            # Always reload from disk so we pick up concurrent changes
            scenario_file = load_scenario_file(path)
            group = scenario_file.scenario_groups[g_idx]
            scenario = group.scenarios[s_idx]
            attempt = scenario.attempts[a_idx]

            total_attempts_in_scenario = len(scenario.attempts)
            attempt_label = f"attempt {a_idx + 1}/{total_attempts_in_scenario}"

            console.print(f"\n[dim]── {pos + 1}/{len(pending)} ──[/dim]")

            feedback = _prompt_feedback(
                console=console,
                group=group,
                scenario=scenario,
                attempt=attempt,
                attempt_label=attempt_label,
            )

            # Save atomically after each entry
            sf = load_scenario_file(path)
            sf.scenario_groups[g_idx].scenarios[s_idx].attempts[a_idx].evaluator_feedback = feedback
            save_scenario_file(path, sf)
            completed += 1

            result_icon = "[green]✔[/green]" if feedback.liked else "[red]✗[/red]"
            console.print(f"  {result_icon} Feedback saved.", style="dim")

    except (KeyboardInterrupt, typer.Abort):
        console.print(
            f"\n[warning]Paused after {completed} entr{'y' if completed == 1 else 'ies'}. Run the command again to resume.[/warning]"
        )
        return

    if awaiting:
        console.print(
            f"\n[success]Feedback pass complete — {completed} feedback entr"
            f"{'y' if completed == 1 else 'ies'} recorded for available responses.[/success]"
        )
    else:
        console.print(f"\n[success]All feedback recorded — {completed} feedback entr{'y' if completed == 1 else 'ies'} recorded.[/success]")

generate

Scaffold a -test-cases.json file for a character.

Generates one scenario group per pressure category (using the category's example prompts as starter attempts) plus one scenario per entry in the character's scenarios[] array. All conversation_history fields start empty; the engine populates them when dcs hitl update runs.

build_scaffold(character, game)

Build a scaffold ScenarioFile for the given character.

Parameters:

Name Type Description Default
character dict

The character dict loaded from a seed file.

required
game str

The game name to use for all scenarios.

required

Returns:

Name Type Description
A ScenarioFile

class:ScenarioFile ready to serialise to JSON.

Source code in dcs_simulation_engine/hitl/generate.py
 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
def build_scaffold(character: dict, game: str) -> ScenarioFile:
    """Build a scaffold ScenarioFile for the given character.

    Args:
        character: The character dict loaded from a seed file.
        game: The game name to use for all scenarios.

    Returns:
        A :class:`ScenarioFile` ready to serialise to JSON.
    """
    hid = character["hid"]
    # Use a brief handle for prompt substitution — prefer hid over long description
    char_label = hid

    categories = load_pressure_categories()
    groups: list[ScenarioGroup] = []

    for cat in categories:
        cat_id: str = cat["id"]
        examples: list[dict] = cat.get("examples", [])

        # Build attempts from this category's example prompts (up to 3)
        attempts = [Attempt(player_message=_fill_template(ex["prompt"], hid, char_label)) for ex in examples[:3]]

        scenario = Scenario(
            id=f"{hid}-{cat_id}-001",
            description=f"{cat['description']}{hid}",
            game=game,
            pc_hid="NA",
            conversation_history=[],
            attempts=attempts,
        )

        group = ScenarioGroup(
            group_id=cat_id,
            label=cat_id.replace("_", " ").title(),
            expected_failure_mode=_FAILURE_MODE_TEMPLATES.get(
                cat_id,
                f"[TODO: describe the expected failure mode for '{cat_id}']",
            ),
            pressure_category=cat_id,
            scenarios=[scenario],
        )
        groups.append(group)

    # One extra scenario per character scenario context (from character.scenarios[])
    char_scenarios: list[dict] = character.get("scenarios", []) or []
    for idx, char_scenario in enumerate(char_scenarios):
        scenario_name: str = char_scenario.get("name", f"Scenario {idx + 1}")
        baseline: str = char_scenario.get("baseline_experience", "")
        context_desc = f"{scenario_name}: {baseline}" if baseline else scenario_name

        # Pick "direct_anti_character" as the pressure type for context-based scenarios
        cat_id = "direct_anti_character"
        cat_examples = next((c.get("examples", []) for c in categories if c["id"] == cat_id), [])
        attempts = [Attempt(player_message=_fill_template(ex["prompt"], hid, char_label)) for ex in cat_examples[:2]]

        scenario = Scenario(
            id=f"{hid}-context-{idx + 1:02d}",
            description=f"Character scenario context: {context_desc}",
            game=game,
            pc_hid="NA",
            conversation_history=[],
            attempts=attempts,
        )
        group = ScenarioGroup(
            group_id=f"context-{idx + 1:02d}",
            label=f"Character Context: {scenario_name}",
            expected_failure_mode=("NPC breaks character when situated in a specific scenario context"),
            pressure_category=cat_id,
            scenarios=[scenario],
        )
        groups.append(group)

    return ScenarioFile(
        npc_hid=hid,
        generated_at=datetime.now(timezone.utc).isoformat(),
        scenario_groups=groups,
    )
load_character(hid, db)

Load a character record by HID from the specified seed database.

Parameters:

Name Type Description Default
hid str

The character's human-readable ID.

required
db str

Which database to load from — "dev" or "prod".

required

Returns:

Type Description
dict

The character dict.

Raises:

Type Description
ValueError

If the character is not found.

FileNotFoundError

If the seed file does not exist.

Source code in dcs_simulation_engine/hitl/generate.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
def load_character(hid: str, db: str) -> dict:
    """Load a character record by HID from the specified seed database.

    Args:
        hid: The character's human-readable ID.
        db: Which database to load from — ``"dev"`` or ``"prod"``.

    Returns:
        The character dict.

    Raises:
        ValueError: If the character is not found.
        FileNotFoundError: If the seed file does not exist.
    """
    path = _DEV_CHARS_PATH if db == "dev" else _PROD_CHARS_PATH
    if not path.exists():
        raise FileNotFoundError(f"Seed file not found: {path}")
    characters = json.loads(path.read_text(encoding="utf-8"))
    for char in characters:
        if char.get("hid") == hid:
            return char
    available = sorted(c.get("hid", "?") for c in characters)
    raise ValueError(f"Character '{hid}' not found in {path.name}. Available HIDs: {', '.join(available)}")
load_pressure_categories()

Load the evaluation category definitions.

Source code in dcs_simulation_engine/hitl/generate.py
73
74
75
76
def load_pressure_categories() -> list[dict]:
    """Load the evaluation category definitions."""
    data = json.loads(_PRESSURE_CATEGORIES_PATH.read_text(encoding="utf-8"))
    return data["evaluation_categories"]
load_scenario_file(path)

Load and parse a scenarios JSON file.

Source code in dcs_simulation_engine/hitl/generate.py
174
175
176
177
def load_scenario_file(path: Path) -> ScenarioFile:
    """Load and parse a scenarios JSON file."""
    data = json.loads(path.read_text(encoding="utf-8"))
    return ScenarioFile.model_validate(data)
save_scaffold(scenario_file, path)

Serialise a ScenarioFile to JSON at the given path.

Source code in dcs_simulation_engine/hitl/generate.py
165
166
167
168
169
170
171
def save_scaffold(scenario_file: ScenarioFile, path: Path) -> None:
    """Serialise a ScenarioFile to JSON at the given path."""
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(
        json.dumps(scenario_file.model_dump(), indent=2, ensure_ascii=False),
        encoding="utf-8",
    )
save_scenario_file(path, scenario_file)

Write a ScenarioFile back to disk (atomic via temp file).

Source code in dcs_simulation_engine/hitl/generate.py
180
181
182
183
184
185
186
187
def save_scenario_file(path: Path, scenario_file: ScenarioFile) -> None:
    """Write a ScenarioFile back to disk (atomic via temp file)."""
    tmp = path.with_suffix(".tmp")
    tmp.write_text(
        json.dumps(scenario_file.model_dump(), indent=2, ensure_ascii=False),
        encoding="utf-8",
    )
    tmp.replace(path)
scenarios_path_for(hid)

Return the expected output path for a character's test cases file.

Source code in dcs_simulation_engine/hitl/generate.py
79
80
81
def scenarios_path_for(hid: str) -> Path:
    """Return the expected output path for a character's test cases file."""
    return _SCENARIOS_DIR / f"{hid}-test-cases.json"

responses

Async shared-history sync and simulator response generation for HITL scenarios.

ParentSessionMissingError

Bases: RuntimeError

Raised when a saved parent session can no longer be resumed.

Source code in dcs_simulation_engine/hitl/responses.py
14
15
class ParentSessionMissingError(RuntimeError):
    """Raised when a saved parent session can no longer be resumed."""
compute_status_counts(path, *, only=None, include_ids=None, exclude=None)

Return post-update counts for the selected scenarios.

Source code in dcs_simulation_engine/hitl/responses.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
def compute_status_counts(
    path: Path,
    *,
    only: list[str] | None = None,
    include_ids: list[str] | None = None,
    exclude: list[str] | None = None,
) -> dict[str, int]:
    """Return post-update counts for the selected scenarios."""
    summary = compute_status_summary(
        path,
        only=only,
        include_ids=include_ids,
        exclude=exclude,
    )
    return {
        "attempts_missing_simulator_responses": summary["attempts_without_simulator_responses"],
        "attempts_missing_player_feedback": summary["attempts_without_player_feedback"],
        "empty_conversation_histories": summary["empty_conversation_histories"],
        "conversation_histories_missing_simulator_reply": summary["conversation_histories_missing_simulator_reply"],
    }
compute_status_summary(path, *, only=None, include_ids=None, exclude=None)

Return totals plus incomplete-work counts for the selected scenarios.

Source code in dcs_simulation_engine/hitl/responses.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
82
83
84
85
86
87
88
89
90
91
92
93
def compute_status_summary(
    path: Path,
    *,
    only: list[str] | None = None,
    include_ids: list[str] | None = None,
    exclude: list[str] | None = None,
) -> dict[str, int]:
    """Return totals plus incomplete-work counts for the selected scenarios."""
    scenario_file = load_scenario_file(path)
    selected = _selected_scenarios(
        scenario_file,
        only=only,
        include_ids=include_ids,
        exclude=exclude,
    )

    summary = {
        "scenario_groups_total": len({g_idx for g_idx, _s_idx in selected}),
        "scenarios_total": len(selected),
        "attempts_total": 0,
        "attempts_without_simulator_responses": 0,
        "attempts_without_player_feedback": 0,
        "empty_conversation_histories": 0,
        "conversation_histories_missing_simulator_reply": 0,
    }

    for g_idx, s_idx in selected:
        scenario = scenario_file.scenario_groups[g_idx].scenarios[s_idx]
        summary["attempts_total"] += len(scenario.attempts)
        if not scenario.conversation_history:
            summary["empty_conversation_histories"] += 1
        elif _history_last_role(scenario.conversation_history) == "user":
            summary["conversation_histories_missing_simulator_reply"] += 1

        for attempt in scenario.attempts:
            if attempt.simulator_response is None:
                summary["attempts_without_simulator_responses"] += 1
            elif attempt.evaluator_feedback is None:
                summary["attempts_without_player_feedback"] += 1

    return summary
generate_responses(path, *, server_url='http://localhost:8080', api_key='', only=None, include_ids=None, exclude=None, concurrency=4, run_attempts=True, regenerate_parent_session=False, console) async

Sync shared history and optionally generate simulator responses for attempts.

Source code in dcs_simulation_engine/hitl/responses.py
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
async def generate_responses(
    path: Path,
    *,
    server_url: str = "http://localhost:8080",
    api_key: str = "",
    only: list[str] | None = None,
    include_ids: list[str] | None = None,
    exclude: list[str] | None = None,
    concurrency: int = 4,
    run_attempts: bool = True,
    regenerate_parent_session: bool = False,
    console,
) -> None:
    """Sync shared history and optionally generate simulator responses for attempts."""
    scenario_file = load_scenario_file(path)
    selected = _selected_scenarios(
        scenario_file,
        only=only,
        include_ids=include_ids,
        exclude=exclude,
    )

    if not selected:
        console.print("[success]No matching scenarios selected.[/success]")
        return

    total_attempts = sum(
        1
        for g_idx, s_idx in selected
        for attempt in scenario_file.scenario_groups[g_idx].scenarios[s_idx].attempts
        if attempt.simulator_response is None
    )
    action = "Synchronizing shared history only" if not run_attempts else "Synchronizing history and generating responses"
    console.print(
        f"{action} for [bold]{len(selected)}[/bold] scenario(s)"
        + (f" with [bold]{total_attempts}[/bold] pending attempt(s)..." if run_attempts else "...")
    )

    lock = asyncio.Lock()
    sem = asyncio.Semaphore(concurrency)

    async def _bounded(g_idx: int, s_idx: int):
        sid = scenario_file.scenario_groups[g_idx].scenarios[s_idx].id
        async with sem:
            try:
                warnings = await _run_scenario_async(
                    path=path,
                    scenario_file=scenario_file,
                    group_idx=g_idx,
                    scenario_idx=s_idx,
                    run_attempts=run_attempts,
                    regenerate_parent_session=regenerate_parent_session,
                    server_url=server_url,
                    api_key=api_key,
                    lock=lock,
                )
                return sid, None, warnings
            except Exception as exc:  # noqa: BLE001
                return sid, str(exc), []

    with Progress(
        SpinnerColumn(),
        TextColumn("[progress.description]{task.description}"),
        TimeElapsedColumn(),
        console=console,
    ) as progress:
        task = progress.add_task("Running HITL update...", total=len(selected))

        async def _runner():
            results = []
            for coro in asyncio.as_completed([_bounded(g_idx, s_idx) for g_idx, s_idx in selected]):
                result = await coro
                progress.advance(task)
                results.append(result)
            return results

        results = await _runner()

    failures = [(sid, err) for sid, err, _warnings in results if err]
    if failures:
        for sid, err in failures:
            console.print(f"[error]✖ {sid}: {err}[/error]")
        raise RuntimeError(f"{len(failures)} scenario(s) failed during update")

    warnings_by_scenario: dict[str, list[str]] = defaultdict(list)
    for sid, _err, warnings in results:
        warnings_by_scenario[sid].extend(warnings)

    for sid in sorted(warnings_by_scenario):
        for warning in warnings_by_scenario[sid]:
            console.print(f"[warning]{warning}[/warning]")

    console.print("[success]✔ HITL update complete.[/success]")
render_status_summary(summary, *, title='Scenario File Summary')

Render a stable human-readable summary block.

Source code in dcs_simulation_engine/hitl/responses.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def render_status_summary(
    summary: dict[str, int],
    *,
    title: str = "Scenario File Summary",
) -> str:
    """Render a stable human-readable summary block."""
    scenarios_total = summary["scenarios_total"]
    attempts_total = summary["attempts_total"]
    lines = [
        f"[bold]{title}[/bold]",
        f"  {summary['scenario_groups_total']} scenario group(s)",
        f"  {scenarios_total} scenario(s)",
        f"  {attempts_total} attempt(s)",
        (f"  {summary['attempts_without_simulator_responses']}/{attempts_total} attempt(s) without simulator responses"),
        (f"  {summary['attempts_without_player_feedback']}/{attempts_total} attempt(s) without player feedback"),
        (f"  {summary['empty_conversation_histories']}/{scenarios_total} empty conversation history/histories"),
        (
            f"  {summary['conversation_histories_missing_simulator_reply']}/{scenarios_total} "
            "conversation history/histories missing a simulator reply"
        ),
    ]
    return "\n".join(lines)

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: {}", " ".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 {}), 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: {}", 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("{} 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
91
92
93
94
95
96
@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
82
83
84
85
86
87
88
@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
106
107
108
109
110
@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 run deployment.

Source code in dcs_simulation_engine/infra/remote.py
52
53
54
55
56
57
58
@dataclass(frozen=True)
class RemoteAppNames:
    """Concrete Fly app names for one remote run deployment."""

    api_app: str
    ui_app: str
    db_app: str
RemoteDeployContext dataclass

Build context and artifact directory used for one remote deploy.

Source code in dcs_simulation_engine/infra/remote.py
131
132
133
134
135
136
@dataclass(frozen=True)
class RemoteDeployContext:
    """Build context and artifact directory used for one remote deploy."""

    root: Path
    artifact_dir: Path
RemoteDeploymentResult dataclass

Structured output returned after a successful remote deployment.

Source code in dcs_simulation_engine/infra/remote.py
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@dataclass(frozen=True)
class RemoteDeploymentResult:
    """Structured output returned after a successful remote deployment."""

    run_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
77
78
79
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
122
123
124
125
126
127
128
@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
48
49
class RemoteLifecycleError(RuntimeError):
    """Raised when a remote Fly lifecycle operation fails."""
RemoteRenderedFlyConfigs dataclass

Rendered Fly TOML contents for one remote run deployment.

Source code in dcs_simulation_engine/infra/remote.py
113
114
115
116
117
118
119
@dataclass(frozen=True)
class RemoteRenderedFlyConfigs:
    """Rendered Fly TOML contents for one remote run deployment."""

    api_toml: str
    ui_toml: str
    db_toml: str
RemoteStatusResult dataclass

Authenticated run status returned for CLI presentation.

Source code in dcs_simulation_engine/infra/remote.py
139
140
141
142
143
144
145
146
147
148
149
@dataclass(frozen=True)
class RemoteStatusResult:
    """Authenticated run status returned for CLI presentation."""

    api_url: str
    run_name: str
    run_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
147
148
149
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
 99
100
101
102
103
@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
193
194
195
def app_url(app_name: str) -> str:
    """Return the public Fly URL for an app."""
    return f"https://{app_name}.fly.dev"
deploy_remote_run(*, config=None, 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
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
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
def deploy_remote_run(
    *,
    config: str | Path | None = None,
    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."""
    assets = resolve_assets(Path.cwd())
    mongo_seed_path = _resolve_remote_mongo_seed_path(mongo_seed_path, assets=assets)
    if admin_key is not None:
        admin_key = validate_access_key(admin_key)
    deployment_name, config_path = _resolve_remote_deployment_target(config=config, assets=assets)
    selected_apps = _normalize_deploy_apps(deploy_apps)
    is_full_deploy = selected_apps == list(REMOTE_DEPLOY_APP_ORDER)
    names = derive_remote_app_names(
        run_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_run_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,
            ),
        ),
        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),
    )
    admin_api_key: str | None = None
    with _remote_deploy_context(assets=assets, run_name=deployment_name, api_url=api_url) as deploy_context:
        fly_configs = _write_remote_fly_configs(
            output_dir=deploy_context.artifact_dir,
            names=names,
            rendered_configs=rendered_configs,
        )
        _write_deployment_run_config(
            output_dir=deploy_context.artifact_dir,
            run_name=deployment_name,
            source_path=config_path,
        )

        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=deploy_context.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=deploy_context.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)

        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=deploy_context.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_run_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_run_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(
        run_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(*, run_name, api_app=None, ui_app=None, db_app=None)

Return explicit or derived app names for a remote run deployment.

Source code in dcs_simulation_engine/infra/remote.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
def derive_remote_app_names(
    *,
    run_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 run deployment."""
    slug = slugify_run_name(run_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
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
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()
            run_name = payload.get("run_name")
            if not isinstance(run_name, str) or not run_name:
                raise RemoteLifecycleError("Remote status response did not include a run name.")
            headers = {"Authorization": f"Bearer {admin_key}"}
            run_response = client.get("/api/run/status", headers=headers)
            run_response.raise_for_status()
            run_status = run_response.json()
    except httpx.HTTPError as exc:
        raise RemoteLifecycleError(f"Failed to fetch remote deployment status: {exc}") from exc

    return RemoteStatusResult(
        api_url=uri,
        run_name=run_name,
        run_status=run_status,
    )
load_run_config(config, *, assets=None)

Resolve and load the run config selected for remote deployment.

Source code in dcs_simulation_engine/infra/remote.py
728
729
730
731
732
def load_run_config(config: str | Path, *, assets: DCSAssets | None = None) -> tuple[Path, RunConfig]:
    """Resolve and load the run config selected for remote deployment."""
    assets = assets or resolve_assets(Path.cwd())
    resolved = _resolve_remote_run_config_path(config, assets=assets)
    return resolved, RunConfig.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
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
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_run_name(value)

Normalize a run name into a Fly-app-safe slug.

Source code in dcs_simulation_engine/infra/remote.py
155
156
157
158
159
160
161
def slugify_run_name(value: str) -> str:
    """Normalize a run 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("Run name does not produce a valid Fly app slug.")
    return slug
stop_remote_run(*, 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 run.

Source code in dcs_simulation_engine/infra/remote.py
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
def stop_remote_run(
    *,
    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 run."""
    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

observability

Runtime observability helpers.

PersistentLogCapture

Capture selected Loguru records with a non-blocking persistence writer.

Source code in dcs_simulation_engine/observability/log_capture.py
 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
class PersistentLogCapture:
    """Capture selected Loguru records with a non-blocking persistence writer."""

    def __init__(
        self,
        *,
        writer: Any,
        source: str,
        run_name: str | None = None,
        min_level_no: int = 30,
        throttle_seconds: int = 60,
        max_throttle_keys: int = 2000,
    ) -> None:
        """Store capture policy and the injected persistence writer."""
        self._source = source
        self._run_name = run_name
        self._min_level_no = min_level_no
        self._default_throttle_seconds = throttle_seconds
        self._max_throttle_keys = max(1, max_throttle_keys)
        self._writer = writer
        self._sink_id: int | None = None
        self._throttle: dict[str, _ThrottleState] = {}
        self._started = False
        self._closed = False

    async def start(self) -> None:
        """Start the backing writer."""
        if self._started:
            return
        await self._writer.start()
        self._started = True

    def install(self) -> None:
        """Attach this capture sink to Loguru."""
        if not self._started:
            raise RuntimeError("PersistentLogCapture.start() must be awaited before install().")
        if self._sink_id is None:
            self._sink_id = logger.add(self, level="DEBUG", enqueue=False, catch=True)

    async def close(self) -> None:
        """Remove the Loguru sink and flush queued/suppressed log events."""
        if self._closed:
            return
        self._closed = True
        if self._sink_id is not None:
            logger.remove(self._sink_id)
            self._sink_id = None
        self.flush_suppressed()
        await self._writer.close()

    def __call__(self, message: Any) -> None:
        """Loguru sink entrypoint."""
        if self._closed:
            return
        try:
            doc = self._record_to_doc(message.record)
        except Exception as exc:
            sys.stderr.write(f"Failed to normalize log event for persistence: {exc}\n")
            return
        if doc is None:
            return
        self._capture_doc(doc)

    def flush_suppressed(self) -> None:
        """Emit summary rows for throttled events that never got a later write."""
        for state in list(self._throttle.values()):
            if state.suppressed_count <= 0:
                continue
            summary = dict(state.last_doc)
            summary["event_id"] = str(uuid4())
            summary["throttled"] = True
            summary["suppressed_count"] = state.suppressed_count
            summary["first_seen_at"] = state.first_seen_at
            summary["last_seen_at"] = state.last_seen_at
            self._writer.enqueue_nowait(summary)
            state.suppressed_count = 0

    def _record_to_doc(self, record: dict[str, Any]) -> dict[str, Any] | None:
        extra = dict(record.get("extra") or {})
        if extra.get("persist_log") is False:
            return None

        level = record.get("level")
        level_no = int(getattr(level, "no", 0) or 0)
        persist_log = bool(extra.get("persist_log", False))
        if level_no < self._min_level_no and not persist_log:
            return None

        event_ts = record.get("time") or utc_now()
        if not isinstance(event_ts, datetime):
            event_ts = utc_now()

        file_info = record.get("file")
        message = str(record.get("message") or "")
        doc: dict[str, Any] = {
            "event_id": str(uuid4()),
            "schema_version": 1,
            EVENT_TS_FIELD: event_ts,
            SOURCE_FIELD: str(extra.get("source") or self._source),
            "run_name": str(extra.get("run_name") or self._run_name or ""),
            "level": str(getattr(level, "name", record.get("level", ""))),
            "level_no": level_no,
            "message": message,
            "module": record.get("module"),
            "function": record.get("function"),
            "line": record.get("line"),
            "file_name": getattr(file_info, "name", None),
            "file_path": getattr(file_info, "path", None),
            "exception": _serialize_exception(record.get("exception")),
            "fingerprint": "",
            "throttled": False,
            "suppressed_count": 0,
            "first_seen_at": event_ts,
            "last_seen_at": event_ts,
            "_capture_throttle": extra.get("throttle", True),
            "_capture_throttle_seconds": extra.get("throttle_seconds"),
        }

        for key in _CONTEXT_EXTRA_KEYS:
            if key in extra and extra[key] is not None:
                doc[key] = _sanitize(extra[key])

        detail = extra.get("detail")
        if detail is not None:
            doc["detail"] = _sanitize(detail)

        remaining_extra = {
            key: value
            for key, value in extra.items()
            if key not in _CONTROL_EXTRA_KEYS and key not in _CONTEXT_EXTRA_KEYS and key != "source"
        }
        if remaining_extra:
            doc["extra"] = _sanitize(remaining_extra)

        throttle_key = extra.get("throttle_key")
        doc["fingerprint"] = str(throttle_key) if throttle_key else _fingerprint(doc)
        return doc

    def _capture_doc(self, doc: dict[str, Any]) -> None:
        throttle_seconds = _throttle_seconds(doc, self._default_throttle_seconds)
        if throttle_seconds <= 0:
            self._writer.enqueue_nowait(doc)
            return

        key = str(doc["fingerprint"])
        now = doc[EVENT_TS_FIELD]
        state = self._throttle.get(key)
        if state is None:
            self._writer.enqueue_nowait(doc)
            self._throttle[key] = _ThrottleState(
                first_seen_at=now,
                last_seen_at=now,
                next_emit_at=now + timedelta(seconds=throttle_seconds),
                suppressed_count=0,
                last_doc=doc,
            )
            self._prune_throttle_state()
            return

        if now < state.next_emit_at:
            state.suppressed_count += 1
            state.last_seen_at = now
            state.last_doc = doc
            return

        if state.suppressed_count > 0:
            doc["throttled"] = True
            doc["suppressed_count"] = state.suppressed_count
            doc["first_seen_at"] = state.first_seen_at
            doc["last_seen_at"] = now

        self._writer.enqueue_nowait(doc)
        self._throttle[key] = _ThrottleState(
            first_seen_at=now,
            last_seen_at=now,
            next_emit_at=now + timedelta(seconds=throttle_seconds),
            suppressed_count=0,
            last_doc=doc,
        )

    def _prune_throttle_state(self) -> None:
        while len(self._throttle) > self._max_throttle_keys:
            key = next(iter(self._throttle))
            self._throttle.pop(key, None)
__call__(message)

Loguru sink entrypoint.

Source code in dcs_simulation_engine/observability/log_capture.py
107
108
109
110
111
112
113
114
115
116
117
118
def __call__(self, message: Any) -> None:
    """Loguru sink entrypoint."""
    if self._closed:
        return
    try:
        doc = self._record_to_doc(message.record)
    except Exception as exc:
        sys.stderr.write(f"Failed to normalize log event for persistence: {exc}\n")
        return
    if doc is None:
        return
    self._capture_doc(doc)
__init__(*, writer, source, run_name=None, min_level_no=30, throttle_seconds=60, max_throttle_keys=2000)

Store capture policy and the injected persistence writer.

Source code in dcs_simulation_engine/observability/log_capture.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def __init__(
    self,
    *,
    writer: Any,
    source: str,
    run_name: str | None = None,
    min_level_no: int = 30,
    throttle_seconds: int = 60,
    max_throttle_keys: int = 2000,
) -> None:
    """Store capture policy and the injected persistence writer."""
    self._source = source
    self._run_name = run_name
    self._min_level_no = min_level_no
    self._default_throttle_seconds = throttle_seconds
    self._max_throttle_keys = max(1, max_throttle_keys)
    self._writer = writer
    self._sink_id: int | None = None
    self._throttle: dict[str, _ThrottleState] = {}
    self._started = False
    self._closed = False
close() async

Remove the Loguru sink and flush queued/suppressed log events.

Source code in dcs_simulation_engine/observability/log_capture.py
 96
 97
 98
 99
100
101
102
103
104
105
async def close(self) -> None:
    """Remove the Loguru sink and flush queued/suppressed log events."""
    if self._closed:
        return
    self._closed = True
    if self._sink_id is not None:
        logger.remove(self._sink_id)
        self._sink_id = None
    self.flush_suppressed()
    await self._writer.close()
flush_suppressed()

Emit summary rows for throttled events that never got a later write.

Source code in dcs_simulation_engine/observability/log_capture.py
120
121
122
123
124
125
126
127
128
129
130
131
132
def flush_suppressed(self) -> None:
    """Emit summary rows for throttled events that never got a later write."""
    for state in list(self._throttle.values()):
        if state.suppressed_count <= 0:
            continue
        summary = dict(state.last_doc)
        summary["event_id"] = str(uuid4())
        summary["throttled"] = True
        summary["suppressed_count"] = state.suppressed_count
        summary["first_seen_at"] = state.first_seen_at
        summary["last_seen_at"] = state.last_seen_at
        self._writer.enqueue_nowait(summary)
        state.suppressed_count = 0
install()

Attach this capture sink to Loguru.

Source code in dcs_simulation_engine/observability/log_capture.py
89
90
91
92
93
94
def install(self) -> None:
    """Attach this capture sink to Loguru."""
    if not self._started:
        raise RuntimeError("PersistentLogCapture.start() must be awaited before install().")
    if self._sink_id is None:
        self._sink_id = logger.add(self, level="DEBUG", enqueue=False, catch=True)
start() async

Start the backing writer.

Source code in dcs_simulation_engine/observability/log_capture.py
82
83
84
85
86
87
async def start(self) -> None:
    """Start the backing writer."""
    if self._started:
        return
    await self._writer.start()
    self._started = True

log_capture

Loguru sink that captures important engine logs with an injected writer.

PersistentLogCapture

Capture selected Loguru records with a non-blocking persistence writer.

Source code in dcs_simulation_engine/observability/log_capture.py
 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
class PersistentLogCapture:
    """Capture selected Loguru records with a non-blocking persistence writer."""

    def __init__(
        self,
        *,
        writer: Any,
        source: str,
        run_name: str | None = None,
        min_level_no: int = 30,
        throttle_seconds: int = 60,
        max_throttle_keys: int = 2000,
    ) -> None:
        """Store capture policy and the injected persistence writer."""
        self._source = source
        self._run_name = run_name
        self._min_level_no = min_level_no
        self._default_throttle_seconds = throttle_seconds
        self._max_throttle_keys = max(1, max_throttle_keys)
        self._writer = writer
        self._sink_id: int | None = None
        self._throttle: dict[str, _ThrottleState] = {}
        self._started = False
        self._closed = False

    async def start(self) -> None:
        """Start the backing writer."""
        if self._started:
            return
        await self._writer.start()
        self._started = True

    def install(self) -> None:
        """Attach this capture sink to Loguru."""
        if not self._started:
            raise RuntimeError("PersistentLogCapture.start() must be awaited before install().")
        if self._sink_id is None:
            self._sink_id = logger.add(self, level="DEBUG", enqueue=False, catch=True)

    async def close(self) -> None:
        """Remove the Loguru sink and flush queued/suppressed log events."""
        if self._closed:
            return
        self._closed = True
        if self._sink_id is not None:
            logger.remove(self._sink_id)
            self._sink_id = None
        self.flush_suppressed()
        await self._writer.close()

    def __call__(self, message: Any) -> None:
        """Loguru sink entrypoint."""
        if self._closed:
            return
        try:
            doc = self._record_to_doc(message.record)
        except Exception as exc:
            sys.stderr.write(f"Failed to normalize log event for persistence: {exc}\n")
            return
        if doc is None:
            return
        self._capture_doc(doc)

    def flush_suppressed(self) -> None:
        """Emit summary rows for throttled events that never got a later write."""
        for state in list(self._throttle.values()):
            if state.suppressed_count <= 0:
                continue
            summary = dict(state.last_doc)
            summary["event_id"] = str(uuid4())
            summary["throttled"] = True
            summary["suppressed_count"] = state.suppressed_count
            summary["first_seen_at"] = state.first_seen_at
            summary["last_seen_at"] = state.last_seen_at
            self._writer.enqueue_nowait(summary)
            state.suppressed_count = 0

    def _record_to_doc(self, record: dict[str, Any]) -> dict[str, Any] | None:
        extra = dict(record.get("extra") or {})
        if extra.get("persist_log") is False:
            return None

        level = record.get("level")
        level_no = int(getattr(level, "no", 0) or 0)
        persist_log = bool(extra.get("persist_log", False))
        if level_no < self._min_level_no and not persist_log:
            return None

        event_ts = record.get("time") or utc_now()
        if not isinstance(event_ts, datetime):
            event_ts = utc_now()

        file_info = record.get("file")
        message = str(record.get("message") or "")
        doc: dict[str, Any] = {
            "event_id": str(uuid4()),
            "schema_version": 1,
            EVENT_TS_FIELD: event_ts,
            SOURCE_FIELD: str(extra.get("source") or self._source),
            "run_name": str(extra.get("run_name") or self._run_name or ""),
            "level": str(getattr(level, "name", record.get("level", ""))),
            "level_no": level_no,
            "message": message,
            "module": record.get("module"),
            "function": record.get("function"),
            "line": record.get("line"),
            "file_name": getattr(file_info, "name", None),
            "file_path": getattr(file_info, "path", None),
            "exception": _serialize_exception(record.get("exception")),
            "fingerprint": "",
            "throttled": False,
            "suppressed_count": 0,
            "first_seen_at": event_ts,
            "last_seen_at": event_ts,
            "_capture_throttle": extra.get("throttle", True),
            "_capture_throttle_seconds": extra.get("throttle_seconds"),
        }

        for key in _CONTEXT_EXTRA_KEYS:
            if key in extra and extra[key] is not None:
                doc[key] = _sanitize(extra[key])

        detail = extra.get("detail")
        if detail is not None:
            doc["detail"] = _sanitize(detail)

        remaining_extra = {
            key: value
            for key, value in extra.items()
            if key not in _CONTROL_EXTRA_KEYS and key not in _CONTEXT_EXTRA_KEYS and key != "source"
        }
        if remaining_extra:
            doc["extra"] = _sanitize(remaining_extra)

        throttle_key = extra.get("throttle_key")
        doc["fingerprint"] = str(throttle_key) if throttle_key else _fingerprint(doc)
        return doc

    def _capture_doc(self, doc: dict[str, Any]) -> None:
        throttle_seconds = _throttle_seconds(doc, self._default_throttle_seconds)
        if throttle_seconds <= 0:
            self._writer.enqueue_nowait(doc)
            return

        key = str(doc["fingerprint"])
        now = doc[EVENT_TS_FIELD]
        state = self._throttle.get(key)
        if state is None:
            self._writer.enqueue_nowait(doc)
            self._throttle[key] = _ThrottleState(
                first_seen_at=now,
                last_seen_at=now,
                next_emit_at=now + timedelta(seconds=throttle_seconds),
                suppressed_count=0,
                last_doc=doc,
            )
            self._prune_throttle_state()
            return

        if now < state.next_emit_at:
            state.suppressed_count += 1
            state.last_seen_at = now
            state.last_doc = doc
            return

        if state.suppressed_count > 0:
            doc["throttled"] = True
            doc["suppressed_count"] = state.suppressed_count
            doc["first_seen_at"] = state.first_seen_at
            doc["last_seen_at"] = now

        self._writer.enqueue_nowait(doc)
        self._throttle[key] = _ThrottleState(
            first_seen_at=now,
            last_seen_at=now,
            next_emit_at=now + timedelta(seconds=throttle_seconds),
            suppressed_count=0,
            last_doc=doc,
        )

    def _prune_throttle_state(self) -> None:
        while len(self._throttle) > self._max_throttle_keys:
            key = next(iter(self._throttle))
            self._throttle.pop(key, None)
__call__(message)

Loguru sink entrypoint.

Source code in dcs_simulation_engine/observability/log_capture.py
107
108
109
110
111
112
113
114
115
116
117
118
def __call__(self, message: Any) -> None:
    """Loguru sink entrypoint."""
    if self._closed:
        return
    try:
        doc = self._record_to_doc(message.record)
    except Exception as exc:
        sys.stderr.write(f"Failed to normalize log event for persistence: {exc}\n")
        return
    if doc is None:
        return
    self._capture_doc(doc)
__init__(*, writer, source, run_name=None, min_level_no=30, throttle_seconds=60, max_throttle_keys=2000)

Store capture policy and the injected persistence writer.

Source code in dcs_simulation_engine/observability/log_capture.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def __init__(
    self,
    *,
    writer: Any,
    source: str,
    run_name: str | None = None,
    min_level_no: int = 30,
    throttle_seconds: int = 60,
    max_throttle_keys: int = 2000,
) -> None:
    """Store capture policy and the injected persistence writer."""
    self._source = source
    self._run_name = run_name
    self._min_level_no = min_level_no
    self._default_throttle_seconds = throttle_seconds
    self._max_throttle_keys = max(1, max_throttle_keys)
    self._writer = writer
    self._sink_id: int | None = None
    self._throttle: dict[str, _ThrottleState] = {}
    self._started = False
    self._closed = False
close() async

Remove the Loguru sink and flush queued/suppressed log events.

Source code in dcs_simulation_engine/observability/log_capture.py
 96
 97
 98
 99
100
101
102
103
104
105
async def close(self) -> None:
    """Remove the Loguru sink and flush queued/suppressed log events."""
    if self._closed:
        return
    self._closed = True
    if self._sink_id is not None:
        logger.remove(self._sink_id)
        self._sink_id = None
    self.flush_suppressed()
    await self._writer.close()
flush_suppressed()

Emit summary rows for throttled events that never got a later write.

Source code in dcs_simulation_engine/observability/log_capture.py
120
121
122
123
124
125
126
127
128
129
130
131
132
def flush_suppressed(self) -> None:
    """Emit summary rows for throttled events that never got a later write."""
    for state in list(self._throttle.values()):
        if state.suppressed_count <= 0:
            continue
        summary = dict(state.last_doc)
        summary["event_id"] = str(uuid4())
        summary["throttled"] = True
        summary["suppressed_count"] = state.suppressed_count
        summary["first_seen_at"] = state.first_seen_at
        summary["last_seen_at"] = state.last_seen_at
        self._writer.enqueue_nowait(summary)
        state.suppressed_count = 0
install()

Attach this capture sink to Loguru.

Source code in dcs_simulation_engine/observability/log_capture.py
89
90
91
92
93
94
def install(self) -> None:
    """Attach this capture sink to Loguru."""
    if not self._started:
        raise RuntimeError("PersistentLogCapture.start() must be awaited before install().")
    if self._sink_id is None:
        self._sink_id = logger.add(self, level="DEBUG", enqueue=False, catch=True)
start() async

Start the backing writer.

Source code in dcs_simulation_engine/observability/log_capture.py
82
83
84
85
86
87
async def start(self) -> None:
    """Start the backing writer."""
    if self._started:
        return
    await self._writer.start()
    self._started = True

reporting

Reporting helpers for DCS simulation results.

AnalysisData dataclass

All analysis-ready DataFrames loaded from a single results directory.

Source code in dcs_simulation_engine/reporting/loader.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
@dataclass
class AnalysisData:
    """All analysis-ready DataFrames loaded from a single results directory."""

    results_dir: Path

    # Raw metadata
    manifest: dict
    run: dict  # first record from runs.json, or {}

    # Core DataFrames
    runs_df: pd.DataFrame  # sessions — one row per run
    players_df: pd.DataFrame  # players (PII columns dropped)
    player_forms_df: pd.DataFrame  # player-scoped form payloads
    transcripts_df: pd.DataFrame  # session_events — one row per event
    assignments_df: pd.DataFrame  # assignments — one row per assignment
    feedback_df: pd.DataFrame  # flattened form answers
    event_feedback_df: pd.DataFrame  # inline per-message feedback from session_events
    logs_df: pd.DataFrame  # log events (empty if no logs/ dir)
    logs_source: str  # logs.json, logs/*.log, or "" when no log source was found
    characters_df: pd.DataFrame  # characters
    errors_df: pd.DataFrame  # WARNING/ERROR/CRITICAL subset of logs_df

    @property
    def runs_enriched_df(self) -> pd.DataFrame:
        """runs_df left-joined with non-PII player columns."""
        df = self.runs_df.copy()
        if self.players_df.empty or "access_key" not in self.players_df.columns:
            return df
        join_cols = [c for c in self.players_df.columns if c not in {"_id"}]
        player_sub = self.players_df[join_cols].rename(columns={"access_key": "player_id"})
        return df.merge(player_sub, on="player_id", how="left")
runs_enriched_df property

runs_df left-joined with non-PII player columns.

load_all(results_dir)

Load all result files from results_dir into an :class:AnalysisData.

Source code in dcs_simulation_engine/reporting/loader.py
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
def load_all(results_dir: str | Path) -> AnalysisData:
    """Load all result files from *results_dir* into an :class:`AnalysisData`."""
    results_dir = Path(results_dir).resolve()

    manifest = _load_manifest(results_dir)
    run = _load_run(results_dir)
    runs_df = _load_runs(results_dir)
    players_df = _load_players(results_dir)
    player_forms_df = _load_player_forms(results_dir)
    transcripts_df = _load_transcripts(results_dir)
    assignments_df = _load_assignments(results_dir)
    feedback_df = _build_feedback(assignments_df, player_forms_df)
    event_feedback_df = _build_event_feedback(transcripts_df, runs_df)
    characters_df = _load_characters(results_dir)
    logs_source = _logs_source_label(results_dir)
    logs_df = _load_logs_safe(results_dir)
    errors_df = _filter_errors(logs_df)

    return AnalysisData(
        results_dir=results_dir,
        manifest=manifest,
        run=run,
        runs_df=runs_df,
        players_df=players_df,
        player_forms_df=player_forms_df,
        transcripts_df=transcripts_df,
        assignments_df=assignments_df,
        feedback_df=feedback_df,
        event_feedback_df=event_feedback_df,
        logs_df=logs_df,
        logs_source=logs_source,
        characters_df=characters_df,
        errors_df=errors_df,
    )

auto

Auto-analysis pipeline.

Entry point:

from dcs_simulation_engine.reporting.auto import run_analysis
html = run_analysis(data, title="My Study")

Or via CLI:

python -m analysis.auto <results_dir> [--title "..."] [--open]
resolve_sections(only, include, exclude)

Return an ordered section list derived from the flag arguments.

Exactly one of only, include, exclude may be non-None. Raises ValueError for invalid slug names or mutual-exclusion violations.

Source code in dcs_simulation_engine/reporting/auto/__init__.py
 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
def resolve_sections(
    only: list[str] | None,
    include: list[str] | None,
    exclude: list[str] | None,
) -> list[tuple]:
    """Return an ordered section list derived from the flag arguments.

    Exactly one of *only*, *include*, *exclude* may be non-None.
    Raises ValueError for invalid slug names or mutual-exclusion violations.
    """
    active = [f for f in (only, include, exclude) if f is not None]
    if len(active) > 1:
        raise ValueError("--only, --include, and --exclude are mutually exclusive.")

    def _validate(slugs: list[str]) -> None:
        unknown = [s for s in slugs if s not in VALID_SECTION_SLUGS]
        if unknown:
            valid = ", ".join(sorted(VALID_SECTION_SLUGS))
            raise ValueError(f"unknown section name(s): {', '.join(repr(s) for s in unknown)}. Valid: {valid}")

    if only is not None:
        _validate(only)
        candidate = frozenset(only)
    elif include is not None:
        _validate(include)
        candidate = _DEFAULT_SECTION_SLUGS | frozenset(include)
    elif exclude is not None:
        _validate(exclude)
        candidate = _DEFAULT_SECTION_SLUGS - frozenset(exclude)
    else:
        candidate = _DEFAULT_SECTION_SLUGS

    result: list[tuple] = []
    pending_group: tuple | None = None
    for entry in SECTIONS:
        anchor, title, module, kind = entry
        if kind == "group":
            pending_group = entry
            continue
        if anchor in candidate:
            # Only emit a pending group label when a sub-section follows it.
            # Top-level sections (kind="top") are not children of any group.
            if pending_group is not None and kind == "sub":
                result.append(pending_group)
            pending_group = None
            result.append(entry)
    return result
run_analysis(data, title='Results Report', with_todos=False, sections=None)

Render all registered sections and return the complete HTML string.

Source code in dcs_simulation_engine/reporting/auto/__init__.py
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
def run_analysis(
    data: AnalysisData,
    title: str = "Results Report",
    with_todos: bool = False,
    sections: list | None = None,
) -> str:
    """Render all registered sections and return the complete HTML string."""
    if sections is None:
        sections = SECTIONS
    rendered: list[tuple[str | None, str, str, str]] = []
    for anchor, section_title, module, kind in sections:
        if kind == "group":
            rendered.append((None, section_title, "", "group"))
            continue
        try:
            fragment = module.render(data)
        except Exception as exc:
            fragment = f'<div class="alert alert-danger"><strong>Error rendering &ldquo;{section_title}&rdquo;:</strong> {exc}</div>'
        if with_todos:
            fragment = fragment + _TODO_PLACEHOLDER
        rendered.append((anchor, section_title, fragment, kind))

    artifacts = {
        "raw_results": {
            "b64": _raw_results_b64(data.results_dir),
            "filename": f"{data.results_dir.name}.zip",
            "mime": "application/zip",
        },
        "run_config": {"b64": _run_config_b64(data), "filename": "run_config.yml", "mime": "text/yaml"},
    }

    return build_html(rendered, title=title, artifacts=artifacts)
run_coverage_report(repo_root=None, hids_filter=None, db='prod')

Render the character coverage report and return the complete HTML string.

Loads character data directly from database_seeds/; does not use AnalysisData.

Source code in dcs_simulation_engine/reporting/auto/__init__.py
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
def run_coverage_report(
    repo_root: Path | None = None,
    hids_filter: list[str] | None = None,
    db: str = "prod",
) -> str:
    """Render the character coverage report and return the complete HTML string.

    Loads character data directly from database_seeds/; does not use AnalysisData.
    """
    import json as _json

    from dcs_simulation_engine.reporting.auto.sections import (
        coverage_human,
        coverage_metadata,
        coverage_nonhuman,
    )

    root = repo_root or _find_asset_root()

    # For prod, restrict to approved characters from the release manifest.
    _no_approved_chars = False
    if hids_filter is None and db == "prod":
        manifest_path = root / "database_seeds" / "prod" / "release_manifest.json"
        try:
            manifest = _json.loads(manifest_path.read_text(encoding="utf-8"))
            approved = manifest.get("approved_characters") or []
            if approved:
                hids_filter = approved
            else:
                _no_approved_chars = True
        except OSError:
            _no_approved_chars = True

    if _no_approved_chars:
        placeholder = (
            '<div class="alert alert-warning mt-3" role="alert">'
            "<strong>No production characters approved.</strong> "
            "The release manifest contains no approved characters. "
            "Coverage cannot be generated until characters are approved for production."
            "</div>"
        )
        rendered = [("coverage", "Coverage", placeholder, "top")]
        title = f"Character Coverage Report \u2014 {db}"
        return build_html(rendered, title=title, artifacts={}, download_items=[])

    coverage_sections = [
        ("metadata", "Metadata", coverage_metadata, "top"),
        (None, "Non-human", None, "group"),
        ("dim-coverage", "Dimensions", coverage_nonhuman, "sub"),
        (None, "Human", None, "group"),
        ("hsn-divergence", "HSN Divergence", coverage_human, "sub"),
    ]

    rendered: list[tuple[str | None, str, str, str]] = []
    for anchor, section_title, module, kind in coverage_sections:
        if kind == "group":
            rendered.append((None, section_title, "", "group"))
            continue
        try:
            fragment = module.render(root, hids_filter=hids_filter, db=db)
        except Exception as exc:
            fragment = f'<div class="alert alert-danger"><strong>Error rendering &ldquo;{section_title}&rdquo;:</strong> {exc}</div>'
        rendered.append((anchor, section_title, fragment, kind))

    dims_path = root / "database_seeds" / "dev" / "character_dimensions.json"
    hsn_path = root / "database_seeds" / "dev" / "hsn_assumptions.json"
    chars_path = root / "database_seeds" / db / "characters.json"

    artifacts = {
        "dimensions": {
            "b64": _read_b64(dims_path),
            "filename": "dimensions.json",
            "mime": "application/json",
        },
        "hsn_assumptions": {
            "b64": _read_b64(hsn_path),
            "filename": "hsn_assumptions.json",
            "mime": "application/json",
        },
        "characters": {
            "b64": _read_b64(chars_path),
            "filename": "characters.json",
            "mime": "application/json",
        },
    }

    download_items = [
        ("dimensions.json", "dimensions"),
        ("hsn_assumptions.json", "hsn_assumptions"),
        ("characters.json", "characters"),
    ]

    title = f"Character Coverage Report \u2014 {db}"
    return build_html(
        rendered,
        title=title,
        artifacts=artifacts,
        download_items=download_items,
    )
constants

Centralised text descriptions for auto-analysis report sections and charts.

Edit the strings here to update what appears in the generated HTML report without touching any rendering or section code.

chart_caption(section, chart)

Return an HTML caption for a chart or table, or '' if absent.

Source code in dcs_simulation_engine/reporting/auto/constants.py
327
328
329
330
331
332
def chart_caption(section: str, chart: str) -> str:
    """Return an HTML caption for a chart or table, or '' if absent."""
    text = CHART_DESCRIPTIONS.get(section, {}).get(chart, "")
    if not text:
        return ""
    return f'<p class="text-muted mt-1 mb-3" style="font-size:0.82rem;"><em>{text}</em></p>'
section_intro(key)

Return an HTML lead paragraph for the given section key, or '' if absent.

Source code in dcs_simulation_engine/reporting/auto/constants.py
319
320
321
322
323
324
def section_intro(key: str) -> str:
    """Return an HTML lead paragraph for the given section key, or '' if absent."""
    text = SECTION_DESCRIPTIONS.get(key, "")
    if not text:
        return ""
    return f'<p class="text-muted mb-3" style="font-size:0.9rem;">{text}</p>'
publish

Publish utilities for dcs.

Provides pure functions for: - Parsing the per-NPC simulation quality table from a report HTML - Building CharacterRecord from a characters.json document - Loading / saving JSON seed files

build_char_record_from_doc(doc)

Build a :class:CharacterRecord from a characters.json document.

Source code in dcs_simulation_engine/reporting/auto/publish.py
181
182
183
184
185
186
187
188
def build_char_record_from_doc(doc: dict[str, Any]) -> CharacterRecord:
    """Build a :class:`CharacterRecord` from a characters.json document."""
    return CharacterRecord(
        hid=doc["hid"],
        name=doc.get("name", ""),
        short_description=doc.get("short_description", ""),
        data={k: v for k, v in doc.items() if k not in _CHAR_RECORD_KNOWN_FIELDS},
    )
load_json_file(path)

Read and return parsed JSON from path.

Source code in dcs_simulation_engine/reporting/auto/publish.py
196
197
198
def load_json_file(path: Path) -> list | dict:
    """Read and return parsed JSON from *path*."""
    return json.loads(path.read_text(encoding="utf-8"))
parse_sim_quality_table(html)

Parse the per-NPC simulation quality table from a report HTML string.

Returns a list of dicts, one per NPC character::

[{"npc_hid": "NA", "turns": 25, "icf": 0.96, "dms": 0.01}, ...]
Raises:

ValueError If the table sim-quality-per-npc-table is not found in the HTML. This means the report was generated before the simulation_quality section was added. Regenerate with::

    dcs generate report ... --template simulation_quality
Source code in dcs_simulation_engine/reporting/auto/publish.py
 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
def parse_sim_quality_table(html: str) -> list[dict[str, Any]]:
    """Parse the per-NPC simulation quality table from a report HTML string.

    Returns a list of dicts, one per NPC character::

        [{"npc_hid": "NA", "turns": 25, "icf": 0.96, "dms": 0.01}, ...]

    Raises:
    ------
    ValueError
        If the table ``sim-quality-per-npc-table`` is not found in the HTML.
        This means the report was generated before the simulation_quality section
        was added. Regenerate with::

            dcs generate report ... --template simulation_quality
    """
    parser = _TableParser("sim-quality-per-npc-table")
    parser.feed(html)

    if not parser.headers:
        raise ValueError(
            "Report has no simulation quality per-NPC table "
            "('sim-quality-per-npc-table' not found). "
            "Regenerate the report with: "
            "dcs generate report <results_dir> --template simulation_quality"
        )

    # Normalise header names → column indices
    headers_lower = [h.strip().lower() for h in parser.headers]

    def _col(name: str) -> int:
        try:
            return headers_lower.index(name)
        except ValueError:
            raise ValueError(f"Expected column {name!r} in sim-quality-per-npc-table but found: {parser.headers}")

    npc_col = _col("hid")
    turns_col = _col("turns")
    icf_col = _col("icf")
    nco_col = _col("nco")

    # Scenario Coverage column is optional (reports generated before this feature lack it)
    try:
        scenario_coverage_col: int | None = _col("scenario coverage")
    except ValueError:
        scenario_coverage_col = None

    def _pct(s: str) -> float:
        """Convert '96.0%' → 0.96, '—' → 0.0."""
        s = s.strip()
        if s in ("—", "-", ""):
            return 0.0
        return round(float(s.rstrip("%")) / 100, 6)

    required_col_count = max(npc_col, turns_col, icf_col, nco_col)

    results: list[dict[str, Any]] = []
    for row in parser.rows:
        if len(row) <= required_col_count:
            continue
        npc_hid = row[npc_col].strip()
        if not npc_hid:
            continue
        try:
            turns = int(row[turns_col].strip())
        except ValueError:
            turns = 0
        scenario_coverage = (
            _pct(row[scenario_coverage_col]) if scenario_coverage_col is not None and len(row) > scenario_coverage_col else 0.0
        )
        results.append(
            {
                "npc_hid": npc_hid,
                "turns": turns,
                "icf": _pct(row[icf_col]),
                "dms": _pct(row[nco_col]),
                "scenario_coverage": scenario_coverage,
            }
        )

    return results
save_json_file(path, data)

Write data as formatted JSON to path (2-space indent, trailing newline).

Source code in dcs_simulation_engine/reporting/auto/publish.py
201
202
203
def save_json_file(path: Path, data: list | dict) -> None:
    """Write *data* as formatted JSON to *path* (2-space indent, trailing newline)."""
    path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
rendering

HTML rendering utilities for the auto-analysis pipeline.

chart_utils

Chart rendering helpers.

plotly_to_html — Plotly Figure → embeddable

string (no bundled JS). matplotlib_to_base64 — matplotlib Figure → string.

add_short_player_id_column(df, *, source='player_id', target='player_label')

Return a copy of df with a shortened player-ID display column.

Source code in dcs_simulation_engine/reporting/auto/rendering/chart_utils.py
36
37
38
39
40
41
42
43
def add_short_player_id_column(df, *, source: str = "player_id", target: str = "player_label"):
    """Return a copy of *df* with a shortened player-ID display column."""
    if source not in df.columns:
        return df.copy()

    display = df.copy()
    display[target] = display[source].map(short_player_id)
    return display
matplotlib_to_base64(fig)

Return an tag with the figure embedded as a base64 PNG.

Source code in dcs_simulation_engine/reporting/auto/rendering/chart_utils.py
72
73
74
75
76
77
78
79
80
81
def matplotlib_to_base64(fig) -> str:
    """Return an <img> tag with the figure embedded as a base64 PNG."""
    import matplotlib.pyplot as plt

    buf = io.BytesIO()
    fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
    buf.seek(0)
    encoded = base64.b64encode(buf.read()).decode("utf-8")
    plt.close(fig)
    return f'<img src="data:image/png;base64,{encoded}" class="img-fluid" alt="chart">'
plotly_to_html(fig, div_id=None)

Return an embeddable HTML div for fig.

Requires Plotly to be loaded from CDN in the page .

Source code in dcs_simulation_engine/reporting/auto/rendering/chart_utils.py
56
57
58
59
60
61
62
63
64
65
66
67
68
69
def plotly_to_html(fig, div_id: str | None = None) -> str:
    """Return an embeddable HTML div for *fig*.

    Requires Plotly to be loaded from CDN in the page <head>.
    """
    import plotly.io as pio

    return pio.to_html(
        fig,
        full_html=False,
        include_plotlyjs=False,
        div_id=div_id,
        config={"responsive": True},
    )
short_player_id(value, *, suffix_chars=8)

Display long player IDs by keeping the distinguishing suffix.

Source code in dcs_simulation_engine/reporting/auto/rendering/chart_utils.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def short_player_id(value, *, suffix_chars: int = 8) -> str:
    """Display long player IDs by keeping the distinguishing suffix."""
    if value is None:
        return ""

    try:
        from pandas import isna

        if isna(value):
            return ""
    except Exception:
        pass

    try:
        if value != value:
            return ""
    except Exception:
        pass

    text = str(value)
    if len(text) <= suffix_chars + 3:
        return text
    return f"...{text[-suffix_chars:]}"
use_integer_ticks(fig, *, x=False, y=False)

Force whole-number tick labels on numeric Plotly axes.

Source code in dcs_simulation_engine/reporting/auto/rendering/chart_utils.py
46
47
48
49
50
51
52
53
def use_integer_ticks(fig, *, x: bool = False, y: bool = False):
    """Force whole-number tick labels on numeric Plotly axes."""
    axis_options = {"dtick": 1, "tickformat": ",d"}
    if x:
        fig.update_xaxes(**axis_options)
    if y:
        fig.update_yaxes(**axis_options)
    return fig
html_builder

Assemble the final HTML document from rendered section fragments.

build_html(sections, title='Results Report', artifacts=None, download_items=None)

Render the Jinja2 base template with all section fragments.

Parameters

sections: List of (anchor_slug, section_title, html_fragment, is_sub) tuples in display order. is_sub=True renders the sidebar entry as an indented child of the preceding top-level item. title: Report title shown in

and . artifacts: Mapping of artifact key → {b64, filename, mime} for downloadable files. download_items: List of (label, key) pairs rendered as dropdown menu items. Each key must match an entry in <em>artifacts</em>. Defaults to the standard raw_results / run_config pair.</p> <h6 id="dcs_simulation_engine.reporting.auto.rendering.html_builder.build_html--returns">Returns:</h6> <p>str Complete, self-contained HTML document as a string.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/reporting/auto/rendering/html_builder.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">13</span> <span class="normal">14</span> <span class="normal">15</span> <span class="normal">16</span> <span class="normal">17</span> <span class="normal">18</span> <span class="normal">19</span> <span class="normal">20</span> <span class="normal">21</span> <span class="normal">22</span> <span class="normal">23</span> <span class="normal">24</span> <span class="normal">25</span> <span class="normal">26</span> <span class="normal">27</span> <span class="normal">28</span> <span class="normal">29</span> <span class="normal">30</span> <span class="normal">31</span> <span class="normal">32</span> <span class="normal">33</span> <span class="normal">34</span> <span class="normal">35</span> <span class="normal">36</span> <span class="normal">37</span> <span class="normal">38</span> <span class="normal">39</span> <span class="normal">40</span> <span class="normal">41</span> <span class="normal">42</span> <span class="normal">43</span> <span class="normal">44</span> <span class="normal">45</span> <span class="normal">46</span> <span class="normal">47</span> <span class="normal">48</span> <span class="normal">49</span> <span class="normal">50</span> <span class="normal">51</span> <span class="normal">52</span> <span class="normal">53</span> <span class="normal">54</span> <span class="normal">55</span> <span class="normal">56</span> <span class="normal">57</span> <span class="normal">58</span> <span class="normal">59</span> <span class="normal">60</span> <span class="normal">61</span> <span class="normal">62</span> <span class="normal">63</span> <span class="normal">64</span> <span class="normal">65</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">build_html</span><span class="p">(</span> <span class="n">sections</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">tuple</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">,</span> <span class="nb">bool</span><span class="p">]],</span> <span class="n">title</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s2">"Results Report"</span><span class="p">,</span> <span class="n">artifacts</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span><span class="p">,</span> <span class="n">download_items</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">tuple</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">]]</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span><span class="p">,</span> <span class="p">)</span> <span class="o">-></span> <span class="nb">str</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Render the Jinja2 base template with all section fragments.</span> <span class="sd"> Parameters</span> <span class="sd"> ----------</span> <span class="sd"> sections:</span> <span class="sd"> List of (anchor_slug, section_title, html_fragment, is_sub) tuples in</span> <span class="sd"> display order. is_sub=True renders the sidebar entry as an indented</span> <span class="sd"> child of the preceding top-level item.</span> <span class="sd"> title:</span> <span class="sd"> Report title shown in <h1> and <title>.</span> <span class="sd"> artifacts:</span> <span class="sd"> Mapping of artifact key → {b64, filename, mime} for downloadable files.</span> <span class="sd"> download_items:</span> <span class="sd"> List of (label, key) pairs rendered as dropdown menu items. Each key</span> <span class="sd"> must match an entry in *artifacts*. Defaults to the standard</span> <span class="sd"> raw_results / run_config pair.</span> <span class="sd"> Returns:</span> <span class="sd"> -------</span> <span class="sd"> str</span> <span class="sd"> Complete, self-contained HTML document as a string.</span> <span class="sd"> """</span> <span class="n">env</span> <span class="o">=</span> <span class="n">Environment</span><span class="p">(</span> <span class="n">loader</span><span class="o">=</span><span class="n">FileSystemLoader</span><span class="p">(</span><span class="nb">str</span><span class="p">(</span><span class="n">_TEMPLATES_DIR</span><span class="p">)),</span> <span class="n">autoescape</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="c1"># sections contain trusted HTML we generated</span> <span class="p">)</span> <span class="n">env</span><span class="o">.</span><span class="n">filters</span><span class="p">[</span><span class="s2">"tojson"</span><span class="p">]</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">dumps</span> <span class="n">template</span> <span class="o">=</span> <span class="n">env</span><span class="o">.</span><span class="n">get_template</span><span class="p">(</span><span class="s2">"base.html"</span><span class="p">)</span> <span class="n">generated_at</span> <span class="o">=</span> <span class="n">datetime</span><span class="o">.</span><span class="n">now</span><span class="p">(</span><span class="n">tz</span><span class="o">=</span><span class="n">timezone</span><span class="o">.</span><span class="n">utc</span><span class="p">)</span><span class="o">.</span><span class="n">strftime</span><span class="p">(</span><span class="s2">"%Y-%m-</span><span class="si">%d</span><span class="s2"> %H:%M UTC"</span><span class="p">)</span> <span class="n">_download_items</span> <span class="o">=</span> <span class="p">(</span> <span class="n">download_items</span> <span class="k">if</span> <span class="n">download_items</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span> <span class="k">else</span> <span class="p">[</span> <span class="p">(</span><span class="s2">"Raw Results (.zip)"</span><span class="p">,</span> <span class="s2">"raw_results"</span><span class="p">),</span> <span class="p">(</span><span class="s2">"Run Config (.yml)"</span><span class="p">,</span> <span class="s2">"run_config"</span><span class="p">),</span> <span class="p">]</span> <span class="p">)</span> <span class="k">return</span> <span class="n">template</span><span class="o">.</span><span class="n">render</span><span class="p">(</span> <span class="n">title</span><span class="o">=</span><span class="n">title</span><span class="p">,</span> <span class="n">sections</span><span class="o">=</span><span class="n">sections</span><span class="p">,</span> <span class="n">generated_at</span><span class="o">=</span><span class="n">generated_at</span><span class="p">,</span> <span class="n">artifacts_json</span><span class="o">=</span><span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">(</span><span class="n">artifacts</span> <span class="ow">or</span> <span class="p">{}),</span> <span class="n">download_items</span><span class="o">=</span><span class="n">_download_items</span><span class="p">,</span> <span class="p">)</span> </code></pre></div></td></tr></table></div> </details> </div> </div> </div> </div> </div> <div class="doc doc-object doc-module"> <h6 id="dcs_simulation_engine.reporting.auto.rendering.table_utils" class="doc doc-heading"> <code>table_utils</code> </h6> <div class="doc doc-contents "> <p>DataFrame → DataTables HTML rendering.</p> <p>df_to_datatable(df, table_id, ...) returns a self-contained HTML string containing a <table> and a <script> block that initialises the DataTables plugin for that table. The page must load jQuery, DataTables core, and the DataTables Bootstrap 5 + Buttons plugins from CDN (all in <head>).</p> <div class="doc doc-children"> <div class="doc doc-object doc-function"> <h7 id="dcs_simulation_engine.reporting.auto.rendering.table_utils.df_to_datatable" class="doc doc-heading"> <code class="highlight language-python"><span class="n">df_to_datatable</span><span class="p">(</span><span class="n">df</span><span class="p">,</span> <span class="n">table_id</span><span class="p">,</span> <span class="n">columns</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">rename</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">scroll_y</span><span class="o">=</span><span class="s1">'400px'</span><span class="p">,</span> <span class="n">scroll_x</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">export_buttons</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">truncate_cols</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">truncate_at</span><span class="o">=</span><span class="mi">400</span><span class="p">,</span> <span class="n">column_filters</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span></code> </h7> <div class="doc doc-contents "> <p>Render <em>df</em> as a Bootstrap 5 DataTable HTML string.</p> <h6 id="dcs_simulation_engine.reporting.auto.rendering.table_utils.df_to_datatable--parameters">Parameters</h6> <p>df: Source DataFrame. table_id: HTML id attribute for the <table> (must be unique in the page). columns: Subset and order of columns to include. All columns used if None. rename: Optional {source_col: display_name} map applied after column selection. scroll_y: Max height of the table body before vertical scrolling kicks in. Set to "" to disable (falls back to pagination). Default "400px". scroll_x: Enable horizontal scrolling. export_buttons: Include Copy / CSV / Excel / Column-visibility buttons. truncate_cols: Column names whose display values should be truncated to <em>truncate_at</em> chars with the full text in a <code>title</code> attribute. truncate_at: Character limit for truncated columns (default 400). column_filters: Add per-column search inputs in a second header row (default True).</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/reporting/auto/rendering/table_utils.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal"> 17</span> <span class="normal"> 18</span> <span class="normal"> 19</span> <span class="normal"> 20</span> <span class="normal"> 21</span> <span class="normal"> 22</span> <span class="normal"> 23</span> <span class="normal"> 24</span> <span class="normal"> 25</span> <span class="normal"> 26</span> <span class="normal"> 27</span> <span class="normal"> 28</span> <span class="normal"> 29</span> <span class="normal"> 30</span> <span class="normal"> 31</span> <span class="normal"> 32</span> <span class="normal"> 33</span> <span class="normal"> 34</span> <span class="normal"> 35</span> <span class="normal"> 36</span> <span class="normal"> 37</span> <span class="normal"> 38</span> <span class="normal"> 39</span> <span class="normal"> 40</span> <span class="normal"> 41</span> <span class="normal"> 42</span> <span class="normal"> 43</span> <span class="normal"> 44</span> <span class="normal"> 45</span> <span class="normal"> 46</span> <span class="normal"> 47</span> <span class="normal"> 48</span> <span class="normal"> 49</span> <span class="normal"> 50</span> <span class="normal"> 51</span> <span class="normal"> 52</span> <span class="normal"> 53</span> <span class="normal"> 54</span> <span class="normal"> 55</span> <span class="normal"> 56</span> <span class="normal"> 57</span> <span class="normal"> 58</span> <span class="normal"> 59</span> <span class="normal"> 60</span> <span class="normal"> 61</span> <span class="normal"> 62</span> <span class="normal"> 63</span> <span class="normal"> 64</span> <span class="normal"> 65</span> <span class="normal"> 66</span> <span class="normal"> 67</span> <span class="normal"> 68</span> <span class="normal"> 69</span> <span class="normal"> 70</span> <span class="normal"> 71</span> <span class="normal"> 72</span> <span class="normal"> 73</span> <span class="normal"> 74</span> <span class="normal"> 75</span> <span class="normal"> 76</span> <span class="normal"> 77</span> <span class="normal"> 78</span> <span class="normal"> 79</span> <span class="normal"> 80</span> <span class="normal"> 81</span> <span class="normal"> 82</span> <span class="normal"> 83</span> <span class="normal"> 84</span> <span class="normal"> 85</span> <span class="normal"> 86</span> <span class="normal"> 87</span> <span class="normal"> 88</span> <span class="normal"> 89</span> <span class="normal"> 90</span> <span class="normal"> 91</span> <span class="normal"> 92</span> <span class="normal"> 93</span> <span class="normal"> 94</span> <span class="normal"> 95</span> <span class="normal"> 96</span> <span class="normal"> 97</span> <span class="normal"> 98</span> <span class="normal"> 99</span> <span class="normal">100</span> <span class="normal">101</span> <span class="normal">102</span> <span class="normal">103</span> <span class="normal">104</span> <span class="normal">105</span> <span class="normal">106</span> <span class="normal">107</span> <span class="normal">108</span> <span class="normal">109</span> <span class="normal">110</span> <span class="normal">111</span> <span class="normal">112</span> <span class="normal">113</span> <span class="normal">114</span> <span class="normal">115</span> <span class="normal">116</span> <span class="normal">117</span> <span class="normal">118</span> <span class="normal">119</span> <span class="normal">120</span> <span class="normal">121</span> <span class="normal">122</span> <span class="normal">123</span> <span class="normal">124</span> <span class="normal">125</span> <span class="normal">126</span> <span class="normal">127</span> <span class="normal">128</span> <span class="normal">129</span> <span class="normal">130</span> <span class="normal">131</span> <span class="normal">132</span> <span class="normal">133</span> <span class="normal">134</span> <span class="normal">135</span> <span class="normal">136</span> <span class="normal">137</span> <span class="normal">138</span> <span class="normal">139</span> <span class="normal">140</span> <span class="normal">141</span> <span class="normal">142</span> <span class="normal">143</span> <span class="normal">144</span> <span class="normal">145</span> <span class="normal">146</span> <span class="normal">147</span> <span class="normal">148</span> <span class="normal">149</span> <span class="normal">150</span> <span class="normal">151</span> <span class="normal">152</span> <span class="normal">153</span> <span class="normal">154</span> <span class="normal">155</span> <span class="normal">156</span> <span class="normal">157</span> <span class="normal">158</span> <span class="normal">159</span> <span class="normal">160</span> <span class="normal">161</span> <span class="normal">162</span> <span class="normal">163</span> <span class="normal">164</span> <span class="normal">165</span> <span class="normal">166</span> <span class="normal">167</span> <span class="normal">168</span> <span class="normal">169</span> <span class="normal">170</span> <span class="normal">171</span> <span class="normal">172</span> <span class="normal">173</span> <span class="normal">174</span> <span class="normal">175</span> <span class="normal">176</span> <span class="normal">177</span> <span class="normal">178</span> <span class="normal">179</span> <span class="normal">180</span> <span class="normal">181</span> <span class="normal">182</span> <span class="normal">183</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">df_to_datatable</span><span class="p">(</span> <span class="n">df</span><span class="p">:</span> <span class="n">pd</span><span class="o">.</span><span class="n">DataFrame</span><span class="p">,</span> <span class="n">table_id</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">columns</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span><span class="p">,</span> <span class="n">rename</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">]</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span><span class="p">,</span> <span class="n">scroll_y</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s2">"400px"</span><span class="p">,</span> <span class="n">scroll_x</span><span class="p">:</span> <span class="nb">bool</span> <span class="o">=</span> <span class="kc">True</span><span class="p">,</span> <span class="n">export_buttons</span><span class="p">:</span> <span class="nb">bool</span> <span class="o">=</span> <span class="kc">True</span><span class="p">,</span> <span class="n">truncate_cols</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span><span class="p">,</span> <span class="n">truncate_at</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">400</span><span class="p">,</span> <span class="n">column_filters</span><span class="p">:</span> <span class="nb">bool</span> <span class="o">=</span> <span class="kc">True</span><span class="p">,</span> <span class="p">)</span> <span class="o">-></span> <span class="nb">str</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Render *df* as a Bootstrap 5 DataTable HTML string.</span> <span class="sd"> Parameters</span> <span class="sd"> ----------</span> <span class="sd"> df:</span> <span class="sd"> Source DataFrame.</span> <span class="sd"> table_id:</span> <span class="sd"> HTML id attribute for the <table> (must be unique in the page).</span> <span class="sd"> columns:</span> <span class="sd"> Subset and order of columns to include. All columns used if None.</span> <span class="sd"> rename:</span> <span class="sd"> Optional {source_col: display_name} map applied after column selection.</span> <span class="sd"> scroll_y:</span> <span class="sd"> Max height of the table body before vertical scrolling kicks in.</span> <span class="sd"> Set to "" to disable (falls back to pagination). Default "400px".</span> <span class="sd"> scroll_x:</span> <span class="sd"> Enable horizontal scrolling.</span> <span class="sd"> export_buttons:</span> <span class="sd"> Include Copy / CSV / Excel / Column-visibility buttons.</span> <span class="sd"> truncate_cols:</span> <span class="sd"> Column names whose display values should be truncated to *truncate_at*</span> <span class="sd"> chars with the full text in a `title` attribute.</span> <span class="sd"> truncate_at:</span> <span class="sd"> Character limit for truncated columns (default 400).</span> <span class="sd"> column_filters:</span> <span class="sd"> Add per-column search inputs in a second header row (default True).</span> <span class="sd"> """</span> <span class="n">source_columns</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="n">columns</span><span class="p">)</span> <span class="k">if</span> <span class="n">columns</span> <span class="k">else</span> <span class="nb">list</span><span class="p">(</span><span class="n">df</span><span class="o">.</span><span class="n">columns</span><span class="p">)</span> <span class="n">display</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="n">columns</span><span class="p">]</span><span class="o">.</span><span class="n">copy</span><span class="p">()</span> <span class="k">if</span> <span class="n">columns</span> <span class="k">else</span> <span class="n">df</span><span class="o">.</span><span class="n">copy</span><span class="p">()</span> <span class="k">if</span> <span class="n">rename</span><span class="p">:</span> <span class="n">display</span> <span class="o">=</span> <span class="n">display</span><span class="o">.</span><span class="n">rename</span><span class="p">(</span><span class="n">columns</span><span class="o">=</span><span class="n">rename</span><span class="p">)</span> <span class="k">for</span> <span class="n">source_col</span> <span class="ow">in</span> <span class="n">source_columns</span><span class="p">:</span> <span class="k">if</span> <span class="n">source_col</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">_PLAYER_ID_COLUMNS</span><span class="p">:</span> <span class="k">continue</span> <span class="n">display_col</span> <span class="o">=</span> <span class="n">rename</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">source_col</span><span class="p">,</span> <span class="n">source_col</span><span class="p">)</span> <span class="k">if</span> <span class="n">rename</span> <span class="k">else</span> <span class="n">source_col</span> <span class="k">if</span> <span class="n">display_col</span> <span class="ow">in</span> <span class="n">display</span><span class="o">.</span><span class="n">columns</span><span class="p">:</span> <span class="n">display</span><span class="p">[</span><span class="n">display_col</span><span class="p">]</span> <span class="o">=</span> <span class="n">display</span><span class="p">[</span><span class="n">display_col</span><span class="p">]</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="n">short_player_id</span><span class="p">)</span> <span class="c1"># Convert timestamps to ISO strings for readability</span> <span class="k">for</span> <span class="n">col</span> <span class="ow">in</span> <span class="n">display</span><span class="o">.</span><span class="n">select_dtypes</span><span class="p">(</span><span class="n">include</span><span class="o">=</span><span class="p">[</span><span class="s2">"datetimetz"</span><span class="p">,</span> <span class="s2">"datetime64[ns, UTC]"</span><span class="p">,</span> <span class="s2">"datetime"</span><span class="p">])</span><span class="o">.</span><span class="n">columns</span><span class="p">:</span> <span class="n">display</span><span class="p">[</span><span class="n">col</span><span class="p">]</span> <span class="o">=</span> <span class="n">display</span><span class="p">[</span><span class="n">col</span><span class="p">]</span><span class="o">.</span><span class="n">dt</span><span class="o">.</span><span class="n">strftime</span><span class="p">(</span><span class="s2">"%Y-%m-</span><span class="si">%d</span><span class="s2"> %H:%M:%S UTC"</span><span class="p">)</span><span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="n">display</span><span class="p">[</span><span class="n">col</span><span class="p">]</span><span class="o">.</span><span class="n">notna</span><span class="p">(),</span> <span class="s2">""</span><span class="p">)</span> <span class="c1"># Replace NaN/NaT with empty string for clean display</span> <span class="n">display</span> <span class="o">=</span> <span class="n">display</span><span class="o">.</span><span class="n">fillna</span><span class="p">(</span><span class="s2">""</span><span class="p">)</span> <span class="c1"># Build <table> HTML</span> <span class="n">table_html</span> <span class="o">=</span> <span class="n">display</span><span class="o">.</span><span class="n">to_html</span><span class="p">(</span> <span class="n">table_id</span><span class="o">=</span><span class="n">table_id</span><span class="p">,</span> <span class="n">classes</span><span class="o">=</span><span class="s2">"table table-striped table-hover table-sm w-100"</span><span class="p">,</span> <span class="n">border</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span> <span class="n">index</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">escape</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="p">)</span> <span class="c1"># Apply truncation post-render by patching cell content (simple approach:</span> <span class="c1"># re-render with truncated values but title containing original)</span> <span class="k">if</span> <span class="n">truncate_cols</span><span class="p">:</span> <span class="n">display_trunc</span> <span class="o">=</span> <span class="n">display</span><span class="o">.</span><span class="n">copy</span><span class="p">()</span> <span class="k">for</span> <span class="n">col</span> <span class="ow">in</span> <span class="n">truncate_cols</span><span class="p">:</span> <span class="k">if</span> <span class="n">col</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">display_trunc</span><span class="o">.</span><span class="n">columns</span><span class="p">:</span> <span class="k">continue</span> <span class="n">disp_col</span> <span class="o">=</span> <span class="n">rename</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">col</span><span class="p">,</span> <span class="n">col</span><span class="p">)</span> <span class="k">if</span> <span class="n">rename</span> <span class="k">else</span> <span class="n">col</span> <span class="k">if</span> <span class="n">disp_col</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">display_trunc</span><span class="o">.</span><span class="n">columns</span><span class="p">:</span> <span class="k">continue</span> <span class="k">def</span><span class="w"> </span><span class="nf">_trunc</span><span class="p">(</span><span class="n">val</span><span class="p">,</span> <span class="n">limit</span><span class="o">=</span><span class="n">truncate_at</span><span class="p">):</span> <span class="n">s</span> <span class="o">=</span> <span class="nb">str</span><span class="p">(</span><span class="n">val</span><span class="p">)</span> <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">s</span><span class="p">)</span> <span class="o"><=</span> <span class="n">limit</span><span class="p">:</span> <span class="k">return</span> <span class="n">s</span> <span class="n">escaped_full</span> <span class="o">=</span> <span class="n">_html</span><span class="o">.</span><span class="n">escape</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">quote</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span> <span class="n">escaped_short</span> <span class="o">=</span> <span class="n">_html</span><span class="o">.</span><span class="n">escape</span><span class="p">(</span><span class="n">s</span><span class="p">[:</span><span class="n">limit</span><span class="p">],</span> <span class="n">quote</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span> <span class="k">return</span> <span class="sa">f</span><span class="s1">'<span title="</span><span class="si">{</span><span class="n">escaped_full</span><span class="si">}</span><span class="s1">"></span><span class="si">{</span><span class="n">escaped_short</span><span class="si">}</span><span class="s1">…</span>'</span> <span class="n">display_trunc</span><span class="p">[</span><span class="n">disp_col</span><span class="p">]</span> <span class="o">=</span> <span class="n">display_trunc</span><span class="p">[</span><span class="n">disp_col</span><span class="p">]</span><span class="o">.</span><span class="n">apply</span><span class="p">(</span><span class="n">_trunc</span><span class="p">)</span> <span class="n">table_html</span> <span class="o">=</span> <span class="n">display_trunc</span><span class="o">.</span><span class="n">to_html</span><span class="p">(</span> <span class="n">table_id</span><span class="o">=</span><span class="n">table_id</span><span class="p">,</span> <span class="n">classes</span><span class="o">=</span><span class="s2">"table table-striped table-hover table-sm w-100"</span><span class="p">,</span> <span class="n">border</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span> <span class="n">index</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">escape</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="p">)</span> <span class="c1"># Inject a second <tr> in <thead> for per-column filter inputs.</span> <span class="c1"># Using the header (not footer) avoids scrollX null-return issues.</span> <span class="k">if</span> <span class="n">column_filters</span><span class="p">:</span> <span class="n">n_cols</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="n">display</span><span class="o">.</span><span class="n">columns</span><span class="p">)</span> <span class="n">filter_cells</span> <span class="o">=</span> <span class="s2">""</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">"<th></th>"</span> <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">n_cols</span><span class="p">))</span> <span class="n">filter_row</span> <span class="o">=</span> <span class="sa">f</span><span class="s1">'<tr class="dt-filter-row"></span><span class="si">{</span><span class="n">filter_cells</span><span class="si">}</span><span class="s1"></tr>'</span> <span class="n">table_html</span> <span class="o">=</span> <span class="n">table_html</span><span class="o">.</span><span class="n">replace</span><span class="p">(</span><span class="s2">"</thead>"</span><span class="p">,</span> <span class="sa">f</span><span class="s2">"</span><span class="si">{</span><span class="n">filter_row</span><span class="si">}</span><span class="se">\n</span><span class="s2"></thead>"</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span> <span class="n">use_scroll_y</span> <span class="o">=</span> <span class="nb">bool</span><span class="p">(</span><span class="n">scroll_y</span><span class="p">)</span> <span class="c1"># Bootstrap 5 DataTables DOM layout.</span> <span class="c1"># When scrollY is active, pagination is replaced by vertical scrolling</span> <span class="c1"># so 'p' (pagination) and 'l' (length menu) are dropped from the dom.</span> <span class="k">if</span> <span class="n">use_scroll_y</span><span class="p">:</span> <span class="n">dom</span> <span class="o">=</span> <span class="p">(</span> <span class="s2">"<'row align-items-center mb-2'<'col-auto'f><'col d-flex justify-content-end'B>>"</span> <span class="s2">"<'row'<'col-sm-12'tr>>"</span> <span class="s2">"<'row mt-2'<'col-sm-12'i>>"</span> <span class="p">)</span> <span class="k">else</span><span class="p">:</span> <span class="n">dom</span> <span class="o">=</span> <span class="p">(</span> <span class="s2">"<'row align-items-center mb-2'<'col-auto'f><'col d-flex justify-content-end'B>>"</span> <span class="s2">"<'row'<'col-sm-12'tr>>"</span> <span class="s2">"<'row mt-2'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>"</span> <span class="p">)</span> <span class="n">buttons_js</span> <span class="o">=</span> <span class="p">(</span> <span class="w"> </span><span class="sd">"""buttons: {</span> <span class="sd"> buttons: ['copy', 'csv', 'excel', 'colvis'],</span> <span class="sd"> dom: { button: { className: 'btn btn-outline-secondary btn-sm' } }</span> <span class="sd"> },"""</span> <span class="k">if</span> <span class="n">export_buttons</span> <span class="k">else</span> <span class="s2">""</span> <span class="p">)</span> <span class="n">scroll_x_js</span> <span class="o">=</span> <span class="s2">"true"</span> <span class="k">if</span> <span class="n">scroll_x</span> <span class="k">else</span> <span class="s2">"false"</span> <span class="n">scroll_y_js</span> <span class="o">=</span> <span class="p">(</span> <span class="sa">f</span><span class="s1">'scrollY: "</span><span class="si">{</span><span class="n">scroll_y</span><span class="si">}</span><span class="s1">", scrollCollapse: true, paging: false,'</span> <span class="k">if</span> <span class="n">use_scroll_y</span> <span class="k">else</span> <span class="s2">"pageLength: 10, lengthMenu: [[10, 25, 50, 100, -1], [10, 25, 50, 100, 'All']],"</span> <span class="p">)</span> <span class="n">col_filter_js</span> <span class="o">=</span> <span class="s2">""</span> <span class="k">if</span> <span class="n">column_filters</span><span class="p">:</span> <span class="n">col_filter_js</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">"""</span> <span class="s2"> orderCellsTop: true,</span> <span class="s2"> initComplete: function () </span><span class="se">{{</span> <span class="s2"> var api = this.api();</span> <span class="s2"> var wrapper = $('#</span><span class="si">{</span><span class="n">table_id</span><span class="si">}</span><span class="s2">').closest('.dataTables_wrapper');</span> <span class="s2"> api.columns().every(function () </span><span class="se">{{</span> <span class="s2"> var col = this;</span> <span class="s2"> var filterTh = wrapper.find('.dataTables_scrollHead thead tr.dt-filter-row th').eq(col.index());</span> <span class="s2"> $('<input type="text" placeholder="Filter</span><span class="se">\u2026</span><span class="s2">" class="form-control form-control-sm"/>')</span> <span class="s2"> .appendTo(filterTh.empty())</span> <span class="s2"> .on('input', function () </span><span class="se">{{</span><span class="s2"> col.search(this.value).draw(); </span><span class="se">}}</span><span class="s2">);</span> <span class="s2"> </span><span class="se">}}</span><span class="s2">);</span> <span class="s2"> </span><span class="se">}}</span><span class="s2">,"""</span> <span class="n">script</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">"""</span> <span class="s2"><script></span> <span class="s2">$(document).ready(function () </span><span class="se">{{</span> <span class="s2"> $('#</span><span class="si">{</span><span class="n">table_id</span><span class="si">}</span><span class="s2">').DataTable(</span><span class="se">{{</span> <span class="s2"> </span><span class="si">{</span><span class="n">scroll_y_js</span><span class="si">}</span> <span class="s2"> scrollX: </span><span class="si">{</span><span class="n">scroll_x_js</span><span class="si">}</span><span class="s2">,</span> <span class="s2"> dom: "</span><span class="si">{</span><span class="n">dom</span><span class="si">}</span><span class="s2">",</span> <span class="s2"> </span><span class="si">{</span><span class="n">buttons_js</span><span class="si">}</span> <span class="s2"> order: [],</span><span class="si">{</span><span class="n">col_filter_js</span><span class="si">}</span> <span class="s2"> </span><span class="se">}}</span><span class="s2">);</span> <span class="se">}}</span><span class="s2">);</span> <span class="s2"></script>"""</span> <span class="k">return</span> <span class="n">table_html</span> <span class="o">+</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">"</span> <span class="o">+</span> <span class="n">script</span> </code></pre></div></td></tr></table></div> </details> </div> </div> </div> </div> </div> </div> </div> </div> <div class="doc doc-object doc-module"> <h5 id="dcs_simulation_engine.reporting.auto.sections" class="doc doc-heading"> <code>sections</code> </h5> <div class="doc doc-contents "> <p>Auto-analysis section modules.</p> <p>Each module exposes a single function:</p> <div class="highlight"><pre><span></span><code>def render(data: AnalysisData) -> str </code></pre></div> <p>that returns a complete HTML fragment (without the wrapping <section> tag — that is added by auto/<strong>init</strong>.py).</p> <div class="doc doc-children"> <div class="doc doc-object doc-module"> <h6 id="dcs_simulation_engine.reporting.auto.sections.coverage_human" class="doc doc-heading"> <code>coverage_human</code> </h6> <div class="doc doc-contents "> <p>Human character HSN divergence coverage section.</p> <p>Loads human characters from database_seeds/prod/characters.json and renders: - HSN divergence heatmap (character × ability assumption, normative/divergent) - Divergent assumption count per character (sorted bar chart)</p> <div class="doc doc-children"> </div> </div> </div> <div class="doc doc-object doc-module"> <h6 id="dcs_simulation_engine.reporting.auto.sections.coverage_metadata" class="doc doc-heading"> <code>coverage_metadata</code> </h6> <div class="doc doc-contents "> <p>Character coverage report — metadata section.</p> <p>Renders a Bootstrap card showing total character count, human/non-human breakdown, and the list of character keys (HIDs), using the same dl-meta style as the default metadata section.</p> <div class="doc doc-children"> </div> </div> </div> <div class="doc doc-object doc-module"> <h6 id="dcs_simulation_engine.reporting.auto.sections.coverage_nonhuman" class="doc doc-heading"> <code>coverage_nonhuman</code> </h6> <div class="doc doc-contents "> <p>Non-human character dimensional coverage section.</p> <p>Loads non-human characters from database_seeds/prod/characters.json and the dimension schema from database_seeds/dev/character_dimensions.json, then renders: - Per-dimension distribution bar charts (10, 2 per row) - Coverage heatmap (dimension × option, binary covered/not) - Combination gap tables (substrate × size, origin × form)</p> <div class="doc doc-children"> </div> </div> </div> <div class="doc doc-object doc-module"> <h6 id="dcs_simulation_engine.reporting.auto.sections.coverage_overview" class="doc doc-heading"> <code>coverage_overview</code> </h6> <div class="doc doc-contents "> <p>Character coverage report — overview section.</p> <details class="renders-two-coverage-score-cards" open> <summary>Renders two coverage score cards</summary> <ul> <li>Non-human coverage score: fraction of dimension pairing combinations (substrate × size, common_labels × form) that have at least one character.</li> <li>Human coverage score: fraction of HSN ability assumptions that have at least one divergent human character.</li> </ul> </details> <div class="doc doc-children"> </div> </div> </div> <div class="doc doc-object doc-module"> <h6 id="dcs_simulation_engine.reporting.auto.sections.coverage_shared" class="doc doc-heading"> <code>coverage_shared</code> </h6> <div class="doc doc-contents "> <p>Shared scorecard helpers for coverage sections.</p> <p>Provides score computation and Bootstrap card rendering used by both the standalone coverage report (coverage_overview) and the embedded coverage sections in the main report (coverage_human, coverage_nonhuman).</p> <div class="doc doc-children"> <div class="doc doc-object doc-function"> <h7 id="dcs_simulation_engine.reporting.auto.sections.coverage_shared.human_score" class="doc doc-heading"> <code class="highlight language-python"><span class="n">human_score</span><span class="p">(</span><span class="n">human</span><span class="p">)</span></code> </h7> <div class="doc doc-contents "> <p>Fraction of HSN ability assumptions covered by at least one divergent character.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/reporting/auto/sections/coverage_shared.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">68</span> <span class="normal">69</span> <span class="normal">70</span> <span class="normal">71</span> <span class="normal">72</span> <span class="normal">73</span> <span class="normal">74</span> <span class="normal">75</span> <span class="normal">76</span> <span class="normal">77</span> <span class="normal">78</span> <span class="normal">79</span> <span class="normal">80</span> <span class="normal">81</span> <span class="normal">82</span> <span class="normal">83</span> <span class="normal">84</span> <span class="normal">85</span> <span class="normal">86</span> <span class="normal">87</span> <span class="normal">88</span> <span class="normal">89</span> <span class="normal">90</span> <span class="normal">91</span> <span class="normal">92</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">human_score</span><span class="p">(</span><span class="n">human</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">])</span> <span class="o">-></span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">float</span><span class="p">,</span> <span class="nb">str</span><span class="p">]:</span> <span class="w"> </span><span class="sd">"""Fraction of HSN ability assumptions covered by at least one divergent character."""</span> <span class="k">if</span> <span class="ow">not</span> <span class="n">human</span><span class="p">:</span> <span class="k">return</span> <span class="mf">0.0</span><span class="p">,</span> <span class="s2">"No human characters with HSN divergence data found."</span> <span class="n">long_rows</span> <span class="o">=</span> <span class="p">[]</span> <span class="k">for</span> <span class="n">c</span> <span class="ow">in</span> <span class="n">human</span><span class="p">:</span> <span class="k">for</span> <span class="n">category</span><span class="p">,</span> <span class="n">abilities</span> <span class="ow">in</span> <span class="n">c</span><span class="p">[</span><span class="s2">"hsn_divergence"</span><span class="p">]</span><span class="o">.</span><span class="n">items</span><span class="p">():</span> <span class="k">for</span> <span class="n">ability</span><span class="p">,</span> <span class="n">data</span> <span class="ow">in</span> <span class="n">abilities</span><span class="o">.</span><span class="n">items</span><span class="p">():</span> <span class="n">long_rows</span><span class="o">.</span><span class="n">append</span><span class="p">(</span> <span class="p">{</span> <span class="s2">"hid"</span><span class="p">:</span> <span class="n">c</span><span class="p">[</span><span class="s2">"hid"</span><span class="p">],</span> <span class="s2">"ability"</span><span class="p">:</span> <span class="n">ability</span><span class="p">,</span> <span class="s2">"value"</span><span class="p">:</span> <span class="n">data</span><span class="p">[</span><span class="s2">"value"</span><span class="p">],</span> <span class="p">}</span> <span class="p">)</span> <span class="n">long_df</span> <span class="o">=</span> <span class="n">pd</span><span class="o">.</span><span class="n">DataFrame</span><span class="p">(</span><span class="n">long_rows</span><span class="p">)</span> <span class="n">all_abilities</span> <span class="o">=</span> <span class="n">long_df</span><span class="p">[</span><span class="s2">"ability"</span><span class="p">]</span><span class="o">.</span><span class="n">unique</span><span class="p">()</span> <span class="n">total</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="n">all_abilities</span><span class="p">)</span> <span class="n">covered</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">long_df</span><span class="p">[</span><span class="n">long_df</span><span class="p">[</span><span class="s2">"value"</span><span class="p">]</span> <span class="o">==</span> <span class="s2">"divergent"</span><span class="p">][</span><span class="s2">"ability"</span><span class="p">]</span><span class="o">.</span><span class="n">nunique</span><span class="p">())</span> <span class="n">score</span> <span class="o">=</span> <span class="n">covered</span> <span class="o">/</span> <span class="n">total</span> <span class="k">if</span> <span class="n">total</span> <span class="o">></span> <span class="mi">0</span> <span class="k">else</span> <span class="mf">0.0</span> <span class="n">detail</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">"HSN assumption coverage: </span><span class="si">{</span><span class="n">covered</span><span class="si">}</span><span class="s2"> of </span><span class="si">{</span><span class="n">total</span><span class="si">}</span><span class="s2"> ability assumptions have at least one divergent human character."</span> <span class="k">return</span> <span class="n">score</span><span class="p">,</span> <span class="n">detail</span> </code></pre></div></td></tr></table></div> </details> </div> </div> <div class="doc doc-object doc-function"> <h7 id="dcs_simulation_engine.reporting.auto.sections.coverage_shared.human_score_card" class="doc doc-heading"> <code class="highlight language-python"><span class="n">human_score_card</span><span class="p">(</span><span class="n">human</span><span class="p">)</span></code> </h7> <div class="doc doc-contents "> <p>Bootstrap card summarising human HSN divergence coverage.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/reporting/auto/sections/coverage_shared.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">115</span> <span class="normal">116</span> <span class="normal">117</span> <span class="normal">118</span> <span class="normal">119</span> <span class="normal">120</span> <span class="normal">121</span> <span class="normal">122</span> <span class="normal">123</span> <span class="normal">124</span> <span class="normal">125</span> <span class="normal">126</span> <span class="normal">127</span> <span class="normal">128</span> <span class="normal">129</span> <span class="normal">130</span> <span class="normal">131</span> <span class="normal">132</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">human_score_card</span><span class="p">(</span><span class="n">human</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">])</span> <span class="o">-></span> <span class="nb">str</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Bootstrap card summarising human HSN divergence coverage."""</span> <span class="n">score</span><span class="p">,</span> <span class="n">detail</span> <span class="o">=</span> <span class="n">human_score</span><span class="p">(</span><span class="n">human</span><span class="p">)</span> <span class="n">pct</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">"</span><span class="si">{</span><span class="n">score</span><span class="si">:</span><span class="s2">.0%</span><span class="si">}</span><span class="s2">"</span> <span class="n">color</span> <span class="o">=</span> <span class="n">_score_color</span><span class="p">(</span><span class="n">score</span><span class="p">)</span> <span class="k">return</span> <span class="sa">f</span><span class="s2">"""</span> <span class="s2"><div class="row g-3 mb-4"></span> <span class="s2"> <div class="col-md-6"></span> <span class="s2"> <div class="card h-100"></span> <span class="s2"> <div class="card-body"></span> <span class="s2"> <h5 class="card-title">Human Coverage</h5></span> <span class="s2"> <p class="display-6 fw-bold mb-2" style="color:</span><span class="si">{</span><span class="n">color</span><span class="si">}</span><span class="s2">;"></span><span class="si">{</span><span class="n">pct</span><span class="si">}</span><span class="s2"></p></span> <span class="s2"> <p class="text-muted mb-0" style="font-size:0.85rem;"></span><span class="si">{</span><span class="n">detail</span><span class="si">}</span><span class="s2"></p></span> <span class="s2"> </div></span> <span class="s2"> </div></span> <span class="s2"> </div></span> <span class="s2"></div></span> <span class="s2">"""</span> </code></pre></div></td></tr></table></div> </details> </div> </div> <div class="doc doc-object doc-function"> <h7 id="dcs_simulation_engine.reporting.auto.sections.coverage_shared.nonhuman_score" class="doc doc-heading"> <code class="highlight language-python"><span class="n">nonhuman_score</span><span class="p">(</span><span class="n">nonhuman</span><span class="p">)</span></code> </h7> <div class="doc doc-contents "> <p>Fraction of dimension pair combinations covered (non-zero character count).</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/reporting/auto/sections/coverage_shared.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">19</span> <span class="normal">20</span> <span class="normal">21</span> <span class="normal">22</span> <span class="normal">23</span> <span class="normal">24</span> <span class="normal">25</span> <span class="normal">26</span> <span class="normal">27</span> <span class="normal">28</span> <span class="normal">29</span> <span class="normal">30</span> <span class="normal">31</span> <span class="normal">32</span> <span class="normal">33</span> <span class="normal">34</span> <span class="normal">35</span> <span class="normal">36</span> <span class="normal">37</span> <span class="normal">38</span> <span class="normal">39</span> <span class="normal">40</span> <span class="normal">41</span> <span class="normal">42</span> <span class="normal">43</span> <span class="normal">44</span> <span class="normal">45</span> <span class="normal">46</span> <span class="normal">47</span> <span class="normal">48</span> <span class="normal">49</span> <span class="normal">50</span> <span class="normal">51</span> <span class="normal">52</span> <span class="normal">53</span> <span class="normal">54</span> <span class="normal">55</span> <span class="normal">56</span> <span class="normal">57</span> <span class="normal">58</span> <span class="normal">59</span> <span class="normal">60</span> <span class="normal">61</span> <span class="normal">62</span> <span class="normal">63</span> <span class="normal">64</span> <span class="normal">65</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">nonhuman_score</span><span class="p">(</span><span class="n">nonhuman</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">])</span> <span class="o">-></span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">float</span><span class="p">,</span> <span class="nb">str</span><span class="p">]:</span> <span class="w"> </span><span class="sd">"""Fraction of dimension pair combinations covered (non-zero character count)."""</span> <span class="k">if</span> <span class="ow">not</span> <span class="n">nonhuman</span><span class="p">:</span> <span class="k">return</span> <span class="mf">0.0</span><span class="p">,</span> <span class="s2">"No non-human characters found."</span> <span class="n">long_rows</span> <span class="o">=</span> <span class="p">[]</span> <span class="k">for</span> <span class="n">c</span> <span class="ow">in</span> <span class="n">nonhuman</span><span class="p">:</span> <span class="k">for</span> <span class="n">dk</span><span class="p">,</span> <span class="n">entry</span> <span class="ow">in</span> <span class="n">c</span><span class="p">[</span><span class="s2">"dimensions"</span><span class="p">]</span><span class="o">.</span><span class="n">items</span><span class="p">():</span> <span class="k">for</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">entry</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">"value"</span><span class="p">)</span> <span class="ow">or</span> <span class="p">[]:</span> <span class="n">long_rows</span><span class="o">.</span><span class="n">append</span><span class="p">({</span><span class="s2">"hid"</span><span class="p">:</span> <span class="n">c</span><span class="p">[</span><span class="s2">"hid"</span><span class="p">],</span> <span class="s2">"dimension"</span><span class="p">:</span> <span class="n">dk</span><span class="p">,</span> <span class="s2">"value"</span><span class="p">:</span> <span class="n">v</span><span class="p">})</span> <span class="k">if</span> <span class="ow">not</span> <span class="n">long_rows</span><span class="p">:</span> <span class="k">return</span> <span class="mf">0.0</span><span class="p">,</span> <span class="s2">"No dimension data found."</span> <span class="n">long_df</span> <span class="o">=</span> <span class="n">pd</span><span class="o">.</span><span class="n">DataFrame</span><span class="p">(</span><span class="n">long_rows</span><span class="p">)</span> <span class="n">pairs</span> <span class="o">=</span> <span class="p">[(</span><span class="s2">"substrate"</span><span class="p">,</span> <span class="s2">"size"</span><span class="p">),</span> <span class="p">(</span><span class="s2">"common_labels"</span><span class="p">,</span> <span class="s2">"form"</span><span class="p">)]</span> <span class="n">total_combos</span> <span class="o">=</span> <span class="mi">0</span> <span class="n">covered_combos</span> <span class="o">=</span> <span class="mi">0</span> <span class="n">pair_details</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span> <span class="k">for</span> <span class="n">dim_a</span><span class="p">,</span> <span class="n">dim_b</span> <span class="ow">in</span> <span class="n">pairs</span><span class="p">:</span> <span class="n">a_vals</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">long_df</span><span class="p">[</span><span class="n">long_df</span><span class="p">[</span><span class="s2">"dimension"</span><span class="p">]</span> <span class="o">==</span> <span class="n">dim_a</span><span class="p">][</span><span class="s2">"value"</span><span class="p">]</span><span class="o">.</span><span class="n">unique</span><span class="p">())</span> <span class="n">b_vals</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">long_df</span><span class="p">[</span><span class="n">long_df</span><span class="p">[</span><span class="s2">"dimension"</span><span class="p">]</span> <span class="o">==</span> <span class="n">dim_b</span><span class="p">][</span><span class="s2">"value"</span><span class="p">]</span><span class="o">.</span><span class="n">unique</span><span class="p">())</span> <span class="k">if</span> <span class="ow">not</span> <span class="n">a_vals</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">b_vals</span><span class="p">:</span> <span class="k">continue</span> <span class="n">pair_covered</span> <span class="o">=</span> <span class="mi">0</span> <span class="k">for</span> <span class="n">a</span> <span class="ow">in</span> <span class="n">a_vals</span><span class="p">:</span> <span class="n">hids_a</span> <span class="o">=</span> <span class="nb">set</span><span class="p">(</span><span class="n">long_df</span><span class="p">[(</span><span class="n">long_df</span><span class="p">[</span><span class="s2">"dimension"</span><span class="p">]</span> <span class="o">==</span> <span class="n">dim_a</span><span class="p">)</span> <span class="o">&</span> <span class="p">(</span><span class="n">long_df</span><span class="p">[</span><span class="s2">"value"</span><span class="p">]</span> <span class="o">==</span> <span class="n">a</span><span class="p">)][</span><span class="s2">"hid"</span><span class="p">])</span> <span class="k">for</span> <span class="n">b</span> <span class="ow">in</span> <span class="n">b_vals</span><span class="p">:</span> <span class="n">hids_b</span> <span class="o">=</span> <span class="nb">set</span><span class="p">(</span><span class="n">long_df</span><span class="p">[(</span><span class="n">long_df</span><span class="p">[</span><span class="s2">"dimension"</span><span class="p">]</span> <span class="o">==</span> <span class="n">dim_b</span><span class="p">)</span> <span class="o">&</span> <span class="p">(</span><span class="n">long_df</span><span class="p">[</span><span class="s2">"value"</span><span class="p">]</span> <span class="o">==</span> <span class="n">b</span><span class="p">)][</span><span class="s2">"hid"</span><span class="p">])</span> <span class="k">if</span> <span class="n">hids_a</span> <span class="o">&</span> <span class="n">hids_b</span><span class="p">:</span> <span class="n">pair_covered</span> <span class="o">+=</span> <span class="mi">1</span> <span class="n">pair_total</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="n">a_vals</span><span class="p">)</span> <span class="o">*</span> <span class="nb">len</span><span class="p">(</span><span class="n">b_vals</span><span class="p">)</span> <span class="n">total_combos</span> <span class="o">+=</span> <span class="n">pair_total</span> <span class="n">covered_combos</span> <span class="o">+=</span> <span class="n">pair_covered</span> <span class="n">pair_details</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">"</span><span class="si">{</span><span class="n">dim_a</span><span class="si">}</span><span class="se">\u00d7</span><span class="si">{</span><span class="n">dim_b</span><span class="si">}</span><span class="s2">: </span><span class="si">{</span><span class="n">pair_covered</span><span class="si">}</span><span class="s2">/</span><span class="si">{</span><span class="n">pair_total</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span> <span class="k">if</span> <span class="n">total_combos</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span> <span class="k">return</span> <span class="mf">0.0</span><span class="p">,</span> <span class="s2">"No pairing data available."</span> <span class="n">score</span> <span class="o">=</span> <span class="n">covered_combos</span> <span class="o">/</span> <span class="n">total_combos</span> <span class="n">detail</span> <span class="o">=</span> <span class="p">(</span> <span class="sa">f</span><span class="s2">"Dimension pair combination coverage: </span><span class="si">{</span><span class="n">covered_combos</span><span class="si">}</span><span class="s2"> of </span><span class="si">{</span><span class="n">total_combos</span><span class="si">}</span><span class="s2"> "</span> <span class="sa">f</span><span class="s2">"combinations have at least one character "</span> <span class="sa">f</span><span class="s2">"(</span><span class="si">{</span><span class="s1">', '</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">pair_details</span><span class="p">)</span><span class="si">}</span><span class="s2">)."</span> <span class="p">)</span> <span class="k">return</span> <span class="n">score</span><span class="p">,</span> <span class="n">detail</span> </code></pre></div></td></tr></table></div> </details> </div> </div> <div class="doc doc-object doc-function"> <h7 id="dcs_simulation_engine.reporting.auto.sections.coverage_shared.nonhuman_score_card" class="doc doc-heading"> <code class="highlight language-python"><span class="n">nonhuman_score_card</span><span class="p">(</span><span class="n">nonhuman</span><span class="p">)</span></code> </h7> <div class="doc doc-contents "> <p>Bootstrap card summarising non-human dimension combination coverage.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/reporting/auto/sections/coverage_shared.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal"> 95</span> <span class="normal"> 96</span> <span class="normal"> 97</span> <span class="normal"> 98</span> <span class="normal"> 99</span> <span class="normal">100</span> <span class="normal">101</span> <span class="normal">102</span> <span class="normal">103</span> <span class="normal">104</span> <span class="normal">105</span> <span class="normal">106</span> <span class="normal">107</span> <span class="normal">108</span> <span class="normal">109</span> <span class="normal">110</span> <span class="normal">111</span> <span class="normal">112</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">nonhuman_score_card</span><span class="p">(</span><span class="n">nonhuman</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">])</span> <span class="o">-></span> <span class="nb">str</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Bootstrap card summarising non-human dimension combination coverage."""</span> <span class="n">score</span><span class="p">,</span> <span class="n">detail</span> <span class="o">=</span> <span class="n">nonhuman_score</span><span class="p">(</span><span class="n">nonhuman</span><span class="p">)</span> <span class="n">pct</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">"</span><span class="si">{</span><span class="n">score</span><span class="si">:</span><span class="s2">.0%</span><span class="si">}</span><span class="s2">"</span> <span class="n">color</span> <span class="o">=</span> <span class="n">_score_color</span><span class="p">(</span><span class="n">score</span><span class="p">)</span> <span class="k">return</span> <span class="sa">f</span><span class="s2">"""</span> <span class="s2"><div class="row g-3 mb-4"></span> <span class="s2"> <div class="col-md-6"></span> <span class="s2"> <div class="card h-100"></span> <span class="s2"> <div class="card-body"></span> <span class="s2"> <h5 class="card-title">Non-human Coverage</h5></span> <span class="s2"> <p class="display-6 fw-bold mb-2" style="color:</span><span class="si">{</span><span class="n">color</span><span class="si">}</span><span class="s2">;"></span><span class="si">{</span><span class="n">pct</span><span class="si">}</span><span class="s2"></p></span> <span class="s2"> <p class="text-muted mb-0" style="font-size:0.85rem;"></span><span class="si">{</span><span class="n">detail</span><span class="si">}</span><span class="s2"></p></span> <span class="s2"> </div></span> <span class="s2"> </div></span> <span class="s2"> </div></span> <span class="s2"></div></span> <span class="s2">"""</span> </code></pre></div></td></tr></table></div> </details> </div> </div> </div> </div> </div> <div class="doc doc-object doc-module"> <h6 id="dcs_simulation_engine.reporting.auto.sections.event_log" class="doc doc-heading"> <code>event_log</code> </h6> <div class="doc doc-contents "> <p>Section 7 — Full Event Log.</p> <p>Session-events DataTable. PC/NPC/player columns are joined from runs_df since session_events only carries session_id.</p> <div class="doc doc-children"> </div> </div> </div> <div class="doc doc-object doc-module"> <h6 id="dcs_simulation_engine.reporting.auto.sections.form_responses" class="doc doc-heading"> <code>form_responses</code> </h6> <div class="doc doc-contents "> <p>Section — Form Responses.</p> <p>Renders flattened assignment form answers (pre/post-game surveys).</p> <div class="doc doc-children"> </div> </div> </div> <div class="doc doc-object doc-module"> <h6 id="dcs_simulation_engine.reporting.auto.sections.logs_table" class="doc doc-heading"> <code>logs_table</code> </h6> <div class="doc doc-contents "> <p>Section 9 — Logs.</p> <p>Full interactive DataTable of all log events parsed from *.log files.</p> <div class="doc doc-children"> </div> </div> </div> <div class="doc doc-object doc-module"> <h6 id="dcs_simulation_engine.reporting.auto.sections.metadata" class="doc doc-heading"> <code>metadata</code> </h6> <div class="doc doc-contents "> <p>Section 1 — Metadata.</p> <p>Renders a Bootstrap card with key facts about the run and links to raw result artifacts when available.</p> <div class="doc doc-children"> </div> </div> </div> <div class="doc doc-object doc-module"> <h6 id="dcs_simulation_engine.reporting.auto.sections.player_engagement" class="doc doc-heading"> <code>player_engagement</code> </h6> <div class="doc doc-contents "> <p>Section 4 — Player Engagement.</p> <details class="plotly-charts" open> <summary>Plotly charts</summary> <ul> <li>Runs per player (bar)</li> <li>Engagement by prior_experience if available (grouped bar)</li> </ul> </details> <div class="doc doc-children"> </div> </div> </div> <div class="doc doc-object doc-module"> <h6 id="dcs_simulation_engine.reporting.auto.sections.player_feedback" class="doc doc-heading"> <code>player_feedback</code> </h6> <div class="doc doc-contents "> <p>Section — Player Feedback.</p> <p>Renders: 1. Flag distribution chart — how often each flag type fires per turn. 2. In-play feedback — inline thumbs/flags/comments on NPC messages, with transcript context. 3. Player feedback patterns — per-player segmentation by feedback behaviour.</p> <div class="doc doc-children"> </div> </div> </div> <div class="doc doc-object doc-module"> <h6 id="dcs_simulation_engine.reporting.auto.sections.player_performance" class="doc doc-heading"> <code>player_performance</code> </h6> <div class="doc doc-contents "> <p>Section - Player Performance.</p> <p>Summarizes scored gameplay sessions by player and game.</p> <div class="doc doc-children"> </div> </div> </div> <div class="doc doc-object doc-module"> <h6 id="dcs_simulation_engine.reporting.auto.sections.players_table" class="doc doc-heading"> <code>players_table</code> </h6> <div class="doc doc-contents "> <p>Section 8 — Players.</p> <p>PII-safe player records DataTable. Raw PII columns (email, phone_number, full_name) are already stripped from players_df by the loader. Run count is added by joining against runs_df.</p> <div class="doc doc-children"> </div> </div> </div> <div class="doc doc-object doc-module"> <h6 id="dcs_simulation_engine.reporting.auto.sections.runs_overview" class="doc doc-heading"> <code>runs_overview</code> </h6> <div class="doc doc-contents "> <p>Section 2 — Runs Overview Table.</p> <p>Renders a DataTables interactive table with one row per session (run), showing player, game, characters, turn count, duration, exit reason, etc.</p> <div class="doc doc-children"> </div> </div> </div> <div class="doc doc-object doc-module"> <h6 id="dcs_simulation_engine.reporting.auto.sections.simulation_quality" class="doc doc-heading"> <code>simulation_quality</code> </h6> <div class="doc doc-contents "> <p>Section — Simulation Quality.</p> <p>Renders: 1. Scores summary card — overall ICF, NCo, and Other rates across all NPC turns. 2. Per-NPC scores table — ICF, NCo, Other, and Scenario Coverage broken down by NPC character.</p> <div class="doc doc-children"> <div class="doc doc-object doc-function"> <h7 id="dcs_simulation_engine.reporting.auto.sections.simulation_quality.build_character_quality_report" class="doc doc-heading"> <code class="highlight language-python"><span class="n">build_character_quality_report</span><span class="p">(</span><span class="n">hid</span><span class="p">,</span> <span class="n">data</span><span class="p">)</span></code> </h7> <div class="doc doc-contents "> <p>Build a standalone per-character quality HTML report for the given NPC HID.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/reporting/auto/sections/simulation_quality.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">214</span> <span class="normal">215</span> <span class="normal">216</span> <span class="normal">217</span> <span class="normal">218</span> <span class="normal">219</span> <span class="normal">220</span> <span class="normal">221</span> <span class="normal">222</span> <span class="normal">223</span> <span class="normal">224</span> <span class="normal">225</span> <span class="normal">226</span> <span class="normal">227</span> <span class="normal">228</span> <span class="normal">229</span> <span class="normal">230</span> <span class="normal">231</span> <span class="normal">232</span> <span class="normal">233</span> <span class="normal">234</span> <span class="normal">235</span> <span class="normal">236</span> <span class="normal">237</span> <span class="normal">238</span> <span class="normal">239</span> <span class="normal">240</span> <span class="normal">241</span> <span class="normal">242</span> <span class="normal">243</span> <span class="normal">244</span> <span class="normal">245</span> <span class="normal">246</span> <span class="normal">247</span> <span class="normal">248</span> <span class="normal">249</span> <span class="normal">250</span> <span class="normal">251</span> <span class="normal">252</span> <span class="normal">253</span> <span class="normal">254</span> <span class="normal">255</span> <span class="normal">256</span> <span class="normal">257</span> <span class="normal">258</span> <span class="normal">259</span> <span class="normal">260</span> <span class="normal">261</span> <span class="normal">262</span> <span class="normal">263</span> <span class="normal">264</span> <span class="normal">265</span> <span class="normal">266</span> <span class="normal">267</span> <span class="normal">268</span> <span class="normal">269</span> <span class="normal">270</span> <span class="normal">271</span> <span class="normal">272</span> <span class="normal">273</span> <span class="normal">274</span> <span class="normal">275</span> <span class="normal">276</span> <span class="normal">277</span> <span class="normal">278</span> <span class="normal">279</span> <span class="normal">280</span> <span class="normal">281</span> <span class="normal">282</span> <span class="normal">283</span> <span class="normal">284</span> <span class="normal">285</span> <span class="normal">286</span> <span class="normal">287</span> <span class="normal">288</span> <span class="normal">289</span> <span class="normal">290</span> <span class="normal">291</span> <span class="normal">292</span> <span class="normal">293</span> <span class="normal">294</span> <span class="normal">295</span> <span class="normal">296</span> <span class="normal">297</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">build_character_quality_report</span><span class="p">(</span><span class="n">hid</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">data</span><span class="p">:</span> <span class="n">AnalysisData</span><span class="p">)</span> <span class="o">-></span> <span class="nb">str</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Build a standalone per-character quality HTML report for the given NPC HID."""</span> <span class="kn">from</span><span class="w"> </span><span class="nn">dcs_simulation_engine.reporting.auto.rendering.html_builder</span><span class="w"> </span><span class="kn">import</span> <span class="n">build_html</span> <span class="kn">from</span><span class="w"> </span><span class="nn">dcs_simulation_engine.reporting.auto.sections</span><span class="w"> </span><span class="kn">import</span> <span class="n">event_log</span><span class="p">,</span> <span class="n">player_feedback</span> <span class="c1"># --- Character metadata ---</span> <span class="n">char_meta</span><span class="p">:</span> <span class="nb">dict</span> <span class="o">=</span> <span class="p">{</span><span class="s2">"hid"</span><span class="p">:</span> <span class="n">hid</span><span class="p">}</span> <span class="k">if</span> <span class="ow">not</span> <span class="n">data</span><span class="o">.</span><span class="n">characters_df</span><span class="o">.</span><span class="n">empty</span> <span class="ow">and</span> <span class="s2">"hid"</span> <span class="ow">in</span> <span class="n">data</span><span class="o">.</span><span class="n">characters_df</span><span class="o">.</span><span class="n">columns</span><span class="p">:</span> <span class="n">matches</span> <span class="o">=</span> <span class="n">data</span><span class="o">.</span><span class="n">characters_df</span><span class="p">[</span><span class="n">data</span><span class="o">.</span><span class="n">characters_df</span><span class="p">[</span><span class="s2">"hid"</span><span class="p">]</span> <span class="o">==</span> <span class="n">hid</span><span class="p">]</span> <span class="k">if</span> <span class="ow">not</span> <span class="n">matches</span><span class="o">.</span><span class="n">empty</span><span class="p">:</span> <span class="n">char_row</span> <span class="o">=</span> <span class="n">matches</span><span class="o">.</span><span class="n">iloc</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="k">for</span> <span class="n">key</span> <span class="ow">in</span> <span class="p">(</span><span class="s2">"name"</span><span class="p">,</span> <span class="s2">"short_description"</span><span class="p">,</span> <span class="s2">"long_description"</span><span class="p">):</span> <span class="k">if</span> <span class="n">key</span> <span class="ow">in</span> <span class="n">char_row</span><span class="o">.</span><span class="n">index</span> <span class="ow">and</span> <span class="n">pd</span><span class="o">.</span><span class="n">notna</span><span class="p">(</span><span class="n">char_row</span><span class="p">[</span><span class="n">key</span><span class="p">]):</span> <span class="n">char_meta</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="o">=</span> <span class="n">char_row</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="c1"># --- Session IDs for this NPC ---</span> <span class="n">session_ids</span><span class="p">:</span> <span class="nb">set</span> <span class="o">=</span> <span class="nb">set</span><span class="p">()</span> <span class="k">if</span> <span class="ow">not</span> <span class="n">data</span><span class="o">.</span><span class="n">runs_df</span><span class="o">.</span><span class="n">empty</span> <span class="ow">and</span> <span class="s2">"npc_hid"</span> <span class="ow">in</span> <span class="n">data</span><span class="o">.</span><span class="n">runs_df</span><span class="o">.</span><span class="n">columns</span> <span class="ow">and</span> <span class="s2">"session_id"</span> <span class="ow">in</span> <span class="n">data</span><span class="o">.</span><span class="n">runs_df</span><span class="o">.</span><span class="n">columns</span><span class="p">:</span> <span class="n">session_ids</span> <span class="o">=</span> <span class="nb">set</span><span class="p">(</span><span class="n">data</span><span class="o">.</span><span class="n">runs_df</span><span class="p">[</span><span class="n">data</span><span class="o">.</span><span class="n">runs_df</span><span class="p">[</span><span class="s2">"npc_hid"</span><span class="p">]</span> <span class="o">==</span> <span class="n">hid</span><span class="p">][</span><span class="s2">"session_id"</span><span class="p">]</span><span class="o">.</span><span class="n">dropna</span><span class="p">())</span> <span class="c1"># --- Filtered DataFrames ---</span> <span class="k">def</span><span class="w"> </span><span class="nf">_filter</span><span class="p">(</span><span class="n">df</span><span class="p">:</span> <span class="n">pd</span><span class="o">.</span><span class="n">DataFrame</span><span class="p">)</span> <span class="o">-></span> <span class="n">pd</span><span class="o">.</span><span class="n">DataFrame</span><span class="p">:</span> <span class="k">if</span> <span class="n">df</span><span class="o">.</span><span class="n">empty</span> <span class="ow">or</span> <span class="s2">"session_id"</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">df</span><span class="o">.</span><span class="n">columns</span><span class="p">:</span> <span class="k">return</span> <span class="n">pd</span><span class="o">.</span><span class="n">DataFrame</span><span class="p">()</span> <span class="k">return</span> <span class="n">df</span><span class="p">[</span><span class="n">df</span><span class="p">[</span><span class="s2">"session_id"</span><span class="p">]</span><span class="o">.</span><span class="n">isin</span><span class="p">(</span><span class="n">session_ids</span><span class="p">)]</span><span class="o">.</span><span class="n">copy</span><span class="p">()</span> <span class="n">char_data</span> <span class="o">=</span> <span class="n">AnalysisData</span><span class="p">(</span> <span class="n">results_dir</span><span class="o">=</span><span class="n">data</span><span class="o">.</span><span class="n">results_dir</span><span class="p">,</span> <span class="n">manifest</span><span class="o">=</span><span class="n">data</span><span class="o">.</span><span class="n">manifest</span><span class="p">,</span> <span class="n">run</span><span class="o">=</span><span class="n">data</span><span class="o">.</span><span class="n">run</span><span class="p">,</span> <span class="n">runs_df</span><span class="o">=</span><span class="n">_filter</span><span class="p">(</span><span class="n">data</span><span class="o">.</span><span class="n">runs_df</span><span class="p">),</span> <span class="n">players_df</span><span class="o">=</span><span class="n">data</span><span class="o">.</span><span class="n">players_df</span><span class="p">,</span> <span class="n">player_forms_df</span><span class="o">=</span><span class="n">data</span><span class="o">.</span><span class="n">player_forms_df</span><span class="p">,</span> <span class="n">transcripts_df</span><span class="o">=</span><span class="n">_filter</span><span class="p">(</span><span class="n">data</span><span class="o">.</span><span class="n">transcripts_df</span><span class="p">),</span> <span class="n">assignments_df</span><span class="o">=</span><span class="n">data</span><span class="o">.</span><span class="n">assignments_df</span><span class="p">,</span> <span class="n">feedback_df</span><span class="o">=</span><span class="n">data</span><span class="o">.</span><span class="n">feedback_df</span><span class="p">,</span> <span class="n">event_feedback_df</span><span class="o">=</span><span class="n">_filter</span><span class="p">(</span><span class="n">data</span><span class="o">.</span><span class="n">event_feedback_df</span><span class="p">),</span> <span class="n">logs_df</span><span class="o">=</span><span class="n">data</span><span class="o">.</span><span class="n">logs_df</span><span class="p">,</span> <span class="n">logs_source</span><span class="o">=</span><span class="n">data</span><span class="o">.</span><span class="n">logs_source</span><span class="p">,</span> <span class="n">characters_df</span><span class="o">=</span><span class="n">data</span><span class="o">.</span><span class="n">characters_df</span><span class="p">,</span> <span class="n">errors_df</span><span class="o">=</span><span class="n">data</span><span class="o">.</span><span class="n">errors_df</span><span class="p">,</span> <span class="p">)</span> <span class="c1"># --- Scenario coverage ---</span> <span class="n">all_cats</span> <span class="o">=</span> <span class="n">_load_pressure_categories</span><span class="p">()</span> <span class="n">covered</span> <span class="o">=</span> <span class="n">_compute_covered_categories</span><span class="p">(</span><span class="n">hid</span><span class="p">,</span> <span class="n">data</span><span class="o">.</span><span class="n">runs_df</span><span class="p">)</span> <span class="k">if</span> <span class="n">all_cats</span><span class="p">:</span> <span class="n">coverage_text</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">"</span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">covered</span><span class="p">)</span><span class="si">}</span><span class="s2">/</span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">all_cats</span><span class="p">)</span><span class="si">}</span><span class="s2"> (</span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">covered</span><span class="p">)</span><span class="w"> </span><span class="o">/</span><span class="w"> </span><span class="nb">len</span><span class="p">(</span><span class="n">all_cats</span><span class="p">)</span><span class="si">:</span><span class="s2">.1%</span><span class="si">}</span><span class="s2">)"</span> <span class="k">else</span><span class="p">:</span> <span class="n">coverage_text</span> <span class="o">=</span> <span class="kc">None</span> <span class="c1"># --- Section fragments ---</span> <span class="c1"># 1. Metadata</span> <span class="n">meta_rows</span> <span class="o">=</span> <span class="p">[(</span><span class="s2">"HID"</span><span class="p">,</span> <span class="n">html_lib</span><span class="o">.</span><span class="n">escape</span><span class="p">(</span><span class="n">hid</span><span class="p">))]</span> <span class="k">for</span> <span class="n">key</span><span class="p">,</span> <span class="n">label</span> <span class="ow">in</span> <span class="p">((</span><span class="s2">"name"</span><span class="p">,</span> <span class="s2">"Name"</span><span class="p">),</span> <span class="p">(</span><span class="s2">"short_description"</span><span class="p">,</span> <span class="s2">"Description"</span><span class="p">),</span> <span class="p">(</span><span class="s2">"long_description"</span><span class="p">,</span> <span class="s2">"Long Description"</span><span class="p">)):</span> <span class="k">if</span> <span class="n">key</span> <span class="ow">in</span> <span class="n">char_meta</span><span class="p">:</span> <span class="n">meta_rows</span><span class="o">.</span><span class="n">append</span><span class="p">((</span><span class="n">label</span><span class="p">,</span> <span class="n">html_lib</span><span class="o">.</span><span class="n">escape</span><span class="p">(</span><span class="nb">str</span><span class="p">(</span><span class="n">char_meta</span><span class="p">[</span><span class="n">key</span><span class="p">]))))</span> <span class="n">dl_items</span> <span class="o">=</span> <span class="s2">""</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="sa">f</span><span class="s2">"<dt class='col-sm-3'></span><span class="si">{</span><span class="n">lbl</span><span class="si">}</span><span class="s2"></dt><dd class='col-sm-9'></span><span class="si">{</span><span class="n">val</span><span class="si">}</span><span class="s2"></dd>"</span> <span class="k">for</span> <span class="n">lbl</span><span class="p">,</span> <span class="n">val</span> <span class="ow">in</span> <span class="n">meta_rows</span><span class="p">)</span> <span class="n">metadata_fragment</span> <span class="o">=</span> <span class="sa">f</span><span class="s1">'<div class="card"><div class="card-body"><dl class="row dl-meta mb-0"></span><span class="si">{</span><span class="n">dl_items</span><span class="si">}</span><span class="s1"></dl></div></div>'</span> <span class="c1"># 2. Overview (scores summary filtered to this character)</span> <span class="n">overview_fragment</span> <span class="o">=</span> <span class="n">_scores_summary_card</span><span class="p">(</span><span class="n">char_data</span><span class="o">.</span><span class="n">transcripts_df</span><span class="p">,</span> <span class="n">coverage_text</span><span class="o">=</span><span class="n">coverage_text</span><span class="p">)</span> <span class="c1"># 3. Scenario Coverage</span> <span class="n">scenario_fragment</span> <span class="o">=</span> <span class="n">_scenario_coverage_section</span><span class="p">(</span><span class="n">hid</span><span class="p">,</span> <span class="n">data</span><span class="o">.</span><span class="n">runs_df</span><span class="p">)</span> <span class="c1"># 4. Feedback</span> <span class="n">feedback_fragment</span> <span class="o">=</span> <span class="n">player_feedback</span><span class="o">.</span><span class="n">render</span><span class="p">(</span><span class="n">char_data</span><span class="p">)</span> <span class="c1"># 5. Full Event Log</span> <span class="n">event_log_fragment</span> <span class="o">=</span> <span class="n">event_log</span><span class="o">.</span><span class="n">render</span><span class="p">(</span><span class="n">char_data</span><span class="p">)</span> <span class="c1"># --- Assemble ---</span> <span class="n">sections</span> <span class="o">=</span> <span class="p">[</span> <span class="p">(</span><span class="s2">"metadata"</span><span class="p">,</span> <span class="s2">"Metadata"</span><span class="p">,</span> <span class="n">metadata_fragment</span><span class="p">,</span> <span class="s2">"top"</span><span class="p">),</span> <span class="p">(</span><span class="s2">"overview"</span><span class="p">,</span> <span class="s2">"Overview"</span><span class="p">,</span> <span class="n">overview_fragment</span><span class="p">,</span> <span class="s2">"top"</span><span class="p">),</span> <span class="p">(</span><span class="s2">"scenario-coverage"</span><span class="p">,</span> <span class="s2">"Scenario Coverage"</span><span class="p">,</span> <span class="n">scenario_fragment</span><span class="p">,</span> <span class="s2">"top"</span><span class="p">),</span> <span class="p">(</span><span class="s2">"feedback"</span><span class="p">,</span> <span class="s2">"Feedback"</span><span class="p">,</span> <span class="n">feedback_fragment</span><span class="p">,</span> <span class="s2">"top"</span><span class="p">),</span> <span class="p">(</span><span class="s2">"event-log"</span><span class="p">,</span> <span class="s2">"Full Event Log"</span><span class="p">,</span> <span class="n">event_log_fragment</span><span class="p">,</span> <span class="s2">"top"</span><span class="p">),</span> <span class="p">]</span> <span class="n">char_name</span> <span class="o">=</span> <span class="n">char_meta</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">"name"</span><span class="p">)</span> <span class="ow">or</span> <span class="n">hid</span> <span class="k">return</span> <span class="n">build_html</span><span class="p">(</span><span class="n">sections</span><span class="p">,</span> <span class="n">title</span><span class="o">=</span><span class="sa">f</span><span class="s2">"</span><span class="si">{</span><span class="n">char_name</span><span class="si">}</span><span class="s2"> — Quality Report"</span><span class="p">,</span> <span class="n">download_items</span><span class="o">=</span><span class="p">[])</span> </code></pre></div></td></tr></table></div> </details> </div> </div> </div> </div> </div> <div class="doc doc-object doc-module"> <h6 id="dcs_simulation_engine.reporting.auto.sections.system_errors" class="doc doc-heading"> <code>system_errors</code> </h6> <div class="doc doc-contents "> <p>Section 10 — System Errors.</p> <details class="two-views-of-errors" open> <summary>Two views of errors</summary> <ol> <li>Summary stats card — log-level counts, in-game error event count, validation-lockout and internal-failure session counts.</li> <li>Charts — log level breakdown (bar) + in-game error events per session (bar).</li> <li>Tables — player-facing error events and the full logs collection.</li> </ol> </details> <div class="doc doc-children"> </div> </div> </div> <div class="doc doc-object doc-module"> <h6 id="dcs_simulation_engine.reporting.auto.sections.system_performance" class="doc doc-heading"> <code>system_performance</code> </h6> <div class="doc doc-contents "> <p>Section 3 — System Performance.</p> <p>Plotly charts covering run durations, pacing, exit reasons, retry budget, PC/NPC pairings, and a session timeline (Gantt).</p> <div class="doc doc-children"> </div> </div> </div> </div> </div> </div> </div> </div> </div> <div class="doc doc-object doc-module"> <h4 id="dcs_simulation_engine.reporting.loader" class="doc doc-heading"> <code>loader</code> </h4> <div class="doc doc-contents "> <p>Load a DCS results directory into analysis-ready DataFrames.</p> <details class="usage" open> <summary>Usage</summary> <p>from dcs_simulation_engine.reporting import load_all data = load_all("/path/to/results")</p> </details> <div class="doc doc-children"> <div class="doc doc-object doc-class"> <h5 id="dcs_simulation_engine.reporting.loader.AnalysisData" class="doc doc-heading"> <code>AnalysisData</code> <span class="doc doc-labels"> <small class="doc doc-label doc-label-dataclass"><code>dataclass</code></small> </span> </h5> <div class="doc doc-contents "> <p>All analysis-ready DataFrames loaded from a single results directory.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/reporting/loader.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">115</span> <span class="normal">116</span> <span class="normal">117</span> <span class="normal">118</span> <span class="normal">119</span> <span class="normal">120</span> <span class="normal">121</span> <span class="normal">122</span> <span class="normal">123</span> <span class="normal">124</span> <span class="normal">125</span> <span class="normal">126</span> <span class="normal">127</span> <span class="normal">128</span> <span class="normal">129</span> <span class="normal">130</span> <span class="normal">131</span> <span class="normal">132</span> <span class="normal">133</span> <span class="normal">134</span> <span class="normal">135</span> <span class="normal">136</span> <span class="normal">137</span> <span class="normal">138</span> <span class="normal">139</span> <span class="normal">140</span> <span class="normal">141</span> <span class="normal">142</span> <span class="normal">143</span> <span class="normal">144</span> <span class="normal">145</span> <span class="normal">146</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="nd">@dataclass</span> <span class="k">class</span><span class="w"> </span><span class="nc">AnalysisData</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""All analysis-ready DataFrames loaded from a single results directory."""</span> <span class="n">results_dir</span><span class="p">:</span> <span class="n">Path</span> <span class="c1"># Raw metadata</span> <span class="n">manifest</span><span class="p">:</span> <span class="nb">dict</span> <span class="n">run</span><span class="p">:</span> <span class="nb">dict</span> <span class="c1"># first record from runs.json, or {}</span> <span class="c1"># Core DataFrames</span> <span class="n">runs_df</span><span class="p">:</span> <span class="n">pd</span><span class="o">.</span><span class="n">DataFrame</span> <span class="c1"># sessions — one row per run</span> <span class="n">players_df</span><span class="p">:</span> <span class="n">pd</span><span class="o">.</span><span class="n">DataFrame</span> <span class="c1"># players (PII columns dropped)</span> <span class="n">player_forms_df</span><span class="p">:</span> <span class="n">pd</span><span class="o">.</span><span class="n">DataFrame</span> <span class="c1"># player-scoped form payloads</span> <span class="n">transcripts_df</span><span class="p">:</span> <span class="n">pd</span><span class="o">.</span><span class="n">DataFrame</span> <span class="c1"># session_events — one row per event</span> <span class="n">assignments_df</span><span class="p">:</span> <span class="n">pd</span><span class="o">.</span><span class="n">DataFrame</span> <span class="c1"># assignments — one row per assignment</span> <span class="n">feedback_df</span><span class="p">:</span> <span class="n">pd</span><span class="o">.</span><span class="n">DataFrame</span> <span class="c1"># flattened form answers</span> <span class="n">event_feedback_df</span><span class="p">:</span> <span class="n">pd</span><span class="o">.</span><span class="n">DataFrame</span> <span class="c1"># inline per-message feedback from session_events</span> <span class="n">logs_df</span><span class="p">:</span> <span class="n">pd</span><span class="o">.</span><span class="n">DataFrame</span> <span class="c1"># log events (empty if no logs/ dir)</span> <span class="n">logs_source</span><span class="p">:</span> <span class="nb">str</span> <span class="c1"># logs.json, logs/*.log, or "" when no log source was found</span> <span class="n">characters_df</span><span class="p">:</span> <span class="n">pd</span><span class="o">.</span><span class="n">DataFrame</span> <span class="c1"># characters</span> <span class="n">errors_df</span><span class="p">:</span> <span class="n">pd</span><span class="o">.</span><span class="n">DataFrame</span> <span class="c1"># WARNING/ERROR/CRITICAL subset of logs_df</span> <span class="nd">@property</span> <span class="k">def</span><span class="w"> </span><span class="nf">runs_enriched_df</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-></span> <span class="n">pd</span><span class="o">.</span><span class="n">DataFrame</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""runs_df left-joined with non-PII player columns."""</span> <span class="n">df</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">runs_df</span><span class="o">.</span><span class="n">copy</span><span class="p">()</span> <span class="k">if</span> <span class="bp">self</span><span class="o">.</span><span class="n">players_df</span><span class="o">.</span><span class="n">empty</span> <span class="ow">or</span> <span class="s2">"access_key"</span> <span class="ow">not</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">players_df</span><span class="o">.</span><span class="n">columns</span><span class="p">:</span> <span class="k">return</span> <span class="n">df</span> <span class="n">join_cols</span> <span class="o">=</span> <span class="p">[</span><span class="n">c</span> <span class="k">for</span> <span class="n">c</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">players_df</span><span class="o">.</span><span class="n">columns</span> <span class="k">if</span> <span class="n">c</span> <span class="ow">not</span> <span class="ow">in</span> <span class="p">{</span><span class="s2">"_id"</span><span class="p">}]</span> <span class="n">player_sub</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">players_df</span><span class="p">[</span><span class="n">join_cols</span><span class="p">]</span><span class="o">.</span><span class="n">rename</span><span class="p">(</span><span class="n">columns</span><span class="o">=</span><span class="p">{</span><span class="s2">"access_key"</span><span class="p">:</span> <span class="s2">"player_id"</span><span class="p">})</span> <span class="k">return</span> <span class="n">df</span><span class="o">.</span><span class="n">merge</span><span class="p">(</span><span class="n">player_sub</span><span class="p">,</span> <span class="n">on</span><span class="o">=</span><span class="s2">"player_id"</span><span class="p">,</span> <span class="n">how</span><span class="o">=</span><span class="s2">"left"</span><span class="p">)</span> </code></pre></div></td></tr></table></div> </details> <div class="doc doc-children"> <div class="doc doc-object doc-attribute"> <h6 id="dcs_simulation_engine.reporting.loader.AnalysisData.runs_enriched_df" class="doc doc-heading"> <code class="highlight language-python"><span class="n">runs_enriched_df</span></code> <span class="doc doc-labels"> <small class="doc doc-label doc-label-property"><code>property</code></small> </span> </h6> <div class="doc doc-contents "> <p>runs_df left-joined with non-PII player columns.</p> </div> </div> </div> </div> </div> <div class="doc doc-object doc-function"> <h5 id="dcs_simulation_engine.reporting.loader.load_all" class="doc doc-heading"> <code class="highlight language-python"><span class="n">load_all</span><span class="p">(</span><span class="n">results_dir</span><span class="p">)</span></code> </h5> <div class="doc doc-contents "> <p>Load all result files from <em>results_dir</em> into an :class:<code>AnalysisData</code>.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/reporting/loader.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">154</span> <span class="normal">155</span> <span class="normal">156</span> <span class="normal">157</span> <span class="normal">158</span> <span class="normal">159</span> <span class="normal">160</span> <span class="normal">161</span> <span class="normal">162</span> <span class="normal">163</span> <span class="normal">164</span> <span class="normal">165</span> <span class="normal">166</span> <span class="normal">167</span> <span class="normal">168</span> <span class="normal">169</span> <span class="normal">170</span> <span class="normal">171</span> <span class="normal">172</span> <span class="normal">173</span> <span class="normal">174</span> <span class="normal">175</span> <span class="normal">176</span> <span class="normal">177</span> <span class="normal">178</span> <span class="normal">179</span> <span class="normal">180</span> <span class="normal">181</span> <span class="normal">182</span> <span class="normal">183</span> <span class="normal">184</span> <span class="normal">185</span> <span class="normal">186</span> <span class="normal">187</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">load_all</span><span class="p">(</span><span class="n">results_dir</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="n">Path</span><span class="p">)</span> <span class="o">-></span> <span class="n">AnalysisData</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Load all result files from *results_dir* into an :class:`AnalysisData`."""</span> <span class="n">results_dir</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span><span class="n">results_dir</span><span class="p">)</span><span class="o">.</span><span class="n">resolve</span><span class="p">()</span> <span class="n">manifest</span> <span class="o">=</span> <span class="n">_load_manifest</span><span class="p">(</span><span class="n">results_dir</span><span class="p">)</span> <span class="n">run</span> <span class="o">=</span> <span class="n">_load_run</span><span class="p">(</span><span class="n">results_dir</span><span class="p">)</span> <span class="n">runs_df</span> <span class="o">=</span> <span class="n">_load_runs</span><span class="p">(</span><span class="n">results_dir</span><span class="p">)</span> <span class="n">players_df</span> <span class="o">=</span> <span class="n">_load_players</span><span class="p">(</span><span class="n">results_dir</span><span class="p">)</span> <span class="n">player_forms_df</span> <span class="o">=</span> <span class="n">_load_player_forms</span><span class="p">(</span><span class="n">results_dir</span><span class="p">)</span> <span class="n">transcripts_df</span> <span class="o">=</span> <span class="n">_load_transcripts</span><span class="p">(</span><span class="n">results_dir</span><span class="p">)</span> <span class="n">assignments_df</span> <span class="o">=</span> <span class="n">_load_assignments</span><span class="p">(</span><span class="n">results_dir</span><span class="p">)</span> <span class="n">feedback_df</span> <span class="o">=</span> <span class="n">_build_feedback</span><span class="p">(</span><span class="n">assignments_df</span><span class="p">,</span> <span class="n">player_forms_df</span><span class="p">)</span> <span class="n">event_feedback_df</span> <span class="o">=</span> <span class="n">_build_event_feedback</span><span class="p">(</span><span class="n">transcripts_df</span><span class="p">,</span> <span class="n">runs_df</span><span class="p">)</span> <span class="n">characters_df</span> <span class="o">=</span> <span class="n">_load_characters</span><span class="p">(</span><span class="n">results_dir</span><span class="p">)</span> <span class="n">logs_source</span> <span class="o">=</span> <span class="n">_logs_source_label</span><span class="p">(</span><span class="n">results_dir</span><span class="p">)</span> <span class="n">logs_df</span> <span class="o">=</span> <span class="n">_load_logs_safe</span><span class="p">(</span><span class="n">results_dir</span><span class="p">)</span> <span class="n">errors_df</span> <span class="o">=</span> <span class="n">_filter_errors</span><span class="p">(</span><span class="n">logs_df</span><span class="p">)</span> <span class="k">return</span> <span class="n">AnalysisData</span><span class="p">(</span> <span class="n">results_dir</span><span class="o">=</span><span class="n">results_dir</span><span class="p">,</span> <span class="n">manifest</span><span class="o">=</span><span class="n">manifest</span><span class="p">,</span> <span class="n">run</span><span class="o">=</span><span class="n">run</span><span class="p">,</span> <span class="n">runs_df</span><span class="o">=</span><span class="n">runs_df</span><span class="p">,</span> <span class="n">players_df</span><span class="o">=</span><span class="n">players_df</span><span class="p">,</span> <span class="n">player_forms_df</span><span class="o">=</span><span class="n">player_forms_df</span><span class="p">,</span> <span class="n">transcripts_df</span><span class="o">=</span><span class="n">transcripts_df</span><span class="p">,</span> <span class="n">assignments_df</span><span class="o">=</span><span class="n">assignments_df</span><span class="p">,</span> <span class="n">feedback_df</span><span class="o">=</span><span class="n">feedback_df</span><span class="p">,</span> <span class="n">event_feedback_df</span><span class="o">=</span><span class="n">event_feedback_df</span><span class="p">,</span> <span class="n">logs_df</span><span class="o">=</span><span class="n">logs_df</span><span class="p">,</span> <span class="n">logs_source</span><span class="o">=</span><span class="n">logs_source</span><span class="p">,</span> <span class="n">characters_df</span><span class="o">=</span><span class="n">characters_df</span><span class="p">,</span> <span class="n">errors_df</span><span class="o">=</span><span class="n">errors_df</span><span class="p">,</span> <span class="p">)</span> </code></pre></div></td></tr></table></div> </details> </div> </div> </div> </div> </div> </div> </div> </div> <div class="doc doc-object doc-module"> <h3 id="dcs_simulation_engine.utils" class="doc doc-heading"> <code>utils</code> </h3> <div class="doc doc-contents "> <p>Utils: pure generic building blocks.</p> <p>Contents should be: - Is pure and reusable anywhere - Has no knowledge of your app - Operates on basic data types</p> <div class="doc doc-children"> <div class="doc doc-object doc-module"> <h4 id="dcs_simulation_engine.utils.assets" class="doc doc-heading"> <code>assets</code> </h4> <div class="doc doc-contents "> <p>Resolve canonical DCS asset paths for repo (host machine or devcontainer) and installed-package.</p> <div class="doc doc-children"> <div class="doc doc-object doc-class"> <h5 id="dcs_simulation_engine.utils.assets.DCSAssets" class="doc doc-heading"> <code>DCSAssets</code> <span class="doc doc-labels"> <small class="doc doc-label doc-label-dataclass"><code>dataclass</code></small> </span> </h5> <div class="doc doc-contents "> <p>Resolved paths to local engine assets.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/assets.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">12</span> <span class="normal">13</span> <span class="normal">14</span> <span class="normal">15</span> <span class="normal">16</span> <span class="normal">17</span> <span class="normal">18</span> <span class="normal">19</span> <span class="normal">20</span> <span class="normal">21</span> <span class="normal">22</span> <span class="normal">23</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="nd">@dataclass</span><span class="p">(</span><span class="n">frozen</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span> <span class="k">class</span><span class="w"> </span><span class="nc">DCSAssets</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Resolved paths to local engine assets."""</span> <span class="n">mode</span><span class="p">:</span> <span class="n">AssetMode</span> <span class="n">root</span><span class="p">:</span> <span class="n">Path</span> <span class="n">compose_file</span><span class="p">:</span> <span class="n">Path</span> <span class="n">docker_dir</span><span class="p">:</span> <span class="n">Path</span> <span class="n">run_configs_dir</span><span class="p">:</span> <span class="n">Path</span> <span class="n">default_run_config</span><span class="p">:</span> <span class="n">Path</span> <span class="n">database_seeds_dir</span><span class="p">:</span> <span class="n">Path</span> <span class="n">ui_dist_dir</span><span class="p">:</span> <span class="n">Path</span> </code></pre></div></td></tr></table></div> </details> <div class="doc doc-children"> </div> </div> </div> <div class="doc doc-object doc-function"> <h5 id="dcs_simulation_engine.utils.assets.find_repo_root" class="doc doc-heading"> <code class="highlight language-python"><span class="n">find_repo_root</span><span class="p">(</span><span class="n">start</span><span class="o">=</span><span class="kc">None</span><span class="p">)</span></code> </h5> <div class="doc doc-contents "> <p>Walk upward from start looking for a DCS repository checkout.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/assets.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">46</span> <span class="normal">47</span> <span class="normal">48</span> <span class="normal">49</span> <span class="normal">50</span> <span class="normal">51</span> <span class="normal">52</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">find_repo_root</span><span class="p">(</span><span class="n">start</span><span class="p">:</span> <span class="n">Path</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span><span class="p">)</span> <span class="o">-></span> <span class="n">Path</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Walk upward from start looking for a DCS repository checkout."""</span> <span class="n">candidate</span> <span class="o">=</span> <span class="p">(</span><span class="n">start</span> <span class="ow">or</span> <span class="n">Path</span><span class="o">.</span><span class="n">cwd</span><span class="p">())</span><span class="o">.</span><span class="n">resolve</span><span class="p">()</span> <span class="k">for</span> <span class="n">path</span> <span class="ow">in</span> <span class="p">[</span><span class="n">candidate</span><span class="p">,</span> <span class="o">*</span><span class="n">candidate</span><span class="o">.</span><span class="n">parents</span><span class="p">]:</span> <span class="k">if</span> <span class="n">_has_required_assets</span><span class="p">(</span><span class="n">path</span><span class="p">)</span> <span class="ow">and</span> <span class="p">(</span><span class="n">path</span> <span class="o">/</span> <span class="s2">"pyproject.toml"</span><span class="p">)</span><span class="o">.</span><span class="n">is_file</span><span class="p">()</span> <span class="ow">and</span> <span class="p">(</span><span class="n">path</span> <span class="o">/</span> <span class="s2">"dcs_simulation_engine"</span><span class="p">)</span><span class="o">.</span><span class="n">is_dir</span><span class="p">():</span> <span class="k">return</span> <span class="n">path</span> <span class="k">return</span> <span class="kc">None</span> </code></pre></div></td></tr></table></div> </details> </div> </div> <div class="doc doc-object doc-function"> <h5 id="dcs_simulation_engine.utils.assets.packaged_assets_root" class="doc doc-heading"> <code class="highlight language-python"><span class="n">packaged_assets_root</span><span class="p">()</span></code> </h5> <div class="doc doc-contents "> <p>Return the expected package asset root inside an installed package.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/assets.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">55</span> <span class="normal">56</span> <span class="normal">57</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">packaged_assets_root</span><span class="p">()</span> <span class="o">-></span> <span class="n">Path</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Return the expected package asset root inside an installed package."""</span> <span class="k">return</span> <span class="n">package_root</span><span class="p">()</span> <span class="o">/</span> <span class="s2">"package_assets"</span> </code></pre></div></td></tr></table></div> </details> </div> </div> <div class="doc doc-object doc-function"> <h5 id="dcs_simulation_engine.utils.assets.resolve_assets" class="doc doc-heading"> <code class="highlight language-python"><span class="n">resolve_assets</span><span class="p">(</span><span class="n">start</span><span class="o">=</span><span class="kc">None</span><span class="p">)</span></code> </h5> <div class="doc doc-contents "> <p>Return repo assets when available, otherwise packaged assets.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/assets.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">26</span> <span class="normal">27</span> <span class="normal">28</span> <span class="normal">29</span> <span class="normal">30</span> <span class="normal">31</span> <span class="normal">32</span> <span class="normal">33</span> <span class="normal">34</span> <span class="normal">35</span> <span class="normal">36</span> <span class="normal">37</span> <span class="normal">38</span> <span class="normal">39</span> <span class="normal">40</span> <span class="normal">41</span> <span class="normal">42</span> <span class="normal">43</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">resolve_assets</span><span class="p">(</span><span class="n">start</span><span class="p">:</span> <span class="n">Path</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span><span class="p">)</span> <span class="o">-></span> <span class="n">DCSAssets</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Return repo assets when available, otherwise packaged assets."""</span> <span class="n">repo_root</span> <span class="o">=</span> <span class="n">find_repo_root</span><span class="p">(</span><span class="n">start</span><span class="p">)</span> <span class="k">if</span> <span class="n">repo_root</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span> <span class="k">return</span> <span class="n">_assets_from_root</span><span class="p">(</span><span class="s2">"repo"</span><span class="p">,</span> <span class="n">repo_root</span><span class="p">)</span> <span class="n">package_repo_root</span> <span class="o">=</span> <span class="n">find_repo_root</span><span class="p">(</span><span class="n">package_root</span><span class="p">())</span> <span class="k">if</span> <span class="n">package_repo_root</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span> <span class="k">return</span> <span class="n">_assets_from_root</span><span class="p">(</span><span class="s2">"repo"</span><span class="p">,</span> <span class="n">package_repo_root</span><span class="p">)</span> <span class="n">package_assets_root</span> <span class="o">=</span> <span class="n">packaged_assets_root</span><span class="p">()</span> <span class="k">if</span> <span class="n">_has_required_package_assets</span><span class="p">(</span><span class="n">package_assets_root</span><span class="p">):</span> <span class="k">return</span> <span class="n">_assets_from_root</span><span class="p">(</span><span class="s2">"package"</span><span class="p">,</span> <span class="n">package_assets_root</span><span class="p">)</span> <span class="k">raise</span> <span class="ne">FileNotFoundError</span><span class="p">(</span> <span class="s2">"DCS assets not found. Expected a repository checkout with compose.yml/docker/database_seeds, "</span> <span class="sa">f</span><span class="s2">"or packaged assets at </span><span class="si">{</span><span class="n">package_assets_root</span><span class="si">}</span><span class="s2">."</span> <span class="p">)</span> </code></pre></div></td></tr></table></div> </details> </div> </div> </div> </div> </div> <div class="doc doc-object doc-module"> <h4 id="dcs_simulation_engine.utils.async_utils" class="doc doc-heading"> <code>async_utils</code> </h4> <div class="doc doc-contents "> <p>Helpers for working with possibly-awaitable values.</p> <div class="doc doc-children"> <div class="doc doc-object doc-function"> <h5 id="dcs_simulation_engine.utils.async_utils.maybe_await" class="doc doc-heading"> <code class="highlight language-python"><span class="n">maybe_await</span><span class="p">(</span><span class="n">value</span><span class="p">)</span></code> <span class="doc doc-labels"> <small class="doc doc-label doc-label-async"><code>async</code></small> </span> </h5> <div class="doc doc-contents "> <p>Await value when awaitable; otherwise return as-is.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/async_utils.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal"> 7</span> <span class="normal"> 8</span> <span class="normal"> 9</span> <span class="normal">10</span> <span class="normal">11</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">async</span> <span class="k">def</span><span class="w"> </span><span class="nf">maybe_await</span><span class="p">(</span><span class="n">value</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="n">Any</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Await value when awaitable; otherwise return as-is."""</span> <span class="k">if</span> <span class="n">inspect</span><span class="o">.</span><span class="n">isawaitable</span><span class="p">(</span><span class="n">value</span><span class="p">):</span> <span class="k">return</span> <span class="k">await</span> <span class="n">value</span> <span class="k">return</span> <span class="n">value</span> </code></pre></div></td></tr></table></div> </details> </div> </div> </div> </div> </div> <div class="doc doc-object doc-module"> <h4 id="dcs_simulation_engine.utils.auth" class="doc doc-heading"> <code>auth</code> </h4> <div class="doc doc-contents "> <p>Access key generation and verification utilities.</p> <div class="doc doc-children"> <div class="doc doc-object doc-function"> <h5 id="dcs_simulation_engine.utils.auth.generate_access_key" class="doc doc-heading"> <code class="highlight language-python"><span class="n">generate_access_key</span><span class="p">(</span><span class="o">*</span><span class="p">,</span> <span class="n">prefix</span><span class="o">=</span><span class="n">DEFAULT_KEY_PREFIX</span><span class="p">)</span></code> </h5> <div class="doc doc-contents "> <p>Generate a random access key string.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/auth.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">12</span> <span class="normal">13</span> <span class="normal">14</span> <span class="normal">15</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">generate_access_key</span><span class="p">(</span><span class="o">*</span><span class="p">,</span> <span class="n">prefix</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="n">DEFAULT_KEY_PREFIX</span><span class="p">)</span> <span class="o">-></span> <span class="nb">str</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Generate a random access key string."""</span> <span class="n">token</span> <span class="o">=</span> <span class="n">secrets</span><span class="o">.</span><span class="n">token_urlsafe</span><span class="p">(</span><span class="mi">32</span><span class="p">)</span> <span class="k">return</span> <span class="n">prefix</span> <span class="o">+</span> <span class="n">token</span> </code></pre></div></td></tr></table></div> </details> </div> </div> <div class="doc doc-object doc-function"> <h5 id="dcs_simulation_engine.utils.auth.validate_access_key" class="doc doc-heading"> <code class="highlight language-python"><span class="n">validate_access_key</span><span class="p">(</span><span class="n">raw_key</span><span class="p">)</span></code> </h5> <div class="doc doc-contents "> <p>Validate and normalize a raw access key.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/auth.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">18</span> <span class="normal">19</span> <span class="normal">20</span> <span class="normal">21</span> <span class="normal">22</span> <span class="normal">23</span> <span class="normal">24</span> <span class="normal">25</span> <span class="normal">26</span> <span class="normal">27</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">validate_access_key</span><span class="p">(</span><span class="n">raw_key</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-></span> <span class="nb">str</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Validate and normalize a raw access key."""</span> <span class="n">key</span> <span class="o">=</span> <span class="n">raw_key</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span> <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">key</span><span class="p">)</span> <span class="o">!=</span> <span class="n">ACCESS_KEY_TOTAL_LENGTH</span><span class="p">:</span> <span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="sa">f</span><span class="s2">"Admin key must be exactly </span><span class="si">{</span><span class="n">ACCESS_KEY_TOTAL_LENGTH</span><span class="si">}</span><span class="s2"> characters long."</span><span class="p">)</span> <span class="k">if</span> <span class="ow">not</span> <span class="n">key</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="n">DEFAULT_KEY_PREFIX</span><span class="p">):</span> <span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="sa">f</span><span class="s2">"Admin key must start with '</span><span class="si">{</span><span class="n">DEFAULT_KEY_PREFIX</span><span class="si">}</span><span class="s2">'."</span><span class="p">)</span> <span class="k">if</span> <span class="ow">not</span> <span class="n">ACCESS_KEY_PATTERN</span><span class="o">.</span><span class="n">fullmatch</span><span class="p">(</span><span class="n">key</span><span class="p">):</span> <span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="s2">"Admin key must use only URL-safe alphanumeric characters after the prefix (A-Z, a-z, 0-9, '_' or '-')."</span><span class="p">)</span> <span class="k">return</span> <span class="n">key</span> </code></pre></div></td></tr></table></div> </details> </div> </div> </div> </div> </div> <div class="doc doc-object doc-module"> <h4 id="dcs_simulation_engine.utils.divergence" class="doc doc-heading"> <code>divergence</code> </h4> <div class="doc doc-contents "> <p>Utilities for comparing character divergence profiles.</p> <div class="doc doc-children"> <div class="doc doc-object doc-function"> <h5 id="dcs_simulation_engine.utils.divergence.compute_divergence_score" class="doc doc-heading"> <code class="highlight language-python"><span class="n">compute_divergence_score</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">)</span></code> </h5> <div class="doc doc-contents "> <p>Compute a deterministic divergence score from two character HSN profiles.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/divergence.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">24</span> <span class="normal">25</span> <span class="normal">26</span> <span class="normal">27</span> <span class="normal">28</span> <span class="normal">29</span> <span class="normal">30</span> <span class="normal">31</span> <span class="normal">32</span> <span class="normal">33</span> <span class="normal">34</span> <span class="normal">35</span> <span class="normal">36</span> <span class="normal">37</span> <span class="normal">38</span> <span class="normal">39</span> <span class="normal">40</span> <span class="normal">41</span> <span class="normal">42</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">compute_divergence_score</span><span class="p">(</span><span class="n">a</span><span class="p">:</span> <span class="n">CharacterRecord</span> <span class="o">|</span> <span class="kc">None</span><span class="p">,</span> <span class="n">b</span><span class="p">:</span> <span class="n">CharacterRecord</span> <span class="o">|</span> <span class="kc">None</span><span class="p">)</span> <span class="o">-></span> <span class="nb">float</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Compute a deterministic divergence score from two character HSN profiles."""</span> <span class="k">if</span> <span class="n">a</span> <span class="ow">is</span> <span class="kc">None</span> <span class="ow">or</span> <span class="n">b</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span> <span class="k">return</span> <span class="mf">0.0</span> <span class="n">a_values</span> <span class="o">=</span> <span class="n">_flatten_hsn_values</span><span class="p">(</span><span class="n">a</span><span class="o">.</span><span class="n">data</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">"hsn_divergence"</span><span class="p">))</span> <span class="n">b_values</span> <span class="o">=</span> <span class="n">_flatten_hsn_values</span><span class="p">(</span><span class="n">b</span><span class="o">.</span><span class="n">data</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">"hsn_divergence"</span><span class="p">))</span> <span class="n">shared_keys</span> <span class="o">=</span> <span class="nb">set</span><span class="p">(</span><span class="n">a_values</span><span class="p">)</span> <span class="o">&</span> <span class="nb">set</span><span class="p">(</span><span class="n">b_values</span><span class="p">)</span> <span class="n">score</span> <span class="o">=</span> <span class="mf">0.0</span> <span class="k">for</span> <span class="n">key</span> <span class="ow">in</span> <span class="n">shared_keys</span><span class="p">:</span> <span class="n">left</span> <span class="o">=</span> <span class="n">a_values</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="n">right</span> <span class="o">=</span> <span class="n">b_values</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="k">if</span> <span class="n">left</span> <span class="o">==</span> <span class="n">right</span><span class="p">:</span> <span class="k">continue</span> <span class="k">if</span> <span class="p">{</span><span class="n">left</span><span class="p">,</span> <span class="n">right</span><span class="p">}</span> <span class="o">==</span> <span class="p">{</span><span class="s2">"divergent"</span><span class="p">,</span> <span class="s2">"normative"</span><span class="p">}:</span> <span class="n">score</span> <span class="o">+=</span> <span class="mf">2.0</span> <span class="k">else</span><span class="p">:</span> <span class="n">score</span> <span class="o">+=</span> <span class="mf">1.0</span> <span class="k">return</span> <span class="n">score</span> </code></pre></div></td></tr></table></div> </details> </div> </div> </div> </div> </div> <div class="doc doc-object doc-module"> <h4 id="dcs_simulation_engine.utils.fingerprint" class="doc doc-heading"> <code>fingerprint</code> </h4> <div class="doc doc-contents "> <p>Fingerprinting utilities for character evaluation QC.</p> <div class="doc doc-children"> <div class="doc doc-object doc-function"> <h5 id="dcs_simulation_engine.utils.fingerprint.compute_character_evaluation_fingerprint" class="doc doc-heading"> <code class="highlight language-python"><span class="n">compute_character_evaluation_fingerprint</span><span class="p">(</span><span class="n">character</span><span class="p">,</span> <span class="n">model</span><span class="o">=</span><span class="n">DEFAULT_MODEL</span><span class="p">,</span> <span class="n">simulator_prompt_bundle</span><span class="o">=</span><span class="kc">None</span><span class="p">)</span></code> </h5> <div class="doc doc-contents "> <p>Return a SHA-256 hex fingerprint of a character + model + simulator prompt bundle.</p> <p>Defaults to the current simulator model and default scene/character updater prompt bundle, so callers typically only need to pass the character:</p> <div class="highlight"><pre><span></span><code>fingerprint = compute_character_evaluation_fingerprint(character) </code></pre></div> <p>The fingerprint is deterministic: same inputs always produce the same value. Any change to the character sheet, model name, or selected prompt bundle changes it.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/fingerprint.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">24</span> <span class="normal">25</span> <span class="normal">26</span> <span class="normal">27</span> <span class="normal">28</span> <span class="normal">29</span> <span class="normal">30</span> <span class="normal">31</span> <span class="normal">32</span> <span class="normal">33</span> <span class="normal">34</span> <span class="normal">35</span> <span class="normal">36</span> <span class="normal">37</span> <span class="normal">38</span> <span class="normal">39</span> <span class="normal">40</span> <span class="normal">41</span> <span class="normal">42</span> <span class="normal">43</span> <span class="normal">44</span> <span class="normal">45</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">compute_character_evaluation_fingerprint</span><span class="p">(</span> <span class="n">character</span><span class="p">:</span> <span class="n">CharacterRecord</span><span class="p">,</span> <span class="n">model</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="n">DEFAULT_MODEL</span><span class="p">,</span> <span class="n">simulator_prompt_bundle</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span><span class="p">,</span> <span class="p">)</span> <span class="o">-></span> <span class="nb">str</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Return a SHA-256 hex fingerprint of a character + model + simulator prompt bundle.</span> <span class="sd"> Defaults to the current simulator model and default scene/character updater prompt</span> <span class="sd"> bundle, so callers typically only need to pass the character:</span> <span class="sd"> fingerprint = compute_character_evaluation_fingerprint(character)</span> <span class="sd"> The fingerprint is deterministic: same inputs always produce the same value.</span> <span class="sd"> Any change to the character sheet, model name, or selected prompt bundle changes it.</span> <span class="sd"> """</span> <span class="n">payload</span> <span class="o">=</span> <span class="p">{</span> <span class="s2">"character"</span><span class="p">:</span> <span class="n">character</span><span class="o">.</span><span class="n">_asdict</span><span class="p">(),</span> <span class="s2">"model"</span><span class="p">:</span> <span class="n">model</span><span class="p">,</span> <span class="s2">"simulator_prompt_bundle"</span><span class="p">:</span> <span class="n">simulator_prompt_bundle</span> <span class="ow">or</span> <span class="n">DEFAULT_SIMULATOR_PROMPT_BUNDLE</span><span class="p">,</span> <span class="p">}</span> <span class="n">canonical</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">(</span><span class="n">payload</span><span class="p">,</span> <span class="n">sort_keys</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">ensure_ascii</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span> <span class="k">return</span> <span class="n">hashlib</span><span class="o">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">canonical</span><span class="o">.</span><span class="n">encode</span><span class="p">())</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">()</span> </code></pre></div></td></tr></table></div> </details> </div> </div> </div> </div> </div> <div class="doc doc-object doc-module"> <h4 id="dcs_simulation_engine.utils.paths" class="doc doc-heading"> <code>paths</code> </h4> <div class="doc doc-contents "> <p>Path utilities.</p> <div class="doc doc-children"> <div class="doc doc-object doc-function"> <h5 id="dcs_simulation_engine.utils.paths.package_games_dir" class="doc doc-heading"> <code class="highlight language-python"><span class="n">package_games_dir</span><span class="p">()</span></code> </h5> <div class="doc doc-contents "> <p>Returns the path to the package's games directory.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/paths.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">12</span> <span class="normal">13</span> <span class="normal">14</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">package_games_dir</span><span class="p">()</span> <span class="o">-></span> <span class="n">Path</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Returns the path to the package's games directory."""</span> <span class="k">return</span> <span class="n">package_root</span><span class="p">()</span><span class="o">.</span><span class="n">parent</span> <span class="o">/</span> <span class="s2">"games"</span> </code></pre></div></td></tr></table></div> </details> </div> </div> <div class="doc doc-object doc-function"> <h5 id="dcs_simulation_engine.utils.paths.package_root" class="doc doc-heading"> <code class="highlight language-python"><span class="n">package_root</span><span class="p">()</span></code> </h5> <div class="doc doc-contents "> <p>Returns the root path of the dcs_simulation_engine package.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/paths.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">7</span> <span class="normal">8</span> <span class="normal">9</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">package_root</span><span class="p">()</span> <span class="o">-></span> <span class="n">Path</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Returns the root path of the dcs_simulation_engine package."""</span> <span class="k">return</span> <span class="n">Path</span><span class="p">(</span><span class="nb">str</span><span class="p">(</span><span class="n">resources</span><span class="o">.</span><span class="n">files</span><span class="p">(</span><span class="s2">"dcs_simulation_engine"</span><span class="p">)))</span> </code></pre></div></td></tr></table></div> </details> </div> </div> </div> </div> </div> <div class="doc doc-object doc-module"> <h4 id="dcs_simulation_engine.utils.release_policy" class="doc doc-heading"> <code>release_policy</code> </h4> <div class="doc doc-contents "> <p>Release policy utilities for the DCS character production pipeline.</p> <p>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.</p> <div class="doc doc-children"> <div class="doc doc-object doc-function"> <h5 id="dcs_simulation_engine.utils.release_policy.compute_approved_characters" class="doc doc-heading"> <code class="highlight language-python"><span class="n">compute_approved_characters</span><span class="p">(</span><span class="n">policy</span><span class="p">,</span> <span class="n">evaluations</span><span class="p">,</span> <span class="n">prod_chars_by_hid</span><span class="p">)</span></code> </h5> <div class="doc doc-contents "> <p>Return sorted list of character HIDs approved under <em>policy</em>.</p> <h6 id="dcs_simulation_engine.utils.release_policy.compute_approved_characters--parameters">Parameters</h6> <p>policy: Parsed policy dict (from :func:<code>load_policy</code>). evaluations: List of evaluation dicts from <code>character_evaluations.json</code>. prod_chars_by_hid: Mapping of <code>hid → character doc</code> for all characters in prod.</p> <p>A character is approved if it has at least one evaluation that passes ALL policy criteria: - <code>scores.icf >= criteria.min_icf_score</code> - (if <code>require_current_fingerprint</code>) evaluation fingerprint matches the fingerprint computed from the current character doc, model, and updater prompt template.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/release_policy.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">30</span> <span class="normal">31</span> <span class="normal">32</span> <span class="normal">33</span> <span class="normal">34</span> <span class="normal">35</span> <span class="normal">36</span> <span class="normal">37</span> <span class="normal">38</span> <span class="normal">39</span> <span class="normal">40</span> <span class="normal">41</span> <span class="normal">42</span> <span class="normal">43</span> <span class="normal">44</span> <span class="normal">45</span> <span class="normal">46</span> <span class="normal">47</span> <span class="normal">48</span> <span class="normal">49</span> <span class="normal">50</span> <span class="normal">51</span> <span class="normal">52</span> <span class="normal">53</span> <span class="normal">54</span> <span class="normal">55</span> <span class="normal">56</span> <span class="normal">57</span> <span class="normal">58</span> <span class="normal">59</span> <span class="normal">60</span> <span class="normal">61</span> <span class="normal">62</span> <span class="normal">63</span> <span class="normal">64</span> <span class="normal">65</span> <span class="normal">66</span> <span class="normal">67</span> <span class="normal">68</span> <span class="normal">69</span> <span class="normal">70</span> <span class="normal">71</span> <span class="normal">72</span> <span class="normal">73</span> <span class="normal">74</span> <span class="normal">75</span> <span class="normal">76</span> <span class="normal">77</span> <span class="normal">78</span> <span class="normal">79</span> <span class="normal">80</span> <span class="normal">81</span> <span class="normal">82</span> <span class="normal">83</span> <span class="normal">84</span> <span class="normal">85</span> <span class="normal">86</span> <span class="normal">87</span> <span class="normal">88</span> <span class="normal">89</span> <span class="normal">90</span> <span class="normal">91</span> <span class="normal">92</span> <span class="normal">93</span> <span class="normal">94</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">compute_approved_characters</span><span class="p">(</span> <span class="n">policy</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">],</span> <span class="n">evaluations</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]],</span> <span class="n">prod_chars_by_hid</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]],</span> <span class="p">)</span> <span class="o">-></span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]:</span> <span class="w"> </span><span class="sd">"""Return sorted list of character HIDs approved under *policy*.</span> <span class="sd"> Parameters</span> <span class="sd"> ----------</span> <span class="sd"> policy:</span> <span class="sd"> Parsed policy dict (from :func:`load_policy`).</span> <span class="sd"> evaluations:</span> <span class="sd"> List of evaluation dicts from ``character_evaluations.json``.</span> <span class="sd"> prod_chars_by_hid:</span> <span class="sd"> Mapping of ``hid → character doc`` for all characters in prod.</span> <span class="sd"> A character is approved if it has at least one evaluation that passes</span> <span class="sd"> ALL policy criteria:</span> <span class="sd"> - ``scores.icf >= criteria.min_icf_score``</span> <span class="sd"> - (if ``require_current_fingerprint``) evaluation fingerprint matches</span> <span class="sd"> the fingerprint computed from the current character doc, model, and</span> <span class="sd"> updater prompt template.</span> <span class="sd"> """</span> <span class="kn">from</span><span class="w"> </span><span class="nn">dcs_simulation_engine.reporting.auto.publish</span><span class="w"> </span><span class="kn">import</span> <span class="n">build_char_record_from_doc</span> <span class="n">criteria</span> <span class="o">=</span> <span class="n">policy</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">"criteria"</span><span class="p">,</span> <span class="p">{})</span> <span class="n">min_icf</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="n">criteria</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">"min_icf_score"</span><span class="p">,</span> <span class="mf">0.0</span><span class="p">)</span> <span class="n">min_scenario_coverage</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="n">criteria</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">"min_scenario_coverage_score"</span><span class="p">,</span> <span class="mf">0.0</span><span class="p">)</span> <span class="n">require_fp</span><span class="p">:</span> <span class="nb">bool</span> <span class="o">=</span> <span class="n">criteria</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">"require_current_fingerprint"</span><span class="p">,</span> <span class="kc">False</span><span class="p">)</span> <span class="c1"># Pre-index evaluations by character_hid for fast lookup</span> <span class="n">evals_by_hid</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">]]</span> <span class="o">=</span> <span class="p">{}</span> <span class="k">for</span> <span class="n">ev</span> <span class="ow">in</span> <span class="n">evaluations</span><span class="p">:</span> <span class="n">hid</span> <span class="o">=</span> <span class="n">ev</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">"character_hid"</span><span class="p">,</span> <span class="s2">""</span><span class="p">)</span> <span class="n">evals_by_hid</span><span class="o">.</span><span class="n">setdefault</span><span class="p">(</span><span class="n">hid</span><span class="p">,</span> <span class="p">[])</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">ev</span><span class="p">)</span> <span class="n">approved</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span> <span class="k">for</span> <span class="n">hid</span><span class="p">,</span> <span class="n">char_doc</span> <span class="ow">in</span> <span class="n">prod_chars_by_hid</span><span class="o">.</span><span class="n">items</span><span class="p">():</span> <span class="n">char_evals</span> <span class="o">=</span> <span class="n">evals_by_hid</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">hid</span><span class="p">,</span> <span class="p">[])</span> <span class="k">if</span> <span class="ow">not</span> <span class="n">char_evals</span><span class="p">:</span> <span class="k">continue</span> <span class="n">current_fp</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span> <span class="k">if</span> <span class="n">require_fp</span><span class="p">:</span> <span class="k">try</span><span class="p">:</span> <span class="n">record</span> <span class="o">=</span> <span class="n">build_char_record_from_doc</span><span class="p">(</span><span class="n">char_doc</span><span class="p">)</span> <span class="n">current_fp</span> <span class="o">=</span> <span class="n">compute_character_evaluation_fingerprint</span><span class="p">(</span><span class="n">record</span><span class="p">)</span> <span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span> <span class="k">continue</span> <span class="c1"># can't fingerprint → skip</span> <span class="k">for</span> <span class="n">ev</span> <span class="ow">in</span> <span class="n">char_evals</span><span class="p">:</span> <span class="n">scores</span> <span class="o">=</span> <span class="n">ev</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">"scores"</span><span class="p">,</span> <span class="p">{})</span> <span class="n">icf</span> <span class="o">=</span> <span class="n">scores</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">"icf"</span><span class="p">,</span> <span class="mf">0.0</span><span class="p">)</span> <span class="k">if</span> <span class="n">icf</span> <span class="o"><</span> <span class="n">min_icf</span><span class="p">:</span> <span class="k">continue</span> <span class="k">if</span> <span class="n">min_scenario_coverage</span> <span class="o">></span> <span class="mi">0</span> <span class="ow">and</span> <span class="n">scores</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">"scenario_coverage"</span><span class="p">,</span> <span class="mf">0.0</span><span class="p">)</span> <span class="o"><</span> <span class="n">min_scenario_coverage</span><span class="p">:</span> <span class="k">continue</span> <span class="k">if</span> <span class="n">require_fp</span> <span class="ow">and</span> <span class="n">current_fp</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span> <span class="k">if</span> <span class="n">ev</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">"fingerprint"</span><span class="p">,</span> <span class="s2">""</span><span class="p">)</span> <span class="o">!=</span> <span class="n">current_fp</span><span class="p">:</span> <span class="k">continue</span> <span class="c1"># Passed all criteria</span> <span class="n">approved</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">hid</span><span class="p">)</span> <span class="k">break</span> <span class="k">return</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">approved</span><span class="p">)</span> </code></pre></div></td></tr></table></div> </details> </div> </div> <div class="doc doc-object doc-function"> <h5 id="dcs_simulation_engine.utils.release_policy.load_policy" class="doc doc-heading"> <code class="highlight language-python"><span class="n">load_policy</span><span class="p">(</span><span class="n">path</span><span class="p">)</span></code> </h5> <div class="doc doc-contents "> <p>Load and return the character release policy from a YAML file.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/release_policy.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">20</span> <span class="normal">21</span> <span class="normal">22</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">load_policy</span><span class="p">(</span><span class="n">path</span><span class="p">:</span> <span class="n">Path</span><span class="p">)</span> <span class="o">-></span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]:</span> <span class="w"> </span><span class="sd">"""Load and return the character release policy from a YAML file."""</span> <span class="k">return</span> <span class="n">yaml</span><span class="o">.</span><span class="n">safe_load</span><span class="p">(</span><span class="n">path</span><span class="o">.</span><span class="n">read_text</span><span class="p">(</span><span class="n">encoding</span><span class="o">=</span><span class="s2">"utf-8"</span><span class="p">))</span> </code></pre></div></td></tr></table></div> </details> </div> </div> <div class="doc doc-object doc-function"> <h5 id="dcs_simulation_engine.utils.release_policy.write_manifest" class="doc doc-heading"> <code class="highlight language-python"><span class="n">write_manifest</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">approved_hids</span><span class="p">,</span> <span class="n">policy_version</span><span class="p">)</span></code> </h5> <div class="doc doc-contents "> <p>Write the release manifest JSON to <em>path</em>.</p> <p>Creates parent directories if they don't exist.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/release_policy.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">104</span> <span class="normal">105</span> <span class="normal">106</span> <span class="normal">107</span> <span class="normal">108</span> <span class="normal">109</span> <span class="normal">110</span> <span class="normal">111</span> <span class="normal">112</span> <span class="normal">113</span> <span class="normal">114</span> <span class="normal">115</span> <span class="normal">116</span> <span class="normal">117</span> <span class="normal">118</span> <span class="normal">119</span> <span class="normal">120</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">write_manifest</span><span class="p">(</span> <span class="n">path</span><span class="p">:</span> <span class="n">Path</span><span class="p">,</span> <span class="n">approved_hids</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">],</span> <span class="n">policy_version</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="p">)</span> <span class="o">-></span> <span class="kc">None</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Write the release manifest JSON to *path*.</span> <span class="sd"> Creates parent directories if they don't exist.</span> <span class="sd"> """</span> <span class="n">manifest</span> <span class="o">=</span> <span class="p">{</span> <span class="s2">"policy_version"</span><span class="p">:</span> <span class="n">policy_version</span><span class="p">,</span> <span class="s2">"engine_version"</span><span class="p">:</span> <span class="n">_ENGINE_VERSION</span><span class="p">,</span> <span class="s2">"generated_at"</span><span class="p">:</span> <span class="n">datetime</span><span class="o">.</span><span class="n">now</span><span class="p">(</span><span class="n">tz</span><span class="o">=</span><span class="n">timezone</span><span class="o">.</span><span class="n">utc</span><span class="p">)</span><span class="o">.</span><span class="n">strftime</span><span class="p">(</span><span class="s2">"%Y-%m-</span><span class="si">%d</span><span class="s2">T%H:%M:%SZ"</span><span class="p">),</span> <span class="s2">"approved_characters"</span><span class="p">:</span> <span class="n">approved_hids</span><span class="p">,</span> <span class="p">}</span> <span class="n">path</span><span class="o">.</span><span class="n">parent</span><span class="o">.</span><span class="n">mkdir</span><span class="p">(</span><span class="n">parents</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">exist_ok</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span> <span class="n">path</span><span class="o">.</span><span class="n">write_text</span><span class="p">(</span><span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">(</span><span class="n">manifest</span><span class="p">,</span> <span class="n">indent</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span> <span class="n">ensure_ascii</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span> <span class="o">+</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">"</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">"utf-8"</span><span class="p">)</span> </code></pre></div></td></tr></table></div> </details> </div> </div> </div> </div> </div> <div class="doc doc-object doc-module"> <h4 id="dcs_simulation_engine.utils.serde" class="doc doc-heading"> <code>serde</code> </h4> <div class="doc doc-contents "> <p>Serialization / deserialization (serde) mixin for Pydantic models.</p> <p>Example Usage: x = X(uid="sys1", short_description="Test")</p> <p>d = x.to_dict() js = x.to_json(indent=2) ys = x.to_yaml()</p> <p>x1 = X.from_json(js) x2 = X.from_yaml(ys)</p> <p>x.save_json("system.json", indent=2) x.save_yaml("system.yaml")</p> <p>x4 = X.load_json("system.json") x5 = X.load_yaml("system.yaml")</p> <div class="doc doc-children"> <div class="doc doc-object doc-class"> <h5 id="dcs_simulation_engine.utils.serde.SerdeMixin" class="doc doc-heading"> <code>SerdeMixin</code> </h5> <div class="doc doc-contents "> <p class="doc doc-class-bases"> Bases: <code><span title="pydantic.BaseModel">BaseModel</span></code></p> <p>Mixin adding serialization / deserialization methods to Pydantic models.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/serde.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal"> 38</span> <span class="normal"> 39</span> <span class="normal"> 40</span> <span class="normal"> 41</span> <span class="normal"> 42</span> <span class="normal"> 43</span> <span class="normal"> 44</span> <span class="normal"> 45</span> <span class="normal"> 46</span> <span class="normal"> 47</span> <span class="normal"> 48</span> <span class="normal"> 49</span> <span class="normal"> 50</span> <span class="normal"> 51</span> <span class="normal"> 52</span> <span class="normal"> 53</span> <span class="normal"> 54</span> <span class="normal"> 55</span> <span class="normal"> 56</span> <span class="normal"> 57</span> <span class="normal"> 58</span> <span class="normal"> 59</span> <span class="normal"> 60</span> <span class="normal"> 61</span> <span class="normal"> 62</span> <span class="normal"> 63</span> <span class="normal"> 64</span> <span class="normal"> 65</span> <span class="normal"> 66</span> <span class="normal"> 67</span> <span class="normal"> 68</span> <span class="normal"> 69</span> <span class="normal"> 70</span> <span class="normal"> 71</span> <span class="normal"> 72</span> <span class="normal"> 73</span> <span class="normal"> 74</span> <span class="normal"> 75</span> <span class="normal"> 76</span> <span class="normal"> 77</span> <span class="normal"> 78</span> <span class="normal"> 79</span> <span class="normal"> 80</span> <span class="normal"> 81</span> <span class="normal"> 82</span> <span class="normal"> 83</span> <span class="normal"> 84</span> <span class="normal"> 85</span> <span class="normal"> 86</span> <span class="normal"> 87</span> <span class="normal"> 88</span> <span class="normal"> 89</span> <span class="normal"> 90</span> <span class="normal"> 91</span> <span class="normal"> 92</span> <span class="normal"> 93</span> <span class="normal"> 94</span> <span class="normal"> 95</span> <span class="normal"> 96</span> <span class="normal"> 97</span> <span class="normal"> 98</span> <span class="normal"> 99</span> <span class="normal">100</span> <span class="normal">101</span> <span class="normal">102</span> <span class="normal">103</span> <span class="normal">104</span> <span class="normal">105</span> <span class="normal">106</span> <span class="normal">107</span> <span class="normal">108</span> <span class="normal">109</span> <span class="normal">110</span> <span class="normal">111</span> <span class="normal">112</span> <span class="normal">113</span> <span class="normal">114</span> <span class="normal">115</span> <span class="normal">116</span> <span class="normal">117</span> <span class="normal">118</span> <span class="normal">119</span> <span class="normal">120</span> <span class="normal">121</span> <span class="normal">122</span> <span class="normal">123</span> <span class="normal">124</span> <span class="normal">125</span> <span class="normal">126</span> <span class="normal">127</span> <span class="normal">128</span> <span class="normal">129</span> <span class="normal">130</span> <span class="normal">131</span> <span class="normal">132</span> <span class="normal">133</span> <span class="normal">134</span> <span class="normal">135</span> <span class="normal">136</span> <span class="normal">137</span> <span class="normal">138</span> <span class="normal">139</span> <span class="normal">140</span> <span class="normal">141</span> <span class="normal">142</span> <span class="normal">143</span> <span class="normal">144</span> <span class="normal">145</span> <span class="normal">146</span> <span class="normal">147</span> <span class="normal">148</span> <span class="normal">149</span> <span class="normal">150</span> <span class="normal">151</span> <span class="normal">152</span> <span class="normal">153</span> <span class="normal">154</span> <span class="normal">155</span> <span class="normal">156</span> <span class="normal">157</span> <span class="normal">158</span> <span class="normal">159</span> <span class="normal">160</span> <span class="normal">161</span> <span class="normal">162</span> <span class="normal">163</span> <span class="normal">164</span> <span class="normal">165</span> <span class="normal">166</span> <span class="normal">167</span> <span class="normal">168</span> <span class="normal">169</span> <span class="normal">170</span> <span class="normal">171</span> <span class="normal">172</span> <span class="normal">173</span> <span class="normal">174</span> <span class="normal">175</span> <span class="normal">176</span> <span class="normal">177</span> <span class="normal">178</span> <span class="normal">179</span> <span class="normal">180</span> <span class="normal">181</span> <span class="normal">182</span> <span class="normal">183</span> <span class="normal">184</span> <span class="normal">185</span> <span class="normal">186</span> <span class="normal">187</span> <span class="normal">188</span> <span class="normal">189</span> <span class="normal">190</span> <span class="normal">191</span> <span class="normal">192</span> <span class="normal">193</span> <span class="normal">194</span> <span class="normal">195</span> <span class="normal">196</span> <span class="normal">197</span> <span class="normal">198</span> <span class="normal">199</span> <span class="normal">200</span> <span class="normal">201</span> <span class="normal">202</span> <span class="normal">203</span> <span class="normal">204</span> <span class="normal">205</span> <span class="normal">206</span> <span class="normal">207</span> <span class="normal">208</span> <span class="normal">209</span> <span class="normal">210</span> <span class="normal">211</span> <span class="normal">212</span> <span class="normal">213</span> <span class="normal">214</span> <span class="normal">215</span> <span class="normal">216</span> <span class="normal">217</span> <span class="normal">218</span> <span class="normal">219</span> <span class="normal">220</span> <span class="normal">221</span> <span class="normal">222</span> <span class="normal">223</span> <span class="normal">224</span> <span class="normal">225</span> <span class="normal">226</span> <span class="normal">227</span> <span class="normal">228</span> <span class="normal">229</span> <span class="normal">230</span> <span class="normal">231</span> <span class="normal">232</span> <span class="normal">233</span> <span class="normal">234</span> <span class="normal">235</span> <span class="normal">236</span> <span class="normal">237</span> <span class="normal">238</span> <span class="normal">239</span> <span class="normal">240</span> <span class="normal">241</span> <span class="normal">242</span> <span class="normal">243</span> <span class="normal">244</span> <span class="normal">245</span> <span class="normal">246</span> <span class="normal">247</span> <span class="normal">248</span> <span class="normal">249</span> <span class="normal">250</span> <span class="normal">251</span> <span class="normal">252</span> <span class="normal">253</span> <span class="normal">254</span> <span class="normal">255</span> <span class="normal">256</span> <span class="normal">257</span> <span class="normal">258</span> <span class="normal">259</span> <span class="normal">260</span> <span class="normal">261</span> <span class="normal">262</span> <span class="normal">263</span> <span class="normal">264</span> <span class="normal">265</span> <span class="normal">266</span> <span class="normal">267</span> <span class="normal">268</span> <span class="normal">269</span> <span class="normal">270</span> <span class="normal">271</span> <span class="normal">272</span> <span class="normal">273</span> <span class="normal">274</span> <span class="normal">275</span> <span class="normal">276</span> <span class="normal">277</span> <span class="normal">278</span> <span class="normal">279</span> <span class="normal">280</span> <span class="normal">281</span> <span class="normal">282</span> <span class="normal">283</span> <span class="normal">284</span> <span class="normal">285</span> <span class="normal">286</span> <span class="normal">287</span> <span class="normal">288</span> <span class="normal">289</span> <span class="normal">290</span> <span class="normal">291</span> <span class="normal">292</span> <span class="normal">293</span> <span class="normal">294</span> <span class="normal">295</span> <span class="normal">296</span> <span class="normal">297</span> <span class="normal">298</span> <span class="normal">299</span> <span class="normal">300</span> <span class="normal">301</span> <span class="normal">302</span> <span class="normal">303</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">SerdeMixin</span><span class="p">(</span><span class="n">BaseModel</span><span class="p">):</span> <span class="w"> </span><span class="sd">"""Mixin adding serialization / deserialization methods to Pydantic models."""</span> <span class="c1"># ---------- nice exports ----------</span> <span class="k">def</span><span class="w"> </span><span class="nf">to_dict</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">**</span><span class="n">dump_kwargs</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]:</span> <span class="w"> </span><span class="sd">"""Convert model to dict. Pass model_dump kwargs if desired."""</span> <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">model_dump</span><span class="p">(</span><span class="o">**</span><span class="n">dump_kwargs</span><span class="p">)</span> <span class="k">def</span><span class="w"> </span><span class="nf">to_json</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">**</span><span class="n">dump_kwargs</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="nb">str</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Convert model to JSON string."""</span> <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">model_dump_json</span><span class="p">(</span><span class="o">**</span><span class="n">dump_kwargs</span><span class="p">)</span> <span class="k">def</span><span class="w"> </span><span class="nf">to_yaml</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">**</span><span class="n">dump_kwargs</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="nb">str</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Convert model to readable YAML.</span> <span class="sd"> Pass yaml.safe_dump kwargs if desired (e.g., sort_keys=False).</span> <span class="sd"> """</span> <span class="c1"># TODO: pre-v001 make export nice yaml not all the /newlines</span> <span class="n">data</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">model_dump</span><span class="p">()</span> <span class="c1"># readable, block-style YAML; avoid single-line flow; keep key order</span> <span class="k">return</span> <span class="nb">str</span><span class="p">(</span> <span class="n">yaml</span><span class="o">.</span><span class="n">safe_dump</span><span class="p">(</span> <span class="n">data</span><span class="p">,</span> <span class="n">allow_unicode</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">sort_keys</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">default_flow_style</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">width</span><span class="o">=</span><span class="mi">88</span><span class="p">,</span> <span class="o">**</span><span class="n">dump_kwargs</span><span class="p">,</span> <span class="p">)</span> <span class="p">)</span> <span class="c1"># ---------- user-friendly loaders ----------</span> <span class="nd">@classmethod</span> <span class="k">def</span><span class="w"> </span><span class="nf">from_json</span><span class="p">(</span><span class="bp">cls</span><span class="p">:</span> <span class="nb">type</span><span class="p">[</span><span class="n">T</span><span class="p">],</span> <span class="n">source</span><span class="p">:</span> <span class="n">Union</span><span class="p">[</span><span class="n">Mapping</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">],</span> <span class="nb">str</span><span class="p">,</span> <span class="n">Path</span><span class="p">],</span> <span class="o">**</span><span class="n">kw</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="n">T</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Instantiate model from a JSON string or a file path with friendly errors."""</span> <span class="c1"># logger.debug(f"Serde called with source type: {type(source)}")</span> <span class="c1"># logger.debug(f"Source content: {str(source)}")</span> <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">source</span><span class="p">,</span> <span class="n">Mapping</span><span class="p">):</span> <span class="n">logger</span><span class="o">.</span><span class="n">debug</span><span class="p">(</span><span class="s2">"Source is a Mapping, using model_validate"</span><span class="p">)</span> <span class="k">return</span> <span class="bp">cls</span><span class="o">.</span><span class="n">model_validate</span><span class="p">(</span><span class="n">source</span><span class="p">,</span> <span class="o">**</span><span class="n">kw</span><span class="p">)</span> <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">source</span><span class="p">,</span> <span class="n">Path</span><span class="p">):</span> <span class="n">logger</span><span class="o">.</span><span class="n">debug</span><span class="p">(</span><span class="s2">"Source is a Path, reading text"</span><span class="p">)</span> <span class="n">text</span> <span class="o">=</span> <span class="n">source</span><span class="o">.</span><span class="n">read_text</span><span class="p">(</span><span class="n">encoding</span><span class="o">=</span><span class="s2">"utf-8"</span><span class="p">)</span> <span class="k">return</span> <span class="bp">cls</span><span class="o">.</span><span class="n">model_validate_json</span><span class="p">(</span><span class="n">text</span><span class="p">,</span> <span class="o">**</span><span class="n">kw</span><span class="p">)</span> <span class="c1"># string: prefer JSON first; only then try file</span> <span class="n">logger</span><span class="o">.</span><span class="n">debug</span><span class="p">(</span><span class="s2">"Source is a string, checking content"</span><span class="p">)</span> <span class="n">s</span> <span class="o">=</span> <span class="n">source</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span> <span class="k">if</span> <span class="n">s</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">"{"</span><span class="p">)</span> <span class="ow">or</span> <span class="n">s</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">"["</span><span class="p">):</span> <span class="k">return</span> <span class="bp">cls</span><span class="o">.</span><span class="n">model_validate_json</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="o">**</span><span class="n">kw</span><span class="p">)</span> <span class="k">try</span><span class="p">:</span> <span class="k">return</span> <span class="bp">cls</span><span class="o">.</span><span class="n">model_validate_json</span><span class="p">(</span><span class="n">Path</span><span class="p">(</span><span class="n">source</span><span class="p">)</span><span class="o">.</span><span class="n">read_text</span><span class="p">(</span><span class="n">encoding</span><span class="o">=</span><span class="s2">"utf-8"</span><span class="p">),</span> <span class="o">**</span><span class="n">kw</span><span class="p">)</span> <span class="k">except</span> <span class="ne">OSError</span><span class="p">:</span> <span class="c1"># Not a file; assume it's JSON content even if not '{'/'[' prefixed</span> <span class="k">return</span> <span class="bp">cls</span><span class="o">.</span><span class="n">model_validate_json</span><span class="p">(</span><span class="n">source</span><span class="p">,</span> <span class="o">**</span><span class="n">kw</span><span class="p">)</span> <span class="nd">@classmethod</span> <span class="k">def</span><span class="w"> </span><span class="nf">from_yaml</span><span class="p">(</span><span class="bp">cls</span><span class="p">:</span> <span class="nb">type</span><span class="p">[</span><span class="n">T</span><span class="p">],</span> <span class="n">source</span><span class="p">:</span> <span class="n">Union</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Path</span><span class="p">],</span> <span class="o">**</span><span class="n">validate_kwargs</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="n">T</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Instantiate model from a YAML string or a file path with friendly errors."""</span> <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">source</span><span class="p">,</span> <span class="n">Path</span><span class="p">)</span> <span class="ow">or</span> <span class="p">(</span><span class="nb">isinstance</span><span class="p">(</span><span class="n">source</span><span class="p">,</span> <span class="nb">str</span><span class="p">)</span> <span class="ow">and</span> <span class="n">Path</span><span class="p">(</span><span class="n">source</span><span class="p">)</span><span class="o">.</span><span class="n">exists</span><span class="p">()):</span> <span class="n">text</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span><span class="n">source</span><span class="p">)</span><span class="o">.</span><span class="n">read_text</span><span class="p">(</span><span class="n">encoding</span><span class="o">=</span><span class="s2">"utf-8"</span><span class="p">)</span> <span class="k">else</span><span class="p">:</span> <span class="n">text</span> <span class="o">=</span> <span class="nb">str</span><span class="p">(</span><span class="n">source</span><span class="p">)</span> <span class="k">try</span><span class="p">:</span> <span class="n">data</span> <span class="o">=</span> <span class="n">yaml</span><span class="o">.</span><span class="n">safe_load</span><span class="p">(</span><span class="n">text</span><span class="p">)</span> <span class="ow">or</span> <span class="p">{}</span> <span class="k">except</span> <span class="n">yaml</span><span class="o">.</span><span class="n">YAMLError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span> <span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="n">SerdeMixin</span><span class="o">.</span><span class="n">_format_yaml_syntax_error</span><span class="p">(</span><span class="n">e</span><span class="p">,</span> <span class="n">text</span><span class="p">))</span> <span class="kn">from</span><span class="w"> </span><span class="nn">e</span> <span class="k">try</span><span class="p">:</span> <span class="k">return</span> <span class="bp">cls</span><span class="o">.</span><span class="n">model_validate</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="o">**</span><span class="n">validate_kwargs</span><span class="p">)</span> <span class="k">except</span> <span class="n">ValidationError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span> <span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="n">SerdeMixin</span><span class="o">.</span><span class="n">_format_validation_error</span><span class="p">(</span><span class="n">e</span><span class="p">,</span> <span class="n">data</span><span class="o">=</span><span class="n">data</span><span class="p">,</span> <span class="n">model</span><span class="o">=</span><span class="bp">cls</span><span class="p">))</span> <span class="kn">from</span><span class="w"> </span><span class="nn">e</span> <span class="c1"># ---------- convenience save/load ----------</span> <span class="k">def</span><span class="w"> </span><span class="nf">save_json</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">path</span><span class="p">:</span> <span class="n">Union</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Path</span><span class="p">],</span> <span class="o">**</span><span class="n">dump_kwargs</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="n">Path</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Save model to a JSON file. Returns the Path."""</span> <span class="n">p</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span><span class="n">path</span><span class="p">)</span> <span class="n">p</span><span class="o">.</span><span class="n">write_text</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">to_json</span><span class="p">(</span><span class="o">**</span><span class="n">dump_kwargs</span><span class="p">),</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">"utf-8"</span><span class="p">)</span> <span class="k">return</span> <span class="n">p</span> <span class="k">def</span><span class="w"> </span><span class="nf">save_yaml</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">path</span><span class="p">:</span> <span class="n">Union</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Path</span><span class="p">],</span> <span class="o">**</span><span class="n">dump_kwargs</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="n">Path</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Save model to a YAML file. Returns the Path."""</span> <span class="n">p</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span><span class="n">path</span><span class="p">)</span> <span class="n">p</span><span class="o">.</span><span class="n">write_text</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">to_yaml</span><span class="p">(</span><span class="o">**</span><span class="n">dump_kwargs</span><span class="p">),</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">"utf-8"</span><span class="p">)</span> <span class="k">return</span> <span class="n">p</span> <span class="nd">@classmethod</span> <span class="k">def</span><span class="w"> </span><span class="nf">load_json</span><span class="p">(</span><span class="bp">cls</span><span class="p">:</span> <span class="nb">type</span><span class="p">[</span><span class="n">T</span><span class="p">],</span> <span class="n">path</span><span class="p">:</span> <span class="n">Union</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Path</span><span class="p">],</span> <span class="o">**</span><span class="n">validate_kwargs</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="n">T</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Load model from a JSON file."""</span> <span class="k">return</span> <span class="bp">cls</span><span class="o">.</span><span class="n">from_json</span><span class="p">(</span><span class="n">Path</span><span class="p">(</span><span class="n">path</span><span class="p">),</span> <span class="o">**</span><span class="n">validate_kwargs</span><span class="p">)</span> <span class="c1"># type: ignore</span> <span class="nd">@classmethod</span> <span class="k">def</span><span class="w"> </span><span class="nf">load_yaml</span><span class="p">(</span><span class="bp">cls</span><span class="p">:</span> <span class="nb">type</span><span class="p">[</span><span class="n">T</span><span class="p">],</span> <span class="n">path</span><span class="p">:</span> <span class="n">Union</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Path</span><span class="p">],</span> <span class="o">**</span><span class="n">validate_kwargs</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="n">T</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Load model from a YAML file."""</span> <span class="k">return</span> <span class="bp">cls</span><span class="o">.</span><span class="n">from_yaml</span><span class="p">(</span><span class="n">Path</span><span class="p">(</span><span class="n">path</span><span class="p">),</span> <span class="o">**</span><span class="n">validate_kwargs</span><span class="p">)</span> <span class="c1"># type: ignore</span> <span class="c1"># ---------- helpers: friendly error messages ----------</span> <span class="nd">@staticmethod</span> <span class="k">def</span><span class="w"> </span><span class="nf">_yaml_context_snippet</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">line</span><span class="p">:</span> <span class="nb">int</span><span class="p">,</span> <span class="n">col</span><span class="p">:</span> <span class="nb">int</span><span class="p">,</span> <span class="n">context</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">1</span><span class="p">)</span> <span class="o">-></span> <span class="nb">str</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Build a tiny snippet pointing to the YAML error location.</span> <span class="sd"> Lines are 1-based.</span> <span class="sd"> """</span> <span class="n">lines</span> <span class="o">=</span> <span class="n">text</span><span class="o">.</span><span class="n">splitlines</span><span class="p">()</span> <span class="n">i</span> <span class="o">=</span> <span class="nb">max</span><span class="p">(</span><span class="n">line</span> <span class="o">-</span> <span class="mi">1</span> <span class="o">-</span> <span class="n">context</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span> <span class="n">j</span> <span class="o">=</span> <span class="nb">min</span><span class="p">(</span><span class="n">line</span> <span class="o">+</span> <span class="n">context</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">lines</span><span class="p">))</span> <span class="n">out</span> <span class="o">=</span> <span class="p">[]</span> <span class="k">for</span> <span class="n">idx</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">i</span><span class="p">,</span> <span class="n">j</span><span class="p">):</span> <span class="n">prefix</span> <span class="o">=</span> <span class="s2">">"</span> <span class="k">if</span> <span class="n">idx</span> <span class="o">==</span> <span class="n">line</span> <span class="o">-</span> <span class="mi">1</span> <span class="k">else</span> <span class="s2">" "</span> <span class="n">out</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">"</span><span class="si">{</span><span class="n">prefix</span><span class="si">}</span><span class="s2"> </span><span class="si">{</span><span class="n">idx</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mi">1</span><span class="si">:</span><span class="s2">>4</span><span class="si">}</span><span class="s2">: </span><span class="si">{</span><span class="n">lines</span><span class="p">[</span><span class="n">idx</span><span class="p">]</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span> <span class="k">if</span> <span class="n">idx</span> <span class="o">==</span> <span class="n">line</span> <span class="o">-</span> <span class="mi">1</span><span class="p">:</span> <span class="n">caret</span> <span class="o">=</span> <span class="s2">" "</span> <span class="o">*</span> <span class="p">(</span><span class="n">col</span> <span class="o">+</span> <span class="mi">7</span><span class="p">)</span> <span class="o">+</span> <span class="s2">"^"</span> <span class="c1"># 7 accounts for formatting above</span> <span class="n">out</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">caret</span><span class="p">)</span> <span class="k">return</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">"</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">out</span><span class="p">)</span> <span class="nd">@classmethod</span> <span class="k">def</span><span class="w"> </span><span class="nf">_format_yaml_syntax_error</span><span class="p">(</span><span class="bp">cls</span><span class="p">,</span> <span class="n">e</span><span class="p">:</span> <span class="n">yaml</span><span class="o">.</span><span class="n">YAMLError</span><span class="p">,</span> <span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-></span> <span class="nb">str</span><span class="p">:</span> <span class="c1"># Try to extract line/column from PyYAML mark</span> <span class="n">line</span> <span class="o">=</span> <span class="n">col</span> <span class="o">=</span> <span class="kc">None</span> <span class="n">problem_mark</span> <span class="o">=</span> <span class="nb">getattr</span><span class="p">(</span><span class="n">e</span><span class="p">,</span> <span class="s2">"problem_mark"</span><span class="p">,</span> <span class="kc">None</span><span class="p">)</span> <span class="k">if</span> <span class="n">problem_mark</span><span class="p">:</span> <span class="n">line</span> <span class="o">=</span> <span class="n">problem_mark</span><span class="o">.</span><span class="n">line</span> <span class="o">+</span> <span class="mi">1</span> <span class="n">col</span> <span class="o">=</span> <span class="n">problem_mark</span><span class="o">.</span><span class="n">column</span> <span class="n">header</span> <span class="o">=</span> <span class="s2">"Your YAML isn't valid."</span> <span class="k">if</span> <span class="n">line</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span> <span class="ow">and</span> <span class="n">col</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span> <span class="n">snippet</span> <span class="o">=</span> <span class="bp">cls</span><span class="o">.</span><span class="n">_yaml_context_snippet</span><span class="p">(</span><span class="n">text</span><span class="p">,</span> <span class="n">line</span><span class="p">,</span> <span class="n">col</span><span class="p">)</span> <span class="k">return</span> <span class="sa">f</span><span class="s2">"</span><span class="si">{</span><span class="n">header</span><span class="si">}</span><span class="se">\n</span><span class="s2">Line </span><span class="si">{</span><span class="n">line</span><span class="si">}</span><span class="s2">, column </span><span class="si">{</span><span class="n">col</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mi">1</span><span class="si">}</span><span class="s2">.</span><span class="se">\n\n</span><span class="si">{</span><span class="n">snippet</span><span class="si">}</span><span class="se">\n\n</span><span class="s2">Fix the </span><span class="se">\</span> <span class="s2"> YAML formatting at the ^ marker."</span> <span class="k">return</span> <span class="sa">f</span><span class="s2">"</span><span class="si">{</span><span class="n">header</span><span class="si">}</span><span class="s2"> </span><span class="si">{</span><span class="nb">str</span><span class="p">(</span><span class="n">e</span><span class="p">)</span><span class="si">}</span><span class="s2">"</span> <span class="nd">@classmethod</span> <span class="k">def</span><span class="w"> </span><span class="nf">_format_validation_error</span><span class="p">(</span> <span class="bp">cls</span><span class="p">,</span> <span class="n">e</span><span class="p">:</span> <span class="n">ValidationError</span><span class="p">,</span> <span class="n">data</span><span class="p">:</span> <span class="n">Any</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span><span class="p">,</span> <span class="n">model</span><span class="p">:</span> <span class="nb">type</span><span class="p">[</span><span class="n">BaseModel</span><span class="p">]</span> <span class="o">|</span> <span class="kc">None</span> <span class="o">=</span> <span class="kc">None</span><span class="p">,</span> <span class="p">)</span> <span class="o">-></span> <span class="nb">str</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Turn Pydantic errors into actionable, plain-English guidance."""</span> <span class="n">lines</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"Your YAML loaded, but it doesn't match the expected structure:"</span><span class="p">]</span> <span class="k">for</span> <span class="n">err</span> <span class="ow">in</span> <span class="n">e</span><span class="o">.</span><span class="n">errors</span><span class="p">():</span> <span class="n">loc</span> <span class="o">=</span> <span class="s2">"."</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="nb">str</span><span class="p">(</span><span class="n">p</span><span class="p">)</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">err</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">"loc"</span><span class="p">,</span> <span class="p">()))</span> <span class="n">typ</span> <span class="o">=</span> <span class="n">err</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">"type"</span><span class="p">,</span> <span class="s2">""</span><span class="p">)</span> <span class="n">msg</span> <span class="o">=</span> <span class="n">err</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">"msg"</span><span class="p">,</span> <span class="s2">""</span><span class="p">)</span> <span class="n">entry</span> <span class="o">=</span> <span class="bp">cls</span><span class="o">.</span><span class="n">_humanize_error</span><span class="p">(</span><span class="n">loc</span><span class="p">,</span> <span class="n">typ</span><span class="p">,</span> <span class="n">msg</span><span class="p">,</span> <span class="n">model</span><span class="p">)</span> <span class="n">lines</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">"• </span><span class="si">{</span><span class="n">entry</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span> <span class="n">lines</span><span class="o">.</span><span class="n">append</span><span class="p">(</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">Tip: keys are case-sensitive; remove unknown keys; </span><span class="se">\</span> <span class="s2"> match the types shown."</span> <span class="p">)</span> <span class="k">return</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">"</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">lines</span><span class="p">)</span> <span class="nd">@classmethod</span> <span class="k">def</span><span class="w"> </span><span class="nf">_humanize_error</span><span class="p">(</span><span class="bp">cls</span><span class="p">,</span> <span class="n">loc</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">typ</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">msg</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">model</span><span class="p">:</span> <span class="nb">type</span><span class="p">[</span><span class="n">BaseModel</span><span class="p">]</span> <span class="o">|</span> <span class="kc">None</span><span class="p">)</span> <span class="o">-></span> <span class="nb">str</span><span class="p">:</span> <span class="c1"># Missing required field</span> <span class="k">if</span> <span class="s2">"missing"</span> <span class="ow">in</span> <span class="n">typ</span> <span class="ow">or</span> <span class="s2">"missing"</span> <span class="ow">in</span> <span class="n">msg</span><span class="o">.</span><span class="n">lower</span><span class="p">():</span> <span class="n">suggestion</span> <span class="o">=</span> <span class="bp">cls</span><span class="o">.</span><span class="n">_suggest_example</span><span class="p">(</span><span class="n">loc</span><span class="p">,</span> <span class="n">model</span><span class="p">)</span> <span class="k">return</span> <span class="sa">f</span><span class="s2">"Missing required field: `</span><span class="si">{</span><span class="n">loc</span><span class="si">}</span><span class="s2">`. Add it like:</span><span class="se">\n</span><span class="si">{</span><span class="n">suggestion</span><span class="si">}</span><span class="s2">"</span> <span class="c1"># Extra / unknown field</span> <span class="k">if</span> <span class="s2">"extra_forbidden"</span> <span class="ow">in</span> <span class="n">typ</span> <span class="ow">or</span> <span class="s2">"extra fields not permitted"</span> <span class="ow">in</span> <span class="n">msg</span><span class="o">.</span><span class="n">lower</span><span class="p">():</span> <span class="k">return</span> <span class="sa">f</span><span class="s2">"Unknown field at `</span><span class="si">{</span><span class="n">loc</span><span class="si">}</span><span class="s2">`. Remove this key or rename it to a </span><span class="se">\</span> <span class="s2"> valid field."</span> <span class="c1"># Type error</span> <span class="k">if</span> <span class="s2">"type_error"</span> <span class="ow">in</span> <span class="n">typ</span> <span class="ow">or</span> <span class="s2">"input_type"</span> <span class="ow">in</span> <span class="n">typ</span> <span class="ow">or</span> <span class="s2">"value_error"</span> <span class="ow">in</span> <span class="n">typ</span><span class="p">:</span> <span class="n">expected</span> <span class="o">=</span> <span class="bp">cls</span><span class="o">.</span><span class="n">_extract_expected_type_from_msg</span><span class="p">(</span><span class="n">msg</span><span class="p">)</span> <span class="k">return</span> <span class="sa">f</span><span class="s2">"Wrong type at `</span><span class="si">{</span><span class="n">loc</span><span class="si">}</span><span class="s2">`. </span><span class="si">{</span><span class="n">expected</span><span class="si">}</span><span class="s2">"</span> <span class="c1"># Fallback</span> <span class="n">nice</span> <span class="o">=</span> <span class="n">msg</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">upper</span><span class="p">()</span> <span class="o">+</span> <span class="n">msg</span><span class="p">[</span><span class="mi">1</span><span class="p">:]</span> <span class="k">if</span> <span class="n">msg</span> <span class="k">else</span> <span class="s2">"Invalid value."</span> <span class="k">return</span> <span class="sa">f</span><span class="s2">"</span><span class="si">{</span><span class="n">nice</span><span class="si">}</span><span class="s2"> (at `</span><span class="si">{</span><span class="n">loc</span><span class="si">}</span><span class="s2">`)."</span> <span class="nd">@staticmethod</span> <span class="k">def</span><span class="w"> </span><span class="nf">_extract_expected_type_from_msg</span><span class="p">(</span><span class="n">msg</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-></span> <span class="nb">str</span><span class="p">:</span> <span class="c1"># Pydantic v2 error messages often include "Input should be <type>"</span> <span class="c1"># Keep this short and friendly.</span> <span class="k">if</span> <span class="n">msg</span><span class="o">.</span><span class="n">lower</span><span class="p">()</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">"input should be"</span><span class="p">):</span> <span class="k">return</span> <span class="n">msg</span> <span class="k">return</span> <span class="sa">f</span><span class="s2">"</span><span class="si">{</span><span class="n">msg</span><span class="si">}</span><span class="s2">"</span> <span class="nd">@classmethod</span> <span class="k">def</span><span class="w"> </span><span class="nf">_suggest_example</span><span class="p">(</span><span class="bp">cls</span><span class="p">,</span> <span class="n">loc</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">model</span><span class="p">:</span> <span class="nb">type</span><span class="p">[</span><span class="n">BaseModel</span><span class="p">]</span> <span class="o">|</span> <span class="kc">None</span><span class="p">)</span> <span class="o">-></span> <span class="nb">str</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Build a minimal YAML example for a missing field by inspecting the model."""</span> <span class="k">if</span> <span class="ow">not</span> <span class="n">model</span><span class="p">:</span> <span class="k">return</span> <span class="sa">f</span><span class="s2">"</span><span class="si">{</span><span class="bp">cls</span><span class="o">.</span><span class="n">_yaml_block_for_path</span><span class="p">(</span><span class="n">loc</span><span class="p">,</span><span class="w"> </span><span class="s1">'<value>'</span><span class="p">)</span><span class="si">}</span><span class="s2">"</span> <span class="c1"># Walk model_fields using the dotted path if possible</span> <span class="n">parts</span> <span class="o">=</span> <span class="n">loc</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s2">"."</span><span class="p">)</span> <span class="k">if</span> <span class="n">loc</span> <span class="k">else</span> <span class="p">[]</span> <span class="n">current_model</span> <span class="o">=</span> <span class="n">model</span> <span class="n">field_type</span> <span class="o">=</span> <span class="kc">None</span> <span class="k">try</span><span class="p">:</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">parts</span><span class="p">:</span> <span class="n">fld</span><span class="p">:</span> <span class="n">fields</span><span class="o">.</span><span class="n">FieldInfo</span> <span class="o">=</span> <span class="n">current_model</span><span class="o">.</span><span class="n">model_fields</span><span class="p">[</span><span class="n">p</span><span class="p">]</span> <span class="n">field_type</span> <span class="o">=</span> <span class="n">fld</span><span class="o">.</span><span class="n">annotation</span> <span class="n">origin</span> <span class="o">=</span> <span class="n">get_origin</span><span class="p">(</span><span class="n">field_type</span><span class="p">)</span> <span class="n">args</span> <span class="o">=</span> <span class="n">get_args</span><span class="p">(</span><span class="n">field_type</span><span class="p">)</span> <span class="c1"># If nested BaseModel, descend</span> <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">fld</span><span class="o">.</span><span class="n">annotation</span><span class="p">,</span> <span class="nb">type</span><span class="p">)</span> <span class="ow">and</span> <span class="nb">issubclass</span><span class="p">(</span><span class="n">fld</span><span class="o">.</span><span class="n">annotation</span><span class="p">,</span> <span class="n">BaseModel</span><span class="p">):</span> <span class="n">current_model</span> <span class="o">=</span> <span class="n">fld</span><span class="o">.</span><span class="n">annotation</span> <span class="k">elif</span> <span class="n">origin</span> <span class="ow">in</span> <span class="p">(</span><span class="nb">list</span><span class="p">,</span> <span class="nb">tuple</span><span class="p">)</span> <span class="ow">and</span> <span class="n">args</span> <span class="ow">and</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">args</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="nb">type</span><span class="p">)</span> <span class="ow">and</span> <span class="nb">issubclass</span><span class="p">(</span><span class="n">args</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">BaseModel</span><span class="p">):</span> <span class="n">current_model</span> <span class="o">=</span> <span class="n">args</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="c1"># item model</span> <span class="c1"># else:</span> <span class="c1"># current_model = None # stop</span> <span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span> <span class="k">pass</span> <span class="n">example_val</span> <span class="o">=</span> <span class="bp">cls</span><span class="o">.</span><span class="n">_example_for_type</span><span class="p">(</span><span class="n">field_type</span><span class="p">)</span> <span class="k">return</span> <span class="bp">cls</span><span class="o">.</span><span class="n">_yaml_block_for_path</span><span class="p">(</span><span class="n">loc</span><span class="p">,</span> <span class="n">example_val</span><span class="p">)</span> <span class="nd">@staticmethod</span> <span class="k">def</span><span class="w"> </span><span class="nf">_yaml_block_for_path</span><span class="p">(</span><span class="n">path</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">leaf</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-></span> <span class="nb">str</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Build a tiny YAML block with indentation for a dotted path.</span> <span class="sd"> Return an indented YAML block like:</span> <span class="sd"> parent:</span> <span class="sd"> child: <example></span> <span class="sd"> """</span> <span class="k">if</span> <span class="ow">not</span> <span class="n">path</span><span class="p">:</span> <span class="k">return</span> <span class="n">leaf</span> <span class="n">parts</span> <span class="o">=</span> <span class="n">path</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s2">"."</span><span class="p">)</span> <span class="n">indent</span> <span class="o">=</span> <span class="s2">""</span> <span class="n">lines</span> <span class="o">=</span> <span class="p">[]</span> <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">p</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">parts</span><span class="p">):</span> <span class="k">if</span> <span class="n">i</span> <span class="o">==</span> <span class="nb">len</span><span class="p">(</span><span class="n">parts</span><span class="p">)</span> <span class="o">-</span> <span class="mi">1</span><span class="p">:</span> <span class="n">lines</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">"</span><span class="si">{</span><span class="n">indent</span><span class="si">}{</span><span class="n">p</span><span class="si">}</span><span class="s2">: </span><span class="si">{</span><span class="n">leaf</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span> <span class="k">else</span><span class="p">:</span> <span class="n">lines</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">"</span><span class="si">{</span><span class="n">indent</span><span class="si">}{</span><span class="n">p</span><span class="si">}</span><span class="s2">:"</span><span class="p">)</span> <span class="n">indent</span> <span class="o">+=</span> <span class="s2">" "</span> <span class="k">return</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">"</span> <span class="o">+</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">"</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">lines</span><span class="p">)</span> <span class="nd">@staticmethod</span> <span class="k">def</span><span class="w"> </span><span class="nf">_example_for_type</span><span class="p">(</span><span class="n">tp</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="nb">str</span><span class="p">:</span> <span class="c1"># Heuristic examples; kept simple for lay users</span> <span class="k">if</span> <span class="n">tp</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span> <span class="k">return</span> <span class="s2">"<value>"</span> <span class="n">origin</span> <span class="o">=</span> <span class="n">get_origin</span><span class="p">(</span><span class="n">tp</span><span class="p">)</span> <span class="n">args</span> <span class="o">=</span> <span class="n">get_args</span><span class="p">(</span><span class="n">tp</span><span class="p">)</span> <span class="k">def</span><span class="w"> </span><span class="nf">name</span><span class="p">(</span><span class="n">t</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="n">Any</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Get a friendly name for a type."""</span> <span class="k">try</span><span class="p">:</span> <span class="k">return</span> <span class="n">t</span><span class="o">.</span><span class="vm">__name__</span> <span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span> <span class="k">return</span> <span class="nb">str</span><span class="p">(</span><span class="n">t</span><span class="p">)</span> <span class="c1"># Common primitives</span> <span class="k">if</span> <span class="n">tp</span> <span class="ow">in</span> <span class="p">(</span><span class="nb">int</span><span class="p">,</span> <span class="nb">float</span><span class="p">):</span> <span class="k">return</span> <span class="s2">"123"</span> <span class="k">if</span> <span class="n">tp</span> <span class="ow">is</span> <span class="nb">int</span> <span class="k">else</span> <span class="s2">"12.34"</span> <span class="k">if</span> <span class="n">tp</span> <span class="ow">is</span> <span class="nb">bool</span><span class="p">:</span> <span class="k">return</span> <span class="s2">"true"</span> <span class="k">if</span> <span class="n">tp</span> <span class="ow">is</span> <span class="nb">str</span><span class="p">:</span> <span class="k">return</span> <span class="s2">"<text>"</span> <span class="c1"># Optionals / Unions</span> <span class="k">if</span> <span class="n">origin</span> <span class="ow">is</span> <span class="n">Union</span><span class="p">:</span> <span class="k">return</span> <span class="sa">f</span><span class="s2">"<</span><span class="si">{</span><span class="s1">' or '</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">name</span><span class="p">(</span><span class="n">a</span><span class="p">)</span><span class="w"> </span><span class="k">for</span><span class="w"> </span><span class="n">a</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="n">args</span><span class="p">)</span><span class="si">}</span><span class="s2">>"</span> <span class="c1"># Collections</span> <span class="k">if</span> <span class="n">origin</span> <span class="ow">in</span> <span class="p">(</span><span class="nb">list</span><span class="p">,</span> <span class="nb">tuple</span><span class="p">,</span> <span class="nb">set</span><span class="p">):</span> <span class="n">inner</span> <span class="o">=</span> <span class="n">_try_example</span><span class="p">(</span><span class="n">args</span><span class="p">[</span><span class="mi">0</span><span class="p">])</span> <span class="k">if</span> <span class="n">args</span> <span class="k">else</span> <span class="s2">"<item>"</span> <span class="k">return</span> <span class="sa">f</span><span class="s2">"</span><span class="se">\n</span><span class="s2"> - </span><span class="si">{</span><span class="n">inner</span><span class="si">}</span><span class="s2">"</span> <span class="k">if</span> <span class="n">origin</span> <span class="ow">is</span> <span class="nb">dict</span><span class="p">:</span> <span class="n">k</span> <span class="o">=</span> <span class="n">_try_example</span><span class="p">(</span><span class="n">args</span><span class="p">[</span><span class="mi">0</span><span class="p">])</span> <span class="k">if</span> <span class="n">args</span> <span class="k">else</span> <span class="s2">"<key>"</span> <span class="n">v</span> <span class="o">=</span> <span class="n">_try_example</span><span class="p">(</span><span class="n">args</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span> <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">args</span><span class="p">)</span> <span class="o">></span> <span class="mi">1</span> <span class="k">else</span> <span class="s2">"<value>"</span> <span class="k">return</span> <span class="sa">f</span><span class="s2">"</span><span class="se">\n</span><span class="s2"> </span><span class="si">{</span><span class="n">k</span><span class="si">}</span><span class="s2">: </span><span class="si">{</span><span class="n">v</span><span class="si">}</span><span class="s2">"</span> <span class="c1"># Nested models</span> <span class="k">try</span><span class="p">:</span> <span class="k">if</span> <span class="nb">issubclass</span><span class="p">(</span><span class="n">tp</span><span class="p">,</span> <span class="n">BaseModel</span><span class="p">):</span> <span class="k">return</span> <span class="s2">"</span><span class="se">\n</span><span class="s2"> <subfields…>"</span> <span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span> <span class="k">pass</span> <span class="k">return</span> <span class="s2">"<value>"</span> </code></pre></div></td></tr></table></div> </details> <div class="doc doc-children"> <div class="doc doc-object doc-function"> <h6 id="dcs_simulation_engine.utils.serde.SerdeMixin.from_json" class="doc doc-heading"> <code class="highlight language-python"><span class="n">from_json</span><span class="p">(</span><span class="n">source</span><span class="p">,</span> <span class="o">**</span><span class="n">kw</span><span class="p">)</span></code> <span class="doc doc-labels"> <small class="doc doc-label doc-label-classmethod"><code>classmethod</code></small> </span> </h6> <div class="doc doc-contents "> <p>Instantiate model from a JSON string or a file path with friendly errors.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/serde.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">70</span> <span class="normal">71</span> <span class="normal">72</span> <span class="normal">73</span> <span class="normal">74</span> <span class="normal">75</span> <span class="normal">76</span> <span class="normal">77</span> <span class="normal">78</span> <span class="normal">79</span> <span class="normal">80</span> <span class="normal">81</span> <span class="normal">82</span> <span class="normal">83</span> <span class="normal">84</span> <span class="normal">85</span> <span class="normal">86</span> <span class="normal">87</span> <span class="normal">88</span> <span class="normal">89</span> <span class="normal">90</span> <span class="normal">91</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="nd">@classmethod</span> <span class="k">def</span><span class="w"> </span><span class="nf">from_json</span><span class="p">(</span><span class="bp">cls</span><span class="p">:</span> <span class="nb">type</span><span class="p">[</span><span class="n">T</span><span class="p">],</span> <span class="n">source</span><span class="p">:</span> <span class="n">Union</span><span class="p">[</span><span class="n">Mapping</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">],</span> <span class="nb">str</span><span class="p">,</span> <span class="n">Path</span><span class="p">],</span> <span class="o">**</span><span class="n">kw</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="n">T</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Instantiate model from a JSON string or a file path with friendly errors."""</span> <span class="c1"># logger.debug(f"Serde called with source type: {type(source)}")</span> <span class="c1"># logger.debug(f"Source content: {str(source)}")</span> <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">source</span><span class="p">,</span> <span class="n">Mapping</span><span class="p">):</span> <span class="n">logger</span><span class="o">.</span><span class="n">debug</span><span class="p">(</span><span class="s2">"Source is a Mapping, using model_validate"</span><span class="p">)</span> <span class="k">return</span> <span class="bp">cls</span><span class="o">.</span><span class="n">model_validate</span><span class="p">(</span><span class="n">source</span><span class="p">,</span> <span class="o">**</span><span class="n">kw</span><span class="p">)</span> <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">source</span><span class="p">,</span> <span class="n">Path</span><span class="p">):</span> <span class="n">logger</span><span class="o">.</span><span class="n">debug</span><span class="p">(</span><span class="s2">"Source is a Path, reading text"</span><span class="p">)</span> <span class="n">text</span> <span class="o">=</span> <span class="n">source</span><span class="o">.</span><span class="n">read_text</span><span class="p">(</span><span class="n">encoding</span><span class="o">=</span><span class="s2">"utf-8"</span><span class="p">)</span> <span class="k">return</span> <span class="bp">cls</span><span class="o">.</span><span class="n">model_validate_json</span><span class="p">(</span><span class="n">text</span><span class="p">,</span> <span class="o">**</span><span class="n">kw</span><span class="p">)</span> <span class="c1"># string: prefer JSON first; only then try file</span> <span class="n">logger</span><span class="o">.</span><span class="n">debug</span><span class="p">(</span><span class="s2">"Source is a string, checking content"</span><span class="p">)</span> <span class="n">s</span> <span class="o">=</span> <span class="n">source</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span> <span class="k">if</span> <span class="n">s</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">"{"</span><span class="p">)</span> <span class="ow">or</span> <span class="n">s</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">"["</span><span class="p">):</span> <span class="k">return</span> <span class="bp">cls</span><span class="o">.</span><span class="n">model_validate_json</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="o">**</span><span class="n">kw</span><span class="p">)</span> <span class="k">try</span><span class="p">:</span> <span class="k">return</span> <span class="bp">cls</span><span class="o">.</span><span class="n">model_validate_json</span><span class="p">(</span><span class="n">Path</span><span class="p">(</span><span class="n">source</span><span class="p">)</span><span class="o">.</span><span class="n">read_text</span><span class="p">(</span><span class="n">encoding</span><span class="o">=</span><span class="s2">"utf-8"</span><span class="p">),</span> <span class="o">**</span><span class="n">kw</span><span class="p">)</span> <span class="k">except</span> <span class="ne">OSError</span><span class="p">:</span> <span class="c1"># Not a file; assume it's JSON content even if not '{'/'[' prefixed</span> <span class="k">return</span> <span class="bp">cls</span><span class="o">.</span><span class="n">model_validate_json</span><span class="p">(</span><span class="n">source</span><span class="p">,</span> <span class="o">**</span><span class="n">kw</span><span class="p">)</span> </code></pre></div></td></tr></table></div> </details> </div> </div> <div class="doc doc-object doc-function"> <h6 id="dcs_simulation_engine.utils.serde.SerdeMixin.from_yaml" class="doc doc-heading"> <code class="highlight language-python"><span class="n">from_yaml</span><span class="p">(</span><span class="n">source</span><span class="p">,</span> <span class="o">**</span><span class="n">validate_kwargs</span><span class="p">)</span></code> <span class="doc doc-labels"> <small class="doc doc-label doc-label-classmethod"><code>classmethod</code></small> </span> </h6> <div class="doc doc-contents "> <p>Instantiate model from a YAML string or a file path with friendly errors.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/serde.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal"> 93</span> <span class="normal"> 94</span> <span class="normal"> 95</span> <span class="normal"> 96</span> <span class="normal"> 97</span> <span class="normal"> 98</span> <span class="normal"> 99</span> <span class="normal">100</span> <span class="normal">101</span> <span class="normal">102</span> <span class="normal">103</span> <span class="normal">104</span> <span class="normal">105</span> <span class="normal">106</span> <span class="normal">107</span> <span class="normal">108</span> <span class="normal">109</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="nd">@classmethod</span> <span class="k">def</span><span class="w"> </span><span class="nf">from_yaml</span><span class="p">(</span><span class="bp">cls</span><span class="p">:</span> <span class="nb">type</span><span class="p">[</span><span class="n">T</span><span class="p">],</span> <span class="n">source</span><span class="p">:</span> <span class="n">Union</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Path</span><span class="p">],</span> <span class="o">**</span><span class="n">validate_kwargs</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="n">T</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Instantiate model from a YAML string or a file path with friendly errors."""</span> <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">source</span><span class="p">,</span> <span class="n">Path</span><span class="p">)</span> <span class="ow">or</span> <span class="p">(</span><span class="nb">isinstance</span><span class="p">(</span><span class="n">source</span><span class="p">,</span> <span class="nb">str</span><span class="p">)</span> <span class="ow">and</span> <span class="n">Path</span><span class="p">(</span><span class="n">source</span><span class="p">)</span><span class="o">.</span><span class="n">exists</span><span class="p">()):</span> <span class="n">text</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span><span class="n">source</span><span class="p">)</span><span class="o">.</span><span class="n">read_text</span><span class="p">(</span><span class="n">encoding</span><span class="o">=</span><span class="s2">"utf-8"</span><span class="p">)</span> <span class="k">else</span><span class="p">:</span> <span class="n">text</span> <span class="o">=</span> <span class="nb">str</span><span class="p">(</span><span class="n">source</span><span class="p">)</span> <span class="k">try</span><span class="p">:</span> <span class="n">data</span> <span class="o">=</span> <span class="n">yaml</span><span class="o">.</span><span class="n">safe_load</span><span class="p">(</span><span class="n">text</span><span class="p">)</span> <span class="ow">or</span> <span class="p">{}</span> <span class="k">except</span> <span class="n">yaml</span><span class="o">.</span><span class="n">YAMLError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span> <span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="n">SerdeMixin</span><span class="o">.</span><span class="n">_format_yaml_syntax_error</span><span class="p">(</span><span class="n">e</span><span class="p">,</span> <span class="n">text</span><span class="p">))</span> <span class="kn">from</span><span class="w"> </span><span class="nn">e</span> <span class="k">try</span><span class="p">:</span> <span class="k">return</span> <span class="bp">cls</span><span class="o">.</span><span class="n">model_validate</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="o">**</span><span class="n">validate_kwargs</span><span class="p">)</span> <span class="k">except</span> <span class="n">ValidationError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span> <span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="n">SerdeMixin</span><span class="o">.</span><span class="n">_format_validation_error</span><span class="p">(</span><span class="n">e</span><span class="p">,</span> <span class="n">data</span><span class="o">=</span><span class="n">data</span><span class="p">,</span> <span class="n">model</span><span class="o">=</span><span class="bp">cls</span><span class="p">))</span> <span class="kn">from</span><span class="w"> </span><span class="nn">e</span> </code></pre></div></td></tr></table></div> </details> </div> </div> <div class="doc doc-object doc-function"> <h6 id="dcs_simulation_engine.utils.serde.SerdeMixin.load_json" class="doc doc-heading"> <code class="highlight language-python"><span class="n">load_json</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="o">**</span><span class="n">validate_kwargs</span><span class="p">)</span></code> <span class="doc doc-labels"> <small class="doc doc-label doc-label-classmethod"><code>classmethod</code></small> </span> </h6> <div class="doc doc-contents "> <p>Load model from a JSON file.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/serde.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">124</span> <span class="normal">125</span> <span class="normal">126</span> <span class="normal">127</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="nd">@classmethod</span> <span class="k">def</span><span class="w"> </span><span class="nf">load_json</span><span class="p">(</span><span class="bp">cls</span><span class="p">:</span> <span class="nb">type</span><span class="p">[</span><span class="n">T</span><span class="p">],</span> <span class="n">path</span><span class="p">:</span> <span class="n">Union</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Path</span><span class="p">],</span> <span class="o">**</span><span class="n">validate_kwargs</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="n">T</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Load model from a JSON file."""</span> <span class="k">return</span> <span class="bp">cls</span><span class="o">.</span><span class="n">from_json</span><span class="p">(</span><span class="n">Path</span><span class="p">(</span><span class="n">path</span><span class="p">),</span> <span class="o">**</span><span class="n">validate_kwargs</span><span class="p">)</span> <span class="c1"># type: ignore</span> </code></pre></div></td></tr></table></div> </details> </div> </div> <div class="doc doc-object doc-function"> <h6 id="dcs_simulation_engine.utils.serde.SerdeMixin.load_yaml" class="doc doc-heading"> <code class="highlight language-python"><span class="n">load_yaml</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="o">**</span><span class="n">validate_kwargs</span><span class="p">)</span></code> <span class="doc doc-labels"> <small class="doc doc-label doc-label-classmethod"><code>classmethod</code></small> </span> </h6> <div class="doc doc-contents "> <p>Load model from a YAML file.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/serde.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">129</span> <span class="normal">130</span> <span class="normal">131</span> <span class="normal">132</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="nd">@classmethod</span> <span class="k">def</span><span class="w"> </span><span class="nf">load_yaml</span><span class="p">(</span><span class="bp">cls</span><span class="p">:</span> <span class="nb">type</span><span class="p">[</span><span class="n">T</span><span class="p">],</span> <span class="n">path</span><span class="p">:</span> <span class="n">Union</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Path</span><span class="p">],</span> <span class="o">**</span><span class="n">validate_kwargs</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="n">T</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Load model from a YAML file."""</span> <span class="k">return</span> <span class="bp">cls</span><span class="o">.</span><span class="n">from_yaml</span><span class="p">(</span><span class="n">Path</span><span class="p">(</span><span class="n">path</span><span class="p">),</span> <span class="o">**</span><span class="n">validate_kwargs</span><span class="p">)</span> <span class="c1"># type: ignore</span> </code></pre></div></td></tr></table></div> </details> </div> </div> <div class="doc doc-object doc-function"> <h6 id="dcs_simulation_engine.utils.serde.SerdeMixin.save_json" class="doc doc-heading"> <code class="highlight language-python"><span class="n">save_json</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="o">**</span><span class="n">dump_kwargs</span><span class="p">)</span></code> </h6> <div class="doc doc-contents "> <p>Save model to a JSON file. Returns the Path.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/serde.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">112</span> <span class="normal">113</span> <span class="normal">114</span> <span class="normal">115</span> <span class="normal">116</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">save_json</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">path</span><span class="p">:</span> <span class="n">Union</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Path</span><span class="p">],</span> <span class="o">**</span><span class="n">dump_kwargs</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="n">Path</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Save model to a JSON file. Returns the Path."""</span> <span class="n">p</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span><span class="n">path</span><span class="p">)</span> <span class="n">p</span><span class="o">.</span><span class="n">write_text</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">to_json</span><span class="p">(</span><span class="o">**</span><span class="n">dump_kwargs</span><span class="p">),</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">"utf-8"</span><span class="p">)</span> <span class="k">return</span> <span class="n">p</span> </code></pre></div></td></tr></table></div> </details> </div> </div> <div class="doc doc-object doc-function"> <h6 id="dcs_simulation_engine.utils.serde.SerdeMixin.save_yaml" class="doc doc-heading"> <code class="highlight language-python"><span class="n">save_yaml</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="o">**</span><span class="n">dump_kwargs</span><span class="p">)</span></code> </h6> <div class="doc doc-contents "> <p>Save model to a YAML file. Returns the Path.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/serde.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">118</span> <span class="normal">119</span> <span class="normal">120</span> <span class="normal">121</span> <span class="normal">122</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">save_yaml</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">path</span><span class="p">:</span> <span class="n">Union</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Path</span><span class="p">],</span> <span class="o">**</span><span class="n">dump_kwargs</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="n">Path</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Save model to a YAML file. Returns the Path."""</span> <span class="n">p</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span><span class="n">path</span><span class="p">)</span> <span class="n">p</span><span class="o">.</span><span class="n">write_text</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">to_yaml</span><span class="p">(</span><span class="o">**</span><span class="n">dump_kwargs</span><span class="p">),</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">"utf-8"</span><span class="p">)</span> <span class="k">return</span> <span class="n">p</span> </code></pre></div></td></tr></table></div> </details> </div> </div> <div class="doc doc-object doc-function"> <h6 id="dcs_simulation_engine.utils.serde.SerdeMixin.to_dict" class="doc doc-heading"> <code class="highlight language-python"><span class="n">to_dict</span><span class="p">(</span><span class="o">**</span><span class="n">dump_kwargs</span><span class="p">)</span></code> </h6> <div class="doc doc-contents "> <p>Convert model to dict. Pass model_dump kwargs if desired.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/serde.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">42</span> <span class="normal">43</span> <span class="normal">44</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">to_dict</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">**</span><span class="n">dump_kwargs</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]:</span> <span class="w"> </span><span class="sd">"""Convert model to dict. Pass model_dump kwargs if desired."""</span> <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">model_dump</span><span class="p">(</span><span class="o">**</span><span class="n">dump_kwargs</span><span class="p">)</span> </code></pre></div></td></tr></table></div> </details> </div> </div> <div class="doc doc-object doc-function"> <h6 id="dcs_simulation_engine.utils.serde.SerdeMixin.to_json" class="doc doc-heading"> <code class="highlight language-python"><span class="n">to_json</span><span class="p">(</span><span class="o">**</span><span class="n">dump_kwargs</span><span class="p">)</span></code> </h6> <div class="doc doc-contents "> <p>Convert model to JSON string.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/serde.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">46</span> <span class="normal">47</span> <span class="normal">48</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">to_json</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">**</span><span class="n">dump_kwargs</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="nb">str</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Convert model to JSON string."""</span> <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">model_dump_json</span><span class="p">(</span><span class="o">**</span><span class="n">dump_kwargs</span><span class="p">)</span> </code></pre></div></td></tr></table></div> </details> </div> </div> <div class="doc doc-object doc-function"> <h6 id="dcs_simulation_engine.utils.serde.SerdeMixin.to_yaml" class="doc doc-heading"> <code class="highlight language-python"><span class="n">to_yaml</span><span class="p">(</span><span class="o">**</span><span class="n">dump_kwargs</span><span class="p">)</span></code> </h6> <div class="doc doc-contents "> <p>Convert model to readable YAML.</p> <p>Pass yaml.safe_dump kwargs if desired (e.g., sort_keys=False).</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/serde.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">50</span> <span class="normal">51</span> <span class="normal">52</span> <span class="normal">53</span> <span class="normal">54</span> <span class="normal">55</span> <span class="normal">56</span> <span class="normal">57</span> <span class="normal">58</span> <span class="normal">59</span> <span class="normal">60</span> <span class="normal">61</span> <span class="normal">62</span> <span class="normal">63</span> <span class="normal">64</span> <span class="normal">65</span> <span class="normal">66</span> <span class="normal">67</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">to_yaml</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">**</span><span class="n">dump_kwargs</span><span class="p">:</span> <span class="n">Any</span><span class="p">)</span> <span class="o">-></span> <span class="nb">str</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Convert model to readable YAML.</span> <span class="sd"> Pass yaml.safe_dump kwargs if desired (e.g., sort_keys=False).</span> <span class="sd"> """</span> <span class="c1"># TODO: pre-v001 make export nice yaml not all the /newlines</span> <span class="n">data</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">model_dump</span><span class="p">()</span> <span class="c1"># readable, block-style YAML; avoid single-line flow; keep key order</span> <span class="k">return</span> <span class="nb">str</span><span class="p">(</span> <span class="n">yaml</span><span class="o">.</span><span class="n">safe_dump</span><span class="p">(</span> <span class="n">data</span><span class="p">,</span> <span class="n">allow_unicode</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">sort_keys</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">default_flow_style</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">width</span><span class="o">=</span><span class="mi">88</span><span class="p">,</span> <span class="o">**</span><span class="n">dump_kwargs</span><span class="p">,</span> <span class="p">)</span> <span class="p">)</span> </code></pre></div></td></tr></table></div> </details> </div> </div> </div> </div> </div> </div> </div> </div> <div class="doc doc-object doc-module"> <h4 id="dcs_simulation_engine.utils.time" class="doc doc-heading"> <code>time</code> </h4> <div class="doc doc-contents "> <p>UTC time helpers used across runtime and persistence layers.</p> <div class="doc doc-children"> <div class="doc doc-object doc-function"> <h5 id="dcs_simulation_engine.utils.time.utc_now" class="doc doc-heading"> <code class="highlight language-python"><span class="n">utc_now</span><span class="p">()</span></code> </h5> <div class="doc doc-contents "> <p>Return timezone-aware current UTC datetime.</p> <details class="mkdocstrings-source"> <summary>Source code in <code>dcs_simulation_engine/utils/time.py</code></summary> <div class="highlight"><table class="highlighttable"><tr><td class="linenos"><div class="linenodiv"><pre><span></span><span class="normal">6</span> <span class="normal">7</span> <span class="normal">8</span></pre></div></td><td class="code"><div><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">utc_now</span><span class="p">()</span> <span class="o">-></span> <span class="n">datetime</span><span class="p">:</span> <span class="w"> </span><span class="sd">"""Return timezone-aware current UTC datetime."""</span> <span class="k">return</span> <span class="n">datetime</span><span class="o">.</span><span class="n">now</span><span class="p">(</span><span class="n">timezone</span><span class="o">.</span><span class="n">utc</span><span class="p">)</span> </code></pre></div></td></tr></table></div> </details> </div> </div> </div> </div> </div> </div> </div> </div> </div> </div> </div> </article> </div> <script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script> </div> </main> <footer class="md-footer"> <div class="md-footer-meta md-typeset"> <div class="md-footer-meta__inner md-grid"> <div class="md-copyright"> Made with <a href="https://squidfunk.github.io/mkdocs-material/" target="_blank" rel="noopener"> Material for MkDocs </a> </div> </div> </div> </footer> </div> <div class="md-dialog" data-md-component="dialog"> <div class="md-dialog__inner md-typeset"></div> </div> <script id="__config" type="application/json">{"annotate": null, "base": ".", "features": [], "search": "assets/javascripts/workers/search.2c215733.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script> <script src="assets/javascripts/bundle.79ae519e.min.js"></script> <script src="https://unpkg.com/mermaid@11/dist/mermaid.min.js"></script> <script src="javascripts/mermaid.js"></script> </body> </html>