"""M10: reading and writing how a campaign's entities look. Four endpoints on the campaign, and one on the scene beneath it. They are the only reader-facing surface M10 adds, and they are an API surface rather than a browser one: M10 builds no gallery, no picker and no preview, because there is nothing to generate and a screen for configuring depictions nobody can make would be a feature pretending to be a seam. ## Why a scene-packet endpoint exists at all `GET .../scene-packet` returns exactly what a future media coordinator would be handed (`media/packet.py`). Nothing in v1 calls it, and it generates nothing. It is here because it is the one part of M10 whose *contents* are a correctness claim โ€” that a provider is given a bounded view and not the campaign, and that narrator-only material does not travel through it. A claim like that should be inspectable by whoever is reviewing the boundary, not only by a test that imports a private function. It is a read: it writes nothing, emits no event, and cannot move the head. ## What these endpoints deliberately are not They are not a state API. A visual profile is presentation metadata and writing one changes no story fact (`models.VisualProfile`), so there is no event, no proposal, no snapshot and no head movement anywhere below here. The separation is structural โ€” this module reaches `media.profiles`, and that module imports nothing that can write authoritative state. """ from fastapi import Body, Depends, HTTPException from sqlalchemy.orm import Session from ... import models from ...database import get_db from ...media import packet as scene_packet from ...media import profiles as visual_profiles from .deps import current_adventure, router @router.get("/{adventure_id}/visual-profiles") def list_visual_profiles( db: Session = Depends(get_db), adventure: models.Adventure = Depends(current_adventure), ): """Every visual profile in the campaign, by entity key. Campaign-scoped rather than scoped to the story being read, because that is what a profile is: a character does not change appearance when the story forks, so there is no position for this list to be relative to. """ return { "profiles": [ {"entity_key": row.entity_key, **visual_profiles.as_dict(row)} for row in visual_profiles.all_for(db, adventure) ], } @router.put("/{adventure_id}/visual-profiles/{entity_key}") def set_visual_profile( entity_key: str, payload: dict = Body(...), db: Session = Depends(get_db), adventure: models.Adventure = Depends(current_adventure), ): """Records how one entity looks. Replaces any existing profile. A `PUT` rather than a `PATCH`, and the whole profile rather than a delta, for the reason `profiles.set_profile` gives: merging would make a descriptor impossible to remove. The entity must exist in the campaign's state at the active head. A 400 for a name nobody has is better than a row describing nobody, which would then be invisible until a future depiction quietly ignored it. """ try: row = visual_profiles.set_profile( db, adventure, entity_key, descriptors=payload.get("descriptors"), features=payload.get("features"), style_notes=payload.get("style_notes"), ) except visual_profiles.ProfileError as exc: raise HTTPException(400, str(exc)) from exc db.commit() db.refresh(row) return {"entity_key": row.entity_key, **visual_profiles.as_dict(row)} @router.get("/{adventure_id}/visual-profiles/{entity_key}") def read_visual_profile( entity_key: str, db: Session = Depends(get_db), adventure: models.Adventure = Depends(current_adventure), ): row = visual_profiles.get_profile(db, adventure, entity_key) if row is None: raise HTTPException(404, f"No visual profile for {entity_key!r}.") return {"entity_key": row.entity_key, **visual_profiles.as_dict(row)} @router.delete("/{adventure_id}/visual-profiles/{entity_key}", status_code=204) def delete_visual_profile( entity_key: str, db: Session = Depends(get_db), adventure: models.Adventure = Depends(current_adventure), ): """Removes a description. Never the entity, which lives in the state.""" if not visual_profiles.delete_profile(db, adventure, entity_key): raise HTTPException(404, f"No visual profile for {entity_key!r}.") db.commit() @router.get("/{adventure_id}/scene-packet") def read_scene_packet( start: int | None = None, end: int | None = None, db: Session = Depends(get_db), adventure: models.Adventure = Depends(current_adventure), ): """What a future media provider would be given for the current scene. `start` and `end` are depths on the active branch, and both are optional: omitted, the packet describes the scene at the position the story last set one. Passing a range is what a future video request would do โ€” a scene is not assumed to be one turn (`MEDIA-EXTENSION-CONTRACT.md` ยง30-31). Generates nothing and contacts nothing. There is no provider to send it to. """ return scene_packet.build(db, adventure, start=start, end=end)