# Event reasons (camera + orange dossier) ## A/B layers | Layer | What | Gate | |-------|------|------| | **B ticker** | Activity / Life / chronicle prose for live ids | Always may write when the game fires | | **A story camera** | Interest candidates, orange reason, camera cuts | `EventCatalog.IsCameraWorthy` only | Story commitment (climax → aftermath → epilogue) is owned by [`StoryPlanner`](story-planner.md); orange aftermath Labels come from `StoryReason` and must stay subject-led for presentability. Happiness: `Presentation.Signal` = A; `Ambient` / `Aggregate` = B-only. Discrete / status / WorldLog: `CreatesInterest` = A gate. `InterestDirector` applies liveness and presentability gates to A candidates; the story scheduler chooses episode developments before bounded ambient/world fallback. Neither authors inventory. Character fill runs only when the pending **EventLed** queue is empty, and uses an empty Label (task chip only). Orange dossier reason = the owning A event’s semantic `NarrativePresentationModel` while the scene is active (`InterestDirector.TryGetOwnedReasonCandidate`). `Label` remains the presentability and compatibility input, but `NarrativeRenderer` owns the final Beat. `CaptionComposer` never appends a generic relationship suffix. Identity remains a separate line (`Name · King of X`). Identity titles and stake slogans never enter `InterestCandidate.Label`; `identity_filter` / `IsIdentityWhy` remain selection safety gates. The focused unit must be a tip principal: - Life / discrete tips: SubjectId + RelatedId (Follow alone is not enough after a handoff). - Pair-locked duels: PairOwner/PairPartner only. - Mass/war/plot/family/outbreak: sticky roster members OK. Dossier identity also rejects subject-led Labels that name another living unit (e.g. Hillih must not show “Iksssssa shares intimacy…”). Quiet grace / inactive dwell clears the reason even if focus has not switched yet. ## Activity History peek freshness Layer B ring stays append-only (Lore Activity = full journal). Dossier History peek uses `ActivityRelevance` / `ActivityLog.LatestRelevantForSubject`: - Soft window (~8s): newest beats always show. - After that: same verb/family as live task, or family live predicates (combat ↔ attack target / in_combat, haul ↔ carrying, status ↔ still has/lost status). - Milestones always peek-worthy. - Peek refreshes when live AI fingerprint changes (task, attack target, carrying), not only when the ring grows. Drops (ambient, createsInterest=false, identity filter, below margin, …) go to `InterestDropLog`. ## Live ensembles (escalating multi-actor) Combat is the first consumer of `LiveEnsemble` ([`Events/LiveEnsemble.cs`](../IdleSpectator/Events/LiveEnsemble.cs)). Sticky scoreboard state lives on `InterestCandidate.Sticky` (`LiveSceneStickyState`); `StickyScoreboard` owns lock / enroll / refresh / scale-hold / follow-promote. The director only decides camera + completion policy and calls those helpers while `CombatActive` stays hot. | Scale | Fighters | Typical reason | |-------|----------|----------------| | Pair | ≤2 | `Duel - Rosado vs Waaf` | | Skirmish / Battle | 3+ | `Battle - Essiona (10) vs Northreach (3)` or `Battle - humans (10) vs wolves (3)` | | Mass | ≥8 (`massCrowdThreshold`) | `Mass - humans (12) vs wolves (8)` | Optional kingdom brackets on species sides: `Battle - [Essiona] humans (10) vs wolves (3)`. Side framing priority: kingdom vs kingdom → species vs species → count-only melee. Never use unit proper names as mass-side labels (no `Battle - Waf (6) vs Eaderbert (1)`). Same-species camps with no opposing sticky keys use a single pack line (`Mass - Wolves (n)`). Sticky opposing camps lock once and enroll member ids while they fight. Counts stay on those tracked members while alive (combat or not) until combat grace clears the scene - so a wiped side can hit `0` while survivors remain counted. New combatants matching a sticky key are enrolled; dead ids are pruned. Orphan pair tips use `{name} is fighting` instead of `Duel - Name` with no foe. Names are humanized (no raw `nomads_human` ids). Scale hold (~2.5s peak hysteresis on `Sticky.ScaleDropSince`) keeps Mass/Battle from flapping to Duel on brief count dips. Combat camera hysteresis only applies between living fighters - sleepers/bystanders never stick. ### Module layout (sticky) | Piece | Owns | |-------|------| | `LiveEnsemble` | Kind/scale/frame snapshot + combat (and later war/plot) **builders** | | `LiveSceneStickyState` | Durable side keys, member id rosters, peak/scale-hold | | `StickyScoreboard` | Lock sides, enroll, refresh counts, promote follow, format reason | | `InterestDirector` | Camera maintain, completion, when to call scoreboard | | `EventReason.Ensemble` / `.Combat` / `.WarFront` / `.PlotCell` / `.FamilyPack` / `.StatusOutbreak` | Orange prose from a snapshot | Compat: `InterestCandidate.CombatSide*` forwarders still work for harness / older call sites. ### Discovery: next sticky consumers (game reflection) Verified against `Assembly-CSharp` multi-actor containers that already expose rosters / opposing sides: | Priority | Kind | Live game surface | Why sticky fits | Fit notes | |----------|------|-------------------|-----------------|-----------| | done | **WarFront** | `War.main_attacker` / `main_defender`, kingdom pop | Natural A/B camps | Implemented (`War - A (n) vs B (m)`) | | done | **PlotCell** | `Plot.units` (2+), `target_kingdom` / city / actor | Plotters vs target kingdom | Implemented (`Plot - Plotters (n) vs A (m)`; solo → unit Plot tip) | | done | **FamilyPack** | `Family.units`, `getAlpha` / `setAlpha` | Pack identity + alpha handoff | Implemented (`Pack - Species (n) · gathering`) | | done | **StatusOutbreak** | Scan actors with same `StatusAsset` id in radius | Spatial cluster like combat | Implemented (`Outbreak - Cursed (n)`) | | 5 | **Army / Clan** | `Army.units` + captain; `Clan.units` + chief | Ready member lists | Usually feed WarFront / siege framing rather than a separate orange scene | | later | Alliance | `Alliance.kingdoms_hashset`, `getUnits()` | Meta-side aggregation | Prefer war sides unless alliance-vs-alliance tips are wanted | | later | DiscoveryCluster | pending first-seens near anchor | Soft cluster | Lower stakes; optional | Do **not** invent inventory from catalogs: builders must read live `World.world.wars` / `plots` / `families` / unit status like combat already does. ### Other families (same snapshot later) | Kind | Live signal | Side framing | Status | |------|-------------|--------------|--------| | Combat | attack_target / in_combat cluster | kingdom → species → camps | Done | | WarFront | `War` attackers/defenders + kingdom pop | kingdom vs kingdom (`War - A (n) vs B (m)`) | Done | | PlotCell | `Plot.units` (2+) + target kingdom | plotters vs kingdom (`Plot - Plotters (n) vs A (m)`) | Done | | FamilyPack | `Family.units` + alpha | soft species pack (`Pack - Wolves (5) · gathering`); yields to discrete unit events | Done | | StatusOutbreak | same status id in radius, **2+ carriers**; affliction/disease only (deny stun/shield/recovery_* and short combat FX via live duration) | status carriers (`Outbreak - Cursed (4)`; drowning OK as group) | Done | | DiscoveryCluster | pending first-seens near anchor | species | Optional | Roadmap + shared contract: [`sticky-ensemble-plan.md`](sticky-ensemble-plan.md). Shared pieces: `LiveEnsemble` builders, `StickyScoreboard`, `EventReason.Ensemble`. ## Semantic presentation [`IdleSpectator/Narrative/NarrativePresentation.cs`](../IdleSpectator/Narrative/NarrativePresentation.cs) defines the typed presentation contract and is the final orange-Beat voice owner. Confirmed durable events receive their exact event/development presentation from `NarrativeEventRecorder`. Ambient, ensemble, status, and world candidates receive a typed envelope at `EventFeedUtil.Register`. [`IdleSpectator/Events/EventReason.cs`](../IdleSpectator/Events/EventReason.cs) remains a catalog template/compatibility facade. Feeds may use its helpers to provide authored semantic input, but downstream code must not parse that English to discover actors, relationships, or developments. ## Events module layout | Path | Role | |------|------| | `Events/EventCatalog.cs` (+ `Catalogs/EventCatalog.*.cs`) | **One inventory** - nested domains + `IsCameraWorthy` | | `Events/Feeds/` | Build + register **A** candidates from catalog dials | | `Events/Patches/` | Harmony hooks → feeds / Activity | | `Narrative/NarrativePresentation.cs` | Typed presentation + final orange Beat rendering | | `Narrative/NarrativeEventRecorder.cs` | Stable confirmed event/development receipt | | `Events/EventReason.cs` | Catalog-authored presentation input / compatibility labels | | `Events/LiveEnsemble.cs` | Multi-actor snapshot + combat builders | | `Events/LiveSceneStickyState.cs` | Durable sticky scoreboard on a candidate | | `Events/StickyScoreboard.cs` | Lock / enroll / refresh / promote / scale-hold | | `Events/EventFeedUtil.cs` | Shared Register path | | `Events/InterestDropLog.cs` | Why a beat stayed B or missed A | | `Events/DiscreteEventEntry.cs` | Shared catalog row shape | Rules: - Sentence form with names: `{a} is fighting {b}`, `{a} is eating a beehive`, `{a} decides to change the kingdom's culture`. Decision orange reasons use the `action` field (infinitive after "decides to"). Other domains use `label` or happiness `variantsWithRelated` / `variantsWithoutRelated`. All of that - plus per-id `strength` - lives in [`event-catalog.json`](../IdleSpectator/event-catalog.json). `EventReason.Decision` only formats `{name} decides to {phrase}`. Species/character score overlays live in the same JSON; global formula stays in `scoring-model.json`. - Set `RelatedUnit` when the sentence names a second party. - Never invent stranger subjects for unit-led events. - Never use engine jargon that misstates the action. ## Removed crumbs Do not author these as orange reasons: - `In action` - bare `Duel` / `Fight (N)` without a named sentence - identity titles (`King of X`, `Leader of X`, `Lone beetle`) - `Consumes a building` / `Damages a building: {a}` crumb form ## Strength dial (Layer A only) `CreatesInterest` / Signal gates whether an id registers for the camera; `EventStrength` ranks among A. Instant cut-in when `next.TotalScore >= current + cutInMargin` (sticky scenes use `stickyCutInMargin`). See `scoring-model.json`. ## Dossier display `UnitDossier.EventLabelBeat` keeps the owned reason presentable; `CaptionComposer` prefers the candidate's typed presentation and exact development id. It rejects identity/weak crumbs and scrubs living stranger names (`ReasonNamesStranger` / `LeadsWithForeignName`). Active EventLed A scenes should not silently blank a valid Label. `EventPresentability.WouldShow` is the shared gate so unpresentable Labels never win Watching. `narrative_presentation_coverage` is the registry/rendering gate: structured intake, no compatibility lookup, and no semantic redundancy. ## See also - [`camera-presentability-plan.md`](camera-presentability-plan.md) - selection = presentation; fall class demotion - [`event-e2e.md`](event-e2e.md) - catalog inject + live outcome suite - [`event-observation.md`](event-observation.md) - observe → confirm → emit (Beh Continue is not success) - [`event-audit.md`](event-audit.md) - sources, MaxWatch, A demotion set - [`event-prose-audit.md`](event-prose-audit.md) - prose vs game call sites - [`scoring-model.md`](scoring-model.md) - score weights