worldbox-observer-mod/docs/event-reason.md
DazedAnon 9a059c17dd fix(spectator): cool soft life crumbs and keep aftermath victim truth
- Soft-cool kill, courtship, intent, construction, status fx, hatch, rest, and worldlog meta
- Block cooled tips from resuming after a cut-in
- Name only dead theater partners in stands-over aftermath
- Add harness gates through 0.28.54
2026-07-21 16:45:12 -05:00

10 KiB
Raw Blame History

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; 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 only ranks A candidates. It does not author 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 events Label while the scene is active (InterestDirector.TryGetOwnedReasonCandidate). 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). 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.

Shared pieces: LiveEnsemble builders, StickyScoreboard, EventReason.Ensemble.

EventReason

IdleSpectator/Events/EventReason.cs is the sole Label factory for camera candidates. It lives in the Events module with catalogs (not in the director). Feeds call typed helpers (Fight, BuildingEat, SeekingLover, …) or Apply(template, a, b, id).

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
Events/EventReason.cs Orange reason sentences
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. 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 EventReason / Name verb … sentences. 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.

See also