worldbox-observer-mod/docs/camera-presentability-plan.md
2026-07-16 20:46:22 -05:00

232 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 05 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. Optional: lightweight Player.log auditor script or harness probe counting `identity_filter` after Watching tips in a timed idle soak.
## 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 AD)
## 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