# Event System > **Status**: Revised — blocking fixes applied (Pillar placeholder defined; Node System dependency updated to Approved; OQ1 resolved) > **Author**: SepComet > **Last Updated**: 2026-04-30 > **Implements Pillar**: Narrative surprise and meaningful stakes — events break combat rhythm, adding story variety and meaningful choices ## Overview The Event System is a **deterministic narrative decision service** that presents the player with branching choices at Event nodes during a run. It reads event definitions from `DREvent` data table, selects the active event via `EventNodeComponent.SelectActiveEvent()` using a seed derived from `RunNodeExecutionContext`, and executes the player's chosen option through `EventOptionExecutor`. Each event offers one or more options, each containing: requirements (e.g., gold threshold, component count), cost effects applied before a probability roll, and reward effects applied only if the roll succeeds. All randomness uses seeded `System.Random` derived from `runSeed + sequenceIndex + nodeId + eventId + optionIndex + effectIndex + salt`, guaranteeing run reproducibility. Events are data-driven via JSON option payloads in `DREvent.OptionsRaw`, enabling new events to be authored without code changes. ## Player Fantasy **"Every choice carves a different path."** The Event System delivers the fantasy of **narrative surprise and meaningful stakes**. The player enters an Event node knowing only that something unexpected awaits — they may gain a windfall, suffer a setback, or face a gamble where the odds are unclear. Events break the mechanical rhythm of combat and shop, adding the texture of a story that unfolds differently every run. Each option carries a clear cost and an uncertain reward — the player must read the situation, weigh their resources, and commit. The player should feel: - **Curiosity and tension** — what will this event be? Events are the primary source of narrative variety between combat nodes - **Genuine dilemma** — options feel meaningfully different, not obviously right or wrong - **Risk awareness** — probabilistic options feel like a gamble, not a guaranteed upgrade - **Story authorship** — the accumulated events of a run become a story the player tells about what happened to them **Reference**: Slay the Spire's event philosophy — events are never pure upside, rewards require trade-offs, and the best path through a run is never obvious. ## Detailed Design ### Core Rules **ER1 — Event Selection (Deterministic)** When the player enters an Event node, `EventNodeComponent.SelectActiveEvent()` picks one event from the `DREvent` data table using a seed derived from `RunNodeExecutionContext`: `seed = runSeed + sequenceIndex + nodeId`. The same run seed, node ID, and sequence index always produce the same event — run reproducibility is guaranteed. If no context is available (null), a random Unity `Random.Range` selection is used (non-reproducible, only for dev/fallback). **ER2 — Option Availability Evaluation** Before displaying options, `EventOptionExecutor.EvaluateOption()` checks every option against the player's `BackpackInventoryData` snapshot. Three requirement types exist: - `GoldAtLeast(count)`: player gold ≥ count - `CompCountAtLeast(count, rarity)`: at least count loose (unassembled) components of specified rarity - `TowerCountAtLeast(count)`: at least count assembled towers Options with unmet requirements are shown as **Blocked** with a reason string (e.g., "需要至少 100 金币"). Blocked options cannot be selected but remain visible. **ER3 — Option Execution Flow** When the player selects an option, `EventOptionExecutor.Execute()` runs in three steps: 1. **Cost Effects applied immediately** — all effects in `costEffects[]` execute against a working inventory copy (gold deducted, components removed, tower endurance reduced) 2. **Probability Roll** — `RollProbability()` uses seeded `System.Random`: `seed = runSeed + sequenceIndex + eventId + optionIndex + 0 + salt(17)`. If `random.NextDouble() ≤ probability`, roll succeeds 3. **Reward Effects applied on success only** — effects in `rewardEffects[]` execute if the roll succeeded, otherwise nothing is added **ER4 — Effect Types** Four effect types exist: - `AddGold(count)`: adjusts working gold by `count` (positive = gain, negative = cost). Throws if gold would go negative - `RemoveRandomComps(count, rarity)`: removes `count` random loose components of specified rarity from inventory, using a seeded shuffle - `AddRandomComps(count, minRarity, maxRarity)`: calls `InventoryGenerationComponent.BuildEventRewardComponents()` to generate `count` components in the rarity range, seeded by context - `DamageRandomTowersEndurance(count, amount)`: reduces endurance of `count` random assembled towers by `amount`, using `InventoryTowerEnduranceUtility.ReduceTowerEndurance()` **ER5 — Inventory Working Copy & Commit** All evaluation and execution uses a `BackpackInventoryData` snapshot (`GameEntry.PlayerInventory.GetInventorySnapshot()`). On `EventOptionExecutionResult.Accepted`, `GameEntry.PlayerInventory.ReplaceInventorySnapshot(workingInventory)` commits changes to the real inventory. The event form closes and `NodeCompleteEventArgs` fires. **ER6 — Determinism Guarantees** Every random operation — event selection, component shuffle, probability roll, component generation — uses `System.Random` seeded from the run context. The seed chain is: - Event selection: `runSeed * 31 + sequenceIndex * 31 + nodeId` - Probability roll: `runSeed + sequenceIndex + eventId + optionIndex + 0 + salt(17)` - Effect random: `runSeed + sequenceIndex + eventId + optionIndex + effectIndex + salt` ### States and Transitions The Event System operates across two layers: the **node component** (managing lifecycle) and the **UI form** (managing player interaction). | State | Owner | Description | |-------|-------|-------------| | **Idle** | EventNodeComponent | No event active. Component initialized, data table loaded. | | **EventActive** | EventNodeComponent | Event node is running. Context is set, event is selected, form is open. | | **FormDisplayed** | EventFormUseCase | Event form is open, options are evaluated and shown. Player is browsing. | | **Executing** | EventFormUseCase | Player has selected an option. Cost effects applied, probability rolled, rewards applied or skipped. | | **FormClosed** | EventFormUseCase | Option execution complete. Form is closed. | **Transitions:** | From | To | Trigger | |------|----|---------| | Idle | EventActive | Node System triggers `StartEvent(RunNodeExecutionContext)` | | EventActive | FormDisplayed | `EventFormUseCase.BindEvent()` + `OpenUI(EventForm)` completes | | FormDisplayed | Executing | Player clicks a selectable option; `TrySelectOption(optionIndex)` is called | | Executing | FormClosed | Execution result returned; `EndEvent()` called, `CloseUI(EventForm)` | | FormClosed | Idle | `ClearActiveNodeContext()` resets component state | There are **no player-accessible states** — the player only ever sees FormDisplayed (browsing options). The Executing state is transient — cost effects, roll, and reward effects execute synchronously before the form closes. ### Interactions with Other Systems **Upstream — Node System** - **Receives**: `StartEvent(RunNodeExecutionContext)` trigger from Node System when player navigates to an Event node - **Provides**: Fires `NodeCompleteEventArgs` on event end, with inventory snapshot - **Interface owner**: Node System **Upstream — PlayerInventoryComponent** - **Receives**: `GetInventorySnapshot()` to get current gold, components, towers before evaluating options - **Receives**: `ReplaceInventorySnapshot(workingInventory)` to commit changes after option execution - **Provides**: Working inventory state for requirement checks and effect application - **Interface owner**: Event System (consumer) **Upstream — InventoryGenerationComponent** - **Receives**: `BuildEventRewardComponents(count, minRarity, maxRarity, runSeed, sequenceIndex, eventId, optionIndex, effectIndex)` for `AddRandomComps` effect type - **Provides**: Component generation service for event rewards - **Interface owner**: Event System (consumer) **Downstream — UI (EventForm)** - **Receives**: `EventFormRawData` (event title, description, option items with availability status) via `CreateInitialModel()` - **Receives**: `TrySelectOption(optionIndex)` call when player clicks an option - **Provides**: Opens/closes `UIFormType.EventForm` - **Interface owner**: Event System (provider) ## Formulas **F1 — Probability Roll** ``` success = (random.NextDouble() <= probability) where random is seeded with: seed = runSeed + sequenceIndex + eventId + optionIndex + 0 + 17 ``` **F2 — Event Selection Seed** ``` seed = (((runSeed * 31) + sequenceIndex) * 31) + nodeId ``` Used by `EventNodeComponent.BuildSelectionSeed()`. `*31` is a standard hash-combining technique. **F3 — Effect Random Seed** ``` seed = runSeed + sequenceIndex + eventId + optionIndex + effectIndex + salt ``` Salt values vary by effect type (e.g., 101 for component shuffle, 211 for tower endurance damage). This ensures each random operation within an event option is independently reproducible. **F4 — Gold Effect (AddGold)** ``` workingInventory.Gold = workingInventory.Gold + count ``` No internal cap. The 9999 `MaxPlayerGold` cap is applied by `PlayerInventoryComponent` on commit, not by the Event System. ## Edge Cases **EC1 — No Event Data Loaded** If `GameEntry.DataTable.GetDataTable()` returns null on `OnInit()`, the component logs a warning and sets `_initialized = true`. Any subsequent `StartEvent()` call logs a warning and returns early without opening the form. **EC2 — No Events in Data Table** If `_eventItems.Count <= 0` when `StartEvent()` is called, the same early-return warning path is taken. **EC3 — Requirements Met by Exact Count** `CompCountAtLeast(count, rarity)` and `TowerCountAtLeast(count)` use `>=` comparison. A player with exactly `count` components/towers satisfies the requirement. **EC4 — RemoveRandomComps — Insufficient Candidates** If `CollectLooseComponents()` returns fewer components than `removeCount`, `ApplyRemoveRandomComponentsEffect()` throws `InvalidOperationException`. This indicates a data-authoring error — the requirement check should prevent this path at runtime. **EC5 — AddRandomComps — No InventoryGenerationComponent** If `GameEntry.InventoryGeneration == null` when `AddRandomComps` is applied, `ApplyAddRandomComponentsEffect()` throws `InvalidOperationException`. Event authors must not use `AddRandomComps` in an environment where `InventoryGenerationComponent` is absent. **EC6 — DamageRandomTowersEndurance — No Towers or Count ≤ 0** `ApplyDamageRandomTowerEnduranceEffect()` silently returns if `towerCount <= 0`, `enduranceLoss <= 0`, or there are no assembled towers. No exception is thrown — the effect is simply a no-op. **EC7 — Probability = 0 (Guaranteed Failure)** `RollProbability()` returns `false` immediately for `probability <= 0`. Cost effects are still applied. The player pays the cost but always receives no reward. **EC8 — Probability = 1 (Guaranteed Success)** `RollProbability()` returns `true` immediately for `probability >= 1`. Reward effects are always applied. **EC9 — Gold Would Go Negative from AddGold Cost** `ApplyAddGoldEffect()` throws `InvalidOperationException` if `workingInventory.Gold + count < 0`. This is prevented by the `GoldAtLeast` requirement on options that spend gold. **EC10 — Component Instance Not Found on Removal** `RemoveComponentByInstanceId()` throws `InvalidOperationException` if the component instance is not found in the list. This should not occur given the CollectLooseComponents → Shuffle → Remove flow. **EC11 — Duplicate Option Indices** If `TrySelectOption()` receives an out-of-range `optionIndex`, it returns `false` and logs a warning. The form remains open. ## Dependencies **Inherited from other systems:** | Entity | Value | Source | |--------|-------|--------| | `MaxPlayerGold` | 9999 | `design/gdd/shop.md` — applied on `PlayerInventoryComponent.ReplaceInventorySnapshot()` commit | **Upstream Dependencies:** | System | Status | Interface Contract | |--------|--------|-------------------| | Node System | **Approved** (`design/gdd/node-system.md`) | Fires `StartEvent(context)`, receives `NodeCompleteEventArgs` | | PlayerInventoryComponent | Code only | `GetInventorySnapshot()` / `ReplaceInventorySnapshot()` | | InventoryGenerationComponent | Code only | `BuildEventRewardComponents()` | | DataTable (DREvent) | Code only | Event definitions with JSON `OptionsRaw` | **Downstream Dependents:** | System | Status | Interface Contract | |--------|--------|-------------------| | UI (EventForm) | Code only | `EventFormRawData`, `TrySelectOption()` | ## Tuning Knobs **TK1 — Event Authoring (Data Table)** All event tuning lives in `Assets/GameMain/DataTables/Event.txt`. Adding a new event requires a new DREvent row with a unique ID, title, description, and JSON option payloads. No code changes needed. Tunable per event: - `probability` value per option (0.0–1.0) — changes success odds - `costEffects` and `rewardEffects` JSON — changes what the option costs and rewards - `requirements` JSON — changes entry threshold **TK2 — New Requirement Types** Adding a new requirement type (e.g., `TowerLevelCountAtLeast`) requires: 1. New `EventRequirementType` enum value in `EventRequirementType.cs` 2. `EventRequirementFactory.Create()` branch 3. `IsRequirementSatisfied()` branch in `EventOptionExecutor` 4. `BuildBlockedReason()` branch 5. New JSON type in `Event.txt` **TK3 — New Effect Types** Adding a new effect type (e.g., `ReduceGold`) requires: 1. New `EventEffectType` enum value in `EventEffectType.cs` 2. `EventEffectFactory.Create()` branch 3. `ApplyEffects()` switch branch in `EventOptionExecutor` 4. New JSON type in `Event.txt` **TK4 — Event Selection Variety** The number of events in `DREvent` data table directly controls event variety. More events = more entropy in event selection per node. ## Visual/Audio Requirements **V1 — Event Node Entry** When `StartEvent()` is called, the Node System handles scene/camera transition and fires `NodeEnterEventArgs`. Event System itself has no standalone VFX. **V2 — Event Form Appearance** When `EventForm` opens: - Event title and description displayed prominently - Options shown as cards with: option text, requirement status (Selectable / Blocked with reason) - No VFX beyond standard UI hover/select feedback **V3 — Option Selection (Success Path)** When `EventOptionExecutionResult.Accepted(isProbabilitySuccess=true)`: - Standard UI close animation - `NodeCompleteEventArgs` fires — Node System handles success feedback - Inventory changes are silent (gold/component changes appear in the HUD on next frame) **V4 — Option Selection (Failure Path)** When `EventOptionExecutionResult.Accepted(isProbabilitySuccess=false)`: - Cost was deducted at execution time (gold already gone from working inventory) - UI shows brief "失败" indicator before form closes (~0.5s) **V5 — Blocked Options** Options with unmet requirements show blocked reason text (e.g., "需要至少 100 金币") in red/disabled style. No special audio. **V6 — Probabilistic Feel** Probability values in `Event.txt` are designer-facing only. The UI must not reveal exact odds — events should feel like genuine gambles. The 70% and 30% options in 赌马 must not visually differ in probability signaling. ## UI Requirements **U1 — Event Form Layout** - Single form: event title (large), description (medium), option list (vertical, scrollable if > 4 options) - Each option card shows: option text, availability state (normal / grayed-out with reason) - No explicit probability display on cards **U2 — Option Card Anatomy** - Option text (primary label) — should imply the cost/action - Availability state: "可选择" (normal) or blocked reason text (disabled) - No price/cost field — the option text is the cost communication **U3 — Gold Display** - Player's current gold visible in standard HUD during event - Gold changes reflected in HUD after event closes **U4 — Accessibility** - All option text readable by screen readers - Blocked reason must use both color AND text label (not color alone) ## Acceptance Criteria **AC1 — Event Selection Determinism** - Given: a run with `runSeed=12345`, `sequenceIndex=3`, `nodeId=7` - When: the player enters the Event node twice with the same context - Then: the same event is selected both times **AC2 — Option Availability — Gold Requirement Not Met** - Given: player has 50 gold, an option has requirement `GoldAtLeast(100)` - When: `EventOptionExecutor.EvaluateOption()` is called - Then: `EventOptionAvailability.IsSelectable == false` with reason "需要至少 100 金币" **AC3 — Option Availability — Gold Requirement Met** - Given: player has 150 gold, an option has requirement `GoldAtLeast(100)` - When: `EvaluateOption()` is called - Then: `EventOptionAvailability.IsSelectable == true` **AC4 — Cost Effects Deducted on Selection** - Given: player has 200 gold, selects the "下注 100 金币" option of 赌马 - When: `Execute()` is called - Then: `workingInventory.Gold == 100` after cost effects **AC5 — Reward Effects Applied on Success** - Given: player has 200 gold, selects the 70% option, roll succeeds - When: `Execute()` returns `EventOptionExecutionResult.Accepted(true)` - Then: `workingInventory.Gold == 250` (cost deducted + reward applied) **AC6 — Reward Effects Skipped on Failure** - Given: player has 200 gold, selects the 70% option, roll fails - When: `Execute()` returns `EventOptionExecutionResult.Accepted(false)` - Then: `workingInventory.Gold == 100` (cost deducted, no reward) **AC7 — Probability Roll Reproducibility** - Given: same context (runSeed, sequenceIndex, eventId, optionIndex), run twice - When: `RollProbability()` is called both times - Then: both calls return the same result **AC8 — RemoveRandomComps Requirement** - Given: player has 2 loose white components, option has `CompCountAtLeast(2, White)` - When: `EvaluateOption()` is called - Then: `IsSelectable == true` **AC9 — Tower Damage Effect** - Given: player has assembled towers, selects the "代价与回报" option - When: `Execute()` completes - Then: at least one tower's endurance is reduced by 20 **AC10 — Event Form Closes After Selection** - Given: player selects any selectable option - When: `TrySelectOption()` returns true - Then: `GameEntry.UIRouter.CloseUI(UIFormType.EventForm)` is called ## Open Questions **OQ1 — Event Frequency in Run** **Status**: RESOLVED — Per the approved Node System GDD, the Plain theme places exactly one Event node at position 6. Events may only occupy positions 4–8 (future themes may vary). The Event System is not dead code — exactly 1 Event node appears per run in the Plain theme. **OQ2 — Player-Driven Event Avoidance** Can the player choose to skip or avoid Event nodes? Currently there is no reroll or skip mechanic. In Slay the Spire, events are often optional (path away). Are Event nodes mandatory stops or optional detours? **OQ3 — Purely Narrative Events** Current events all have mechanical trade-offs. Should purely narrative events exist (e.g., "a traveler tells you a story — nothing happens") for flavor, or should every event always have mechanical stakes? **OQ4 — Player Influence Over Event Selection** Currently event selection uses `runSeed + sequenceIndex + nodeId` — deterministic but player has no agency. An alternative: add a choice layer ("you see two merchants — choose one"). Is this desirable? **OQ5 — Event Component Rarity Budget** `AddRandomComps` uses `InventoryGenerationComponent` with the same rarity budget as shop/inventory generation. Should event rewards have a separate rarity budget (e.g., events always drop one tier lower than shop)?