# Camera presentability plan Goal: IdleSpectator never starts **Watching** on a beat the orange dossier would blank or refuse. Ambient object-falls stop competing with real story (combat, birth, plot, kingdom) without chasing per-asset denylists. This plan follows live Player.log audits after event-live verification (Phases 0–5 done). Coverage of *firing* is largely solved. Idle *quality* fails on ranking / ownership mismatch, not missing catalogs. ## Problem (from live idle) | Symptom | Root | |---------|------| | ~60% watches are vegetation / object falls | Building destroy feed registers A at high strength | | Tip → `identity_filter` → `max_cap` loop | Selection ≠ presentation: `"A Maple Tree falls near X"` fails dossier identity rules | | Combat sticky via `below_margin` while falls cut in | Falls outrank or interrupt poorly; sticky fights do not lose focus cleanly | | `no_focus_while_idle` BAD after `max_cap` / `follow_lost` / `quiet_grace` | Focus hold across scene end is broken | | Tip `"Watching: something interesting"` | Empty Label placeholder in `CameraDirector.FormatWatchTip` | ## Principles (do not violate) 1. **Selection equals presentation.** If `UnitDossier.EventLabelBeat` / scene identity rules would empty the reason for the follow subject, that candidate must not win Watching. 2. **Policy overlays classes, not asset ids.** Demote "ambient building/object fall" as a feed family or WorldLog shape predicate. Do not maintain Maple / Poop / Mineral id lists. 3. **Game remains source of truth for inventory.** Authored dials set strength / CreatesInterest / watch windows. They do not invent which buildings exist. 4. **Layer B stays honest.** Falls may still hit Activity / Life when useful. Demotion means "not camera A," not "erase from the world." 5. **One invariant over many patches.** Prefer a shared presentability gate at register / rank time over scattered feed hacks. ## Architecture target ``` observe → confirm → emit Label ↓ presentability gate (same rules as UnitDossier) ↓ CreatesInterest / strength policy (class overlay) ↓ InterestDirector rank → Watching ↓ dossier shows the same Label (or scene ends) ``` Drop reason for the new gate: `unpresentable` (or reuse `identity_filter` with a clear prefix) in `InterestDropLog`. ## Phase A - Presentability gate (structural, do first) **Invariant:** unpresentable beats never become the active EventLed scene. ### A1. Shared check Extract a single helper used by both dossier and director, e.g. `EventPresentability.WouldShow(Actor subject, string label)` (or static on `UnitDossier`). It must mirror: - identity / weak flavor rejection - `LeadsWithForeignName` / stranger scrub for the follow subject - empty / placeholder Labels Do **not** fork a second, looser copy of the rules. ### A2. Gate at publish or rank Preferred order: 1. Soft reject in `EventFeedUtil.Register` when Label fails for `follow` (record drop, return null) - catches most unit-led noise early. 2. Hard reject in `InterestDirector` before adopting a candidate (defense in depth for location-only / race cases). Location-only falls without a subject: either fail presentability by policy, or never register as A (Phase B). ### A3. Harness Add focused asserts (new scenario or nest in existing director smoke): | Case | Expect | |------|--------| | Register Label that dossier blanks for follow | no Watching; drop logged | | Register subject-led presentable Label | Watching + orange reason match Label | | Inject / live path that previously `identity_filter` after cut | no cut-in | Exit: idle session no longer shows Watching → immediate `identity_filter` on the same tip as the dominant pattern. Gate: `critical_smoke` + a dedicated presentability scenario ×3. ## Phase B - Demote ambient fall class (policy overlay) **Class:** unit-adjacent or location-only **building/object destroy** ambient (trees, plants, minerals, mushrooms, poop, etc.). Not in scope for demotion: intentional spectacle consumes (`building_consume` / eat when an actor is clearly the subject and Label is subject-led), war siege set-pieces if we later mark them Signal. ### B1. Predicate, not id list In `BuildingEventPatches.PostfixStartDestroy` (and any WorldLog twin if it feeds the same shape): - Default destroy → Layer B only (`CreatesInterest=false` / skip `EventFeedUtil.Register`, still Activity if desired). - Or register A only when a **strong subject-led** story exists (actor is clearly damaging/consuming, not "nearest unit to a falling bush"). Nearest-unit attach for ambient falls is the bug pattern. Prefer: no follow invent from proximity for A. ### B2. Catalog dials If WorldLog building-destroy ids are camera today, set class default `camera: false` / low strength via discovery overlay rules - still not per maple id. Update `docs/event-audit.md` Layer A noise notes when done. ### B3. Harness | Case | Expect | |------|--------| | Destroy ambient vegetation near a unit | no A cut; optional B log | | `BehConsumeTargetBuilding` with living eater | still A, subject-led Label | | Building destroy pipeline in `event_live_pipelines` | `id_drop` or B-only as authored; not FAIL | Exit: live idle fall watch share collapses; combat/birth/plot reclaim camera time. ## Phase C - Ban empty Watching tip ### C1. Product rule Never show `Watching: something interesting`. Options (pick one, keep simple): - Refuse to start Watching without a non-empty presentable Label. - Or tip omits reason / uses task chip only when Label empty (Character fill already uses empty Label intentionally - do not use FormatWatchTip placeholder there). Remove or guard the fallback in `CameraDirector.FormatWatchTip`. ### C2. Harness Assert tip never equals / contains the placeholder string during idle EventLed scenes. ## Phase D - Focus hold across scene end Separate from fall demotion; required for idle quality. ### D1. Reproduce `no_focus_while_idle` after `max_cap`, `follow_lost`, `quiet_grace`. Reproduce closest to end-user idle (spectate, wait for scene churn) before patching. ### D2. Fix When an EventLed scene ends and Character fill / next candidate should continue idle: - Keep or immediately reacquire a living focus unit. - Do not clear follow in a way that flashes the power bar or trips BAD. - Every director tick: if Idle is on and camera has no living focus, reattach current follow/related or `EnsureIdleFocus` (do not wait for quiet_grace). Document intended end-of-scene focus ownership in `event-audit.md` interrupt/hold section. ### D3. Harness Assert: during SpectatorMode, after forced `max_cap` / quiet_grace end, `MoveCamera.hasFocusUnit()` remains true (or is restored within one director tick) unless world has no living units. ## Phase E - Optional Label rework (only if we want some falls as A) Only after A+B. Rewrite `EventReason.BuildingFalls` to subject-led form if product wants rare A falls: - Bad: `A Maple Tree falls near Boh` (foreign-led / identity_filter) - Better: `Boh is near a falling maple tree` (only if Phase B still allows that class as A) Do not use Label rewrite as a substitute for the gate or class demotion. ## Phase F - Docs + idle quality gate 1. Cross-link from `event-reason.md` and `event-audit.md`. 2. Add a short "idle quality" checklist to `event-e2e.md`: - no Watching → identity_filter churn on same tip - no placeholder tip - no `no_focus_while_idle` BAD spike in a quiet idle window - fall class not dominating watches 3. Player.log soak auditor: `./scripts/soak-audit-player-log.sh [seconds]` (also `--since-bytes N`). ## Implementation status | Phase | Status | |-------|--------| | A Presentability gate | Done - `EventPresentability` + Register/SelectNext/SwitchTo; harness bypass for synthetic tips | | B Fall class demotion | Done - `Building.startDestroyBuilding` is B-only; consume stays A | | C Placeholder tip ban | Done - `FormatWatchTip` returns `Watching` when Label empty | | D Focus hold | Done - `MaintainCameraFocus` every tick; orphan scenes end without quiet_grace gap | | E Label rewrite | Done early - `BuildingFalls` subject-led (for residual / Activity prose) | | F Docs + idle quality gate | Done - `camera_presentability` in regression; docs synced | ## Order of work | Order | Phase | Why first | |------:|-------|-----------| | 1 | A Presentability gate | Fixes whole contradiction class forever | | 2 | B Fall class demotion | Stops ambient spam without id lists | | 3 | C Placeholder tip ban | Cheap, visible polish; blocks empty A | | 4 | D Focus hold | Fixes BAD / churn; independent root cause | | 5 | E Label rewrite | Optional; only if some falls stay A | | 6 | F Docs / soak gate | Locks the bar | Do not start with per-id JSON demotions. Do not demote only and skip the gate. ## Non-goals - Hand-authored denylist of building asset ids - Treating inject coverage as idle-quality proof - Making every WorldLog message camera-worthy - Solving spell / biome / boat unload unsupported fires in this plan - Beh-positive `family_group_join` (separate observation work) ## Success criteria - Presentability helper is the single source of truth for "would orange show this?" - Ambient object-falls are B-by-default (class predicate) - Zero Watching cuts whose tip is immediately identity-filtered - Zero `"something interesting"` tips in EventLed Watching - Idle soak: `no_focus_while_idle` BAD count stays near zero across scene churn - Regression still green (`critical_smoke` ×3; `regression` after A–D) ## See also - [event-reason.md](event-reason.md) - A/B layers, EventReason, dossier filters - [event-audit.md](event-audit.md) - dials, interrupt / hold / grace - [event-live-verification-plan.md](event-live-verification-plan.md) - fire accuracy (done) - [event-e2e.md](event-e2e.md) - harness suite map - [event-observation.md](event-observation.md) - observe → confirm → emit