introduce ccgs

This commit is contained in:
SepComet
2026-04-30 00:09:30 +08:00
parent 2e54acbc85
commit 55506eff9a
87 changed files with 22704 additions and 0 deletions
+338
View File
@@ -0,0 +1,338 @@
# Event System
> **Status**: Designed
> **Author**: SepComet
> **Last Updated**: 2026-04-29
> **Implements Pillar**: [To be designed]
## 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<DREvent>()` 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 | Designed (`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**
How many Event nodes appear per run? The Node System GDD specifies 10 total nodes, but does not specify how many are Event nodes. If there are 0 event nodes per run, the Event System is unreachable dead code.
**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)?
@@ -0,0 +1,207 @@
# Cross-GDD Review Report
> **Date**: 2026-04-29
> **Reviewer**: Consistency Agent + Game Design Holism Agent
> **GDDs Reviewed**: 5 (node-system.md, shop.md, tower-assembly.md, event-system.md, progression.md)
> **Systems Covered**: Node System, Shop System, Tower Assembly, Event System, Progression
---
## Consistency Issues
### Blocking (must resolve before architecture)
🔴 **[C1] Rule contradiction: "no partial rewards on loss" vs. Progression accepting gold on loss**
- **Documents**: `node-system.md` vs `progression.md`
- **Node System** (Edge Cases): "Any combat loss: Run ends in failure immediately. No partial rewards are awarded."
- **Progression** (SR2 + AC): `RecordRunEnd()` is called on every run end (win or loss). On loss: `totalRunsStarted++`, `totalGoldEarned += goldEarned`, `furthestNodeReached` updates, but NO unlock evaluation.
- **Contradiction**: "No partial rewards" implies gold is NOT awarded/recorded on loss. Progression explicitly expects `goldEarned` on loss runs.
- **Resolution needed**: Clarify the intended design. Two options:
- (A) Loss runs do NOT record gold in LifetimeStats → remove `totalGoldEarned += goldEarned` from loss path in Progression AC
- (B) Loss runs DO record gold stats (only unlocks are withheld) → Node System's "no partial rewards" must be clarified to mean "no unlocks" not "no gold tracking"
---
🔴 **[C2] One-directional dependency: Tower Assembly ↔ Progression**
- **Documents**: `tower-assembly.md` vs `progression.md`
- **Tower Assembly** (Dependencies): "Progression — May read aggregate tower assembly stats across runs. Pending Progression GDD."
- **Progression** (Dependencies): Lists Node System and Shop System as upstream. Does NOT list Tower Assembly.
- **Impact**: If Tower Assembly wanted to persist aggregate stats (e.g., "total towers assembled lifetime"), no interface contract exists. Progression has no `RecordTowerAssemblyStats()` method.
- **Resolution needed**: Either (A) Progression adds Tower Assembly as a soft upstream dependency with a defined read interface, or (B) Tower Assembly's reference to Progression is removed/clarified as speculative.
---
### Warnings
⚠️ **[C3] Bidirectionality gap: Node System → Tower Assembly (soft vs. hard mismatch)**
- **Documents**: `node-system.md` vs `tower-assembly.md`
- Node System marks Tower Assembly as **Soft/provisional** in its Dependencies.
- Tower Assembly marks Node System as **Hard** upstream.
- This is a directionality mismatch — the softer designation should flow one way consistently.
- **Not blocking**: Tower Assembly's treatment of Node System as hard is likely correct; Node System's soft marking may be residual uncertainty from before Tower Assembly was designed.
⚠️ **[C4] Shop System GDD status in Node System is stale**
- **Documents**: `node-system.md` (Open Questions #3)
- Node System marks Shop System GDD as "blocking — interface contract pending alignment."
- However, `shop.md` is now **Designed** (complete design exists).
- Node System's Open Question #3 flag is stale.
- **Resolution**: Node System Open Question #3 should be resolved now that Shop GDD exists.
⚠️ **[C5] Tuning Knob ownership: MaxPlayerGold shared across 3 GDDs**
- **Documents**: `shop.md`, `event-system.md`, `progression.md`
- All three reference `MaxPlayerGold = 9999` consistently — no value conflict.
- However, no single GDD formally declares itself the **owner** of this constant.
- Entity registry correctly sources it from `shop.md`.
- **Recommendation**: Document in Shop GDD that it is the authoritative owner of `MaxPlayerGold`.
---
## Game Design Issues
### Blocking
🔴 **[G1] Exponential boss HP vs. linear player power — indefinite gap creates a hard wall**
- **Documents**: `node-system.md` + `tower-assembly.md`
- `BossEffectiveHp = BaseHp × 2^(completedLoopCount)` — exponential in boss cycles survived
- Player tower stats: `statValue[i] = baseValue + perLevel × i` — linear, capped at 5 levels
- After ~5–7 boss cycles, boss HP doubles so fast that even max-rarity max-level towers cannot deal enough damage before the boss outscales
- The game becomes mathematically unbeatable at high loop counts regardless of build quality
- **This is not a difficulty curve — it is a hard wall**
- **Resolution needed**: Add a boss cycle cap, or add a player power catch-up mechanic (e.g., each boss cycle also grants a temporary attack buff, or boss HP scaling changes to logarithmic rather than exponential). Alternatively, redefine "VictoryType" to not involve looping, removing the exponential scaling trigger.
---
🔴 **[G2] Run End Screen cannot display gold earned this run — undefined data flow**
- **Documents**: `node-system.md` + `progression.md`
- Node System fires `RunEnd` → calls `Progression.RecordRunEnd(runStats)`
- Progression updates `LifetimeStats.totalGoldEarned += goldEarned`
- But `OnUnlockedEventArgs` carries only `UnlockResult[]` — NOT the run's gold amount
- Run End Victory screen should show "Gold earned this run" — but no interface provides this data
- **Resolution needed**: Either (A) `UnlockedEventArgs` carries a `goldEarnedThisRun` field, or (B) Node System provides gold data directly to Run End screen (bypass Progression), or (C) Run End screen queries `Progression.GetLastRunGold()` (add this method)
---
🔴 **[G3] Undefined behavior: does RecordRunEnd fire on loss runs?**
- **Documents**: `node-system.md` vs `progression.md`
- Progression SR2: "`RecordRunEnd()` is called on every run end (win or loss)"
- Node System Edge Case: "Any combat loss: Run ends in failure immediately" — no mention of calling `RecordRunEnd`
- Progression AC explicitly covers loss runs (stats update, no unlocks)
- But if Node System never calls `RecordRunEnd` on loss, Progression's loss-path AC is unreachable
- **Resolution needed**: Clarify: does Node System call `RecordRunEnd` on loss? If yes — Node System GDD must document this. If no — Progression's loss-run AC is unreachable and must be revised.
---
### Warnings
⚠️ **[G4] Unbounded gold accumulation — no sink between runs**
- **Documents**: `node-system.md` + `shop.md`
- Per full winning run: ~600 (combat) + 300 (boss level) + 200 (boss bonus) = ~1100 gold
- With `MaxPlayerGold = 9999`, a player needs ~9 wins to cap out
- Once at cap, excess gold from combat is discarded — additional survival is not rewarded
- No sink between runs: no repair, no permanent upgrades, no sacrifice mechanic
- **Risk**: Short optimized runs may be more rewarding than long successful ones; endgame economy becomes meaningless
- **Recommendation**: Consider a meta-gold sink (permanent repair station, unlock purchases, or cosmetic unlocks) to recycle late-game gold
⚠️ **[G5] Multiple systems claim to be the primary progression loop**
- **Documents**: All 5 GDDs
- Node System: "tactician executing a plan" — navigates the run
- Tower Assembly: "puzzle solver, cleverness" — builds power
- Shop: "tactical urgency, deliberate investment" — resource decisions
- Event: "narrative surprise, meaningful stakes" — variety
- Progression: "relentless collection" — meta motivation
- **Risk**: Without a declared primary loop, players optimize the wrong thing
- **Recommendation**: Explicitly designate Tower Assembly as the core tactical loop; Node System as the structural frame; Shop/Event as the decision points; Progression as the meta-reward
⚠️ **[G6] Component Tag stacking — potential dominant strategy**
- **Documents**: `tower-assembly.md`
- R7: No compatibility constraints between component types
- Tags aggregate with stack counts merging across 3 components
- Optimal strategy: collect 3 copies of the best tag, stack it
- **Risk**: The "puzzle" of tower assembly may have a trivially discoverable dominant solution
- **Recommendation**: Consider adding anti-synergy rules (e.g., same tag on 3 components reduces effectiveness) or explicit trade-offs to maintain build diversity
⚠️ **[G7] Lossy economy (sell ~50% of buy) discourages experimentation**
- **Documents**: `shop.md`
- Sell price = midpoint of `[MinPrice, MaxPrice]` ≈ 50% of average buy
- Repeated buy-sell cycles destroy gold
- Players hoard components rather than experiment
- **Risk**: Reduces strategic depth; players avoid build diversity
- **Recommendation**: Consider a higher sell ratio (e.g., 60–70%) or a component enhancement sink to make exploration more affordable
⚠️ **[G8] Event rewards use same rarity budget as shop — no distinct reward tier**
- **Documents**: `event-system.md` + `shop.md` (OQ5)
- `AddRandomComps` uses `InventoryGenerationComponent` with identical rarity budget to shop
- Events offer no higher-tier rewards than shop can provide
- **Risk**: Events feel mathematically equivalent to additional shop visits; "genuine dilemma" fantasy may be undermined
- **Recommendation**: Consider a separate event rarity budget (e.g., events drop 1 tier lower than equivalent shop purchase) to give events a distinct identity
⚠️ **[G9] Assembly Phase has 5 simultaneous information panels**
- **Documents**: `tower-assembly.md`
- During Assembly Phase: Inventory Grid + Tower Slots + Assembled Towers Panel + Combat Roster + Next Node Preview
- **Risk**: Cognitive overload for new players
- **Recommendation**: Consider a tabbed or sequenced interface rather than all panels visible simultaneously
---
## Cross-System Scenario Issues
**Scenario: Player completes a winning run — full RunEnd chain**
### Steps:
1. **Node System**: Combat victory at Boss node → fires `NodeCompleteEventArgs` with `CombatWon=true`
2. **Node System**: Calls `Progression.RecordRunEnd(runStats)` with `{goldEarned, nodesCompleted=10, bossDefeated=true, ...}`
3. **Progression**: Updates `LifetimeStats` → evaluates unlocks → fires `UnlockedEventArgs` with `UnlockResult[]`
4. **UI / Run End Screen**: Receives `OnUnlockedEventArgs` → shows toast for new unlocks
### Issues found:
🔴 **G2 (already flagged above)**: Run End screen cannot display gold earned this run
- Step 3: `UnlockedEventArgs` carries no `goldEarned` field
- Run End Victory screen should show gold earned — no data path defined
🔴 **G3 (already flagged above)**: Ambiguous whether loss runs call `RecordRunEnd`
- If loss path does not call `RecordRunEnd`, steps 2–3 never occur on loss
⚠️ **Scenario: Player reaches Boss but loses**
- Node System fires `RunEnd` with `bossDefeated=false`
- Does `RecordRunEnd` fire? (G3 ambiguity)
- If yes: LifetimeStats records `furthestNodeReached=9`, no unlocks
- If no: Partial run progress is lost, player gets no credit for reaching node 9
⚠️ **Scenario: Multiple unlocks fire simultaneously**
- Step 3: `UnlockResult[]` can contain multiple items
- Step 4: Toast popup shows all — good
- But no priority ordering if unlocks are from different categories (e.g., Difficulty + Theme simultaneously)
- **Info**: Not a blocker; worth documenting expected ordering
---
## GDDs Flagged for Revision
| GDD | Reason | Type | Priority |
|-----|--------|------|----------|
| `node-system.md` | C1/C3/C4: Stale Open Question #3, ambiguous loss behavior, "no partial rewards" contradicts Progression's loss-path AC | Consistency + Design Theory | **High** |
| `progression.md` | C1: Loss-run gold recording contradicts Node System's "no partial rewards"; G3: loss run behavior undefined | Consistency + Design Theory | **High** |
| `tower-assembly.md` | C2: Progression dependency is orphaned (one-directional); G6: Tag stacking dominant strategy risk | Consistency + Design Theory | Medium |
---
## Verdict: **CONCERNS**
Three blocking issues must be resolved before architecture begins:
1. **C1**: "No partial rewards" vs. Progression loss-path gold — design decision required
2. **G1**: Exponential boss HP vs. linear player power — hard wall makes boss unbeatable after ~cycle 5–7
3. **G2/G3**: Undefined data flow for Run End screen gold display + ambiguous loss run behavior
Warnings (G4–G9) are advisory and should be addressed before implementation but do not block architecture.
---
## Recommended Actions
1. **C1**: Decide: do loss runs record gold in LifetimeStats? Update both Node System and Progression GDDs accordingly
2. **G1**: Redesign boss scaling formula — change from exponential to logarithmic, or add player catch-up mechanic, or cap boss cycles
3. **G2**: Add `goldEarnedThisRun` to `UnlockedEventArgs`, OR have Node System provide gold directly to Run End screen
4. **G3**: Clarify whether Node System calls `RecordRunEnd` on loss. Update Progression SR2 and AC to match.
5. **C2**: Either add Tower Assembly as soft upstream to Progression (with interface), or remove the speculative reference from Tower Assembly GDD
6. **C4**: Resolve Node System Open Question #3 now that Shop GDD is complete
+189
View File
@@ -0,0 +1,189 @@
# Cross-GDD Review Report
**Date:** 2026/04/29
**GDDs Reviewed:** 2
**Systems Covered:** Node System, Tower Assembly
---
## 一致性问题 (Consistency Issues)
### 警告级 (Warnings)
#### ⚠️ `TryDisassembleTower` 未实现但 UI 可调用
- **GDD**: `tower-assembly.md` Open Question #1
- **问题**: `node-system.md` R3 规定"组装后可以免费拆解",Assembly Phase UI 也提供 Disassemble 选项。但 `tower-assembly.md` 明确标注 `TryDisassembleTower` 方法不存在。玩家看到可点击的 Disassemble 按钮但无法使用,属于误导性 UI。
- **建议**: `tower-assembly.md` 应将 Open Question #1 从 "OPEN" 改为标注为 "Blocking — GDD 自洽性受损",直至方法实现
---
## 游戏设计问题 (Game Design Issues)
### 阻塞级 (Blocking)
#### 🔴 Boss 难度曲线可能无法追赶 — 威胁两大系统核心幻想
**位置**: `node-system.md` Boss Difficulty Scaling 公式;`tower-assembly.md` Stat Scaling
**问题描述**:
- Boss HP 增长: `BossEffectiveHp = DRLevel.BaseHp × 2^(completedLoopCount)` — 指数增长
- 玩家战力增长: Tower Assembly 提供线性成长(每场战斗最多 8 次升级事件,每次 `baseValue + perLevel * i`)
- 在某个 loop 阈值后,玩家数学上无法击败 Boss
**设计后果**:
- `node-system.md` Player Fantasy: "whether I survive **depends on** how I prepare" — 若准备永远不够,此幻想崩塌
- `tower-assembly.md` Player Fantasy: "arrange pieces to solve it" — 若 puzzle 解不开,此幻想崩塌
- 两大系统的核心幻想共享同一个前提:玩家准备应当是充分的
**未知依赖**: `DRMuzzleComp`、`DRBearingComp`、`DRBaseComp` 数据表值未知,无法验证实际成长率
**设计建议**:
1. 在 Boss 战中引入 catch-up 机制(每 loop 玩家获得临时增益)
2. 让 Tower Assembly 存在超线性成长路径(tag synergy、combo 机制)
3. 降低 Boss 的指数系数(从 ×2 改为 ×1.3~1.5)
4. 在 `node-system.md` 中明确说明"Boss 的设计意图是部分玩家无法一次通关"以调整预期
---
#### 🔴 无 Gold 消耗强制机制 — 经济回路开放无界
**位置**: `node-system.md` 经济表格(1100 gold 上限);`tower-assembly.md` 无 gold sink 定义
**问题描述**:
- 6 场 Combat 节点共 600 gold,Boss 胜利额外 500 gold,最大总量 1100 gold/run
- Shop Node 4 和 8 奖励 0 gold,不强制消费
- 无任何机制(修理费、强制升级、门票等)要求消耗 gold
- **Shop System GDD(待编写)必须定义强制消耗场景**,否则 Shop 节点沦为 UI 空操作
**风险**: Shop 节点存在但内容空洞,玩家跳过购买不打 Boss —— 浪费了 2 个节点的设计价值
---
### 警告级 (Warnings)
#### ⚠️ 塔组装存在显然的最优策略
**位置**: `tower-assembly.md` R7(无组件兼容性约束)
**问题**: 任意 Muzzle+Bearing+Base 组合均可。Rarity Resolution 取算术均值:
- Red(5)+Red(5)+Red(5) → 均值 5.0 → Red Tower
- Red(5)+Green(2)+White(1) → 均值 2.67 → 向下取整 → Green Tower(比单 Red 差)
**结论**: 贪婪地堆最高稀有度组件永远是最优策略,无任何权衡代价。塔组装的最优解是排序后取 top 3,无需战术思考。
**建议**: 引入稀有度以外的优化维度:
- Tag synergy 机制(Fire+Ice 组合产生额外效果)
- Endurance 配平(3 高稀有度 = 3 高损耗率,混合可延长功能性)
- Roster slot 约束下的边际收益(分散稀有度可在 4 槽位限制下覆盖更多敌人类型)
---
#### ⚠️ 节点选择若两条路径难度不同,最优路径显而易见
**位置**: `node-system.md` 节点选择流程
**问题**: 每 junction 显示 2 个目的节点类型。若两条路径在难度/奖励上存在差异(比如两 Combat 但一个是 L1 一个是 L3),"战术决策"退化为"比较两个数字"。
**建议**: 确保两条分支在难度和奖励上等价,或引入隐藏变量(敌人波次、特殊条件)使玩家无法在选择前判断哪个更有利。
---
#### ⚠️ 玩家注意力预算已达上限,无扩展空间
**当前 Assembly Phase + Node Choice 时活跃系统**: 4 个
1. Tower Assembly 决策(选 3 组件组合)
2. Roster 管理(4 槽位分配)
3. 下个节点威胁评估(已知类型 + 难度)
4. 节点二选一(强制决策)
**设计阈值**: 技能定义 >4 时触发警告,当前 = 4(刚好达标)
**风险**: Event System(待设计)若在 Assembly Phase 窗口引入额外主动决策(如事件影响当前备战),预算立即超标。
---
#### ⚠️ Pillar 轻微漂移
| GDD | Pillar 声明 | 实际行为 | 漂移程度 |
|-----|------------|---------|---------|
| `node-system.md` | "players drive their own path" | 节点类型序列固定(Plain 主题下所有玩家路径相同);Junction 处只选顺序不选类型 | 轻微 — "path" 实为"choice order" |
| `tower-assembly.md` | "adaptation to known threats" | Shop/Event 节点无需塔组装决策;"adaptation" 对 30% 节点不适用 | 轻微 — pillar 覆盖部分场景 |
---
## 跨系统场景问题 (Cross-System Scenario Issues)
**场景走过**: 3 个
---
### 🔴 场景1: 战斗结束 → 组件掉落 → 塔组装(数据流歧义)
**涉及系统**: Combat → Inventory → Tower Assembly
**问题描述**:
Combat 节点完成后,组件掉落通过 `InventoryComponent.AddItem` 进入 inventory。**接口未定义**:
- 掉落的是全新组件实例(InstanceId 新生成)?
- 还是对现有组件的修改(如 `IsAssembledIntoTower` 标记改变)?
**隐患**: 若为后者,而玩家在 Assembly Phase 前一步已组装了某些塔,战损标记可能与已组装组件的状态产生歧义。
**需确认**: `InventoryComponent.AddItem` 的精确语义,建议在 `node-system.md` Dependencies 或接口协议中明确定义。
---
### ⚠️ 场景2: Boss 前夕的 Assembly Phase(难度曲线验证)
**涉及系统**: Node System + Tower Assembly + Combat
**问题描述**:
玩家在 Node 9 完成后进入 Assembly Phase,此时 `BossEffectiveHp` 是可计算的(因 `completedLoopCount` 已知)。玩家可以提前计算胜率。
**风险**:
- 若计算结果为正 → Boss 战是仪式感结局,fantasy 得到验证
- 若计算结果为负 → 玩家感受到"数值碾压"而非"战术压力",两大系统的核心幻想同时受损
**需验证**:
- `BossEffectiveHp` 是否对玩家 UI 可见(建议:仅在 Boss 血条上显示,不显示计算公式)
- 实际 tower DPT(每秒伤害)是否能与指数增长的 Boss HP 达到动态平衡
---
### ℹ️ 场景3: Degraded 塔在战斗开始时的处理
**涉及系统**: Tower Assembly + Combat
**问题描述**:
`tower-assembly.md` 明确:"mid-combat 降级不会自动从 roster 移除"。但 `CombatNodeComponent` 的 roster 验证逻辑未定义。
**两种处理方式的后果**:
- 若验证在战斗**开始**时:degraded 塔被筛除,玩家以 <4 塔对抗预期敌人规模
- 若验证在战斗**进行中**:塔在战斗中失效,战斗难度非线性上升
**建议**: 在 `tower-assembly.md` Acceptance Criteria 中补充:`CombatNodeComponent` 必须在战斗开始前调用 `CombatParticipantTowerValidationService.ValidateParticipantTowers`,拒绝 degraded 塔入战。
---
## GDD 标记需修订
| GDD | 原因 | 类型 | 优先级 |
|-----|------|------|--------|
| `tower-assembly.md` | `TryDisassembleTower` 未实现但 UI 可调用;Open Question #1 应更新状态为 Blocking | 设计完整性 | Warning |
| `node-system.md` | Boss catchability 需在 GDD 中明确设计意图;建议补充"战斗开始前 roster 验证逻辑"的跨系统约定 | 难度曲线 | Warning |
| `systems-index.md` | Combat System 标注"Designed"但 GDD 在 `docs/CombatNodeArchitecture.md` 而非 `design/gdd/`,文档位置不统一 | 文档管理 | Warning |
---
## Verdict: **CONCERNS**
存在 2 个阻塞级问题(Boss catchability + 无 gold sink)和多个警告级问题。阻塞问题不会阻止架构设计,但应在 `/create-architecture` 前明确设计意图。
---
## Next Steps
- `/design-system shop` — 编写 Shop System GDD(阻塞项:gold sink 定义)
- `/design-system event` — 编写 Event System GDD(阻塞项:EventContext 合约)
- `/design-system progression` — 编写 Progression GDD(阻塞项:RunEnd 数据持久化)
- `/design-review tower-assembly` — 修订 Open Question #1 并更新 TryDisassembleTower 状态
- `/create-architecture` — 在所有 MVP GDD 完成后开始架构设计(Verdict 为 CONCERNS 但非 FAIL)
+420
View File
@@ -0,0 +1,420 @@
# Node System (节点系统)
> **Status**: Revised — post-design-review fixes applied (edge divergence, economy, Boss scaling, Assembly Phase entry, Boss color, Coin sink, AC gaps)
> **Author**: SepComet + agents
> **Last Updated**: 2026-04-30 (post-review revisions: edge level variants, first-shop tiering, Boss nodesCompleted scaling, Assy Phase auto-enter, Boss VFX color, Coin sink clarification, AC gaps filled)
> **Implements Pillar**: Core game loop navigation — players drive their own path through the run
## Overview
The Node System is the **run-level navigation layer** that structures a complete playthrough. A run consists of exactly 10 sequential nodes. The player advances through nodes one at a time, choosing which available node to tackle next. After each node resolves, the player enters a brief **assembly phase** to reconfigure their towers before committing to the next node.
**Node types**:
- **Combat Node**: Triggers a wave-based tower defense battle via `CombatNodeComponent`. The player earns Gold (persistent run currency) and component drops based on performance.
- **Event Node**: Presents a branching choice with risk/reward outcomes. No combat; purely decision-based.
- **Shop Node**: Opens the component store for purchasing upgrades between battles.
- **Boss Node** (Node 10): A combat node with higher difficulty and guaranteed valuable drops — the run's climax.
**Currency note**: This GDD distinguishes two currencies. **Gold** is the persistent run-level currency earned from combat nodes and spent at Shop nodes. **Coin** is a combat-internal currency earned per combat round and spent within a single combat encounter on tower building and other intra-combat actions — Coin does not persist between nodes and is exclusive to the CombatNode domain. The Coin sink is defined in the CombatNode design; Coin has no interaction with Gold or the shop system.
**Node graph structure**:
The node graph is a **linear track with one branch per node**. At each node entry, the player is shown the available outgoing edge(s) and must choose which node to enter next. There is no convergence/merging of paths within a run — the player advances linearly, not across branching tracks. The two edges at each junction lead to the **same node type** but offer **different level variants** (distinct map layouts, enemy compositions, or environmental conditions) — creating strategic divergence through topology and threat profile rather than through node-type variety.
**Node type generation**: Node types follow a **fixed sequence** (not randomized) per run. The sequence for the default (Plain theme) is: Combat → Combat → Combat → Shop → Combat → Event → Combat → Shop → Combat → BossCombat. Events are restricted to positions 4–8 only. Future themes may define their own fixed sequences. Players cannot choose or reroll node types. The two outgoing edges from each node lead to distinct level variants of the next node type, not to different node types.
## Player Fantasy
**"I feel like a tactician executing a plan through hostile terrain — the route is set, but how I prepare and when I commit my forces determines whether I survive."**
The Node System delivers the fantasy of **tactical navigation under pressure**. The player is a tactician with a fixed route ahead — they know what kinds of challenges await (the full node track and boss are visible at run start), but at each junction they choose which of two paths to commit to. The core feeling is **the weight of tactical commitment**: selecting a path means locking in your approach. You can see the Boss at Node 10 glowing at the end, and you know the full track from the start — but the question is whether the resources and tower builds you've chosen will be sufficient to reach it intact.
The player should feel:
- **Preparing and adapting** — using Assembly Phases to optimize tower builds based on known upcoming challenges; choosing path segments that complement the components in hand
- **The weight of commitment** — once you enter a node, the choice is locked; there's no undoing or backtracking within a run
- **Building toward a climax** — each node brings the player closer to the Boss; the 10-node arc creates mounting tension toward the run's inevitable crescendo
- **Satisfaction when the plan holds** — the run "reads" as a coherent story in retrospect: "I invested heavily in early towers, conserved resources mid-run, and deployed my best assembly for the Boss"
**Reference**: Into the Breach's "visible consequences of choices" feeling — the player can see what lies ahead (both edge destinations and their level variants) and must prepare accordingly. The Geometry TD node system achieves this through the fixed Boss at Node 10, the linear-but-choiced track structure where the two outgoing edges present different level variants of the next node, and the Assembly Phase where the player configures their towers for the known upcoming challenge. Resource timing and build optimization matter more than node-type gambling — but the specific level variant encountered is also shaped by the player's path choice.
## Detailed Design
### Core Rules
**Run Structure**
1. A run consists of exactly **10 sequential nodes**. Node 10 is always a Boss Combat node.
2. Node types follow a **fixed sequence** per theme (see Node Type Generation above). The player cannot choose or reroll node types.
3. The run graph is **strictly forward-only**: no backtracking, no skipping nodes, no retries of completed nodes.
**Node Entry Flow**
4. Player arrives at a node. The node resolves based on type:
- **Combat / Boss**: `CombatNodeComponent.StartCombat()` is called by the Procedure layer. On victory, player receives Coin, component drops, and Gold.
- **Event**: Branching choice presented. Player selects an option; risk/reward resolves immediately.
- **Shop**: Shop interface opens. Player buys/sells components. Player exits freely.
5. Node resolves. **Assembly Phase is automatically entered** after every node resolves. Player interaction within it is optional — the "Ready" button can be clicked immediately to proceed, or the player may re-enter freely before selecting the next node. Assembly Phase is never skipped; it is a mandatory transit point, not a mandatory modification point.
6. **Assembly Phase**: Player can swap any component on any tower, reorganize inventory, and review current stats. Player confirms "Ready" to proceed to node choice, or may re-enter Assembly Phase freely before selecting the next node.
7. Assembly Phase ends (player-initiated or via "Ready" confirmation). The **2 outgoing edge destinations** from the completed node are revealed (node types shown).
8. Player selects one destination. The chosen edge is locked in. Player travels to the next node.
9. Repeat steps 4–8 until Node 10 (Boss) is reached.
**Combat Loss Rules**
10. **Any combat loss**: Run ends in failure immediately. There is no continuation after a combat loss — the run concludes at the point of failure. This applies to both regular Combat nodes and the Boss node. Node is marked as `RunNodeStatus.Exception` in the run state on loss.
**Data Persistence**
11. Within a run: Inventory, Repository, Gold, Coin, Tower configs, visited node history, and active buffs/debuffs **persist across nodes**.
12. Between runs: All of the above **reset to starting values**. Only permanent meta-progression (unlocks, permanent upgrades) persists.
**NodeComponent — Ownership and Interface**
15. There is no `NodeComponent` class. Run-level orchestration is handled by the Procedure layer via `RunStateAdvanceService` and `RunState`.
16. The Procedure layer calls `CombatNodeComponent.StartCombat()` only after the Assembly Phase is confirmed complete.
17. `CombatNodeComponent` fires `NodeCompleteEventArgs` (with `CombatWon` field) after combat resolves. The Procedure layer receives this event and drives state transitions via `RunStateAdvanceService.TryCompleteCurrentNode`.
### States and Transitions
| State | Description | Exits |
|-------|-------------|-------|
| `RunIdle` | Pre-run, at main menu. Player not yet in a run. | → `NodeReveal` on "Start Run" |
| `NodeReveal` | Outgoing edges from current node are displayed. Player makes a choice. | → `NodeTransition` on choice confirmed |
| `NodeTransition` | Player travels to the chosen destination node. | → `NodeEntry` |
| `NodeEntry` | Player arrives at the node. Node-type logic triggers (Combat/Event/Shop/Boss). | → `AssemblyPhase` on node resolved |
| `AssemblyPhase` | Full tower assembly enabled. Player may enter/exit freely. Player confirms "Ready" to proceed to node choice. | → `NodeReveal` (next node) or `RunEnd` (Boss completed) |
| `RunEnd` | Victory or failure screen. Stats recorded. Return to main menu. | → `RunIdle` |
*Note: `CombatNodeComponent` manages its own internal `Loading → RunningPhase → ... → Settlement` state machine (per CombatNodeArchitecture.md). From the Node System's perspective, a Combat node entry is a single atomic transition: `NodeEntry → AssemblyPhase` on receiving the `CombatVictory` or `CombatDefeat` event.*
### Interactions with Other Systems
| System | Direction | Interface |
|--------|-----------|------------|
| **CombatNodeComponent** | Delegates to | `StartCombat(CombatData)`, receives `OnCombatVictory / OnCombatDefeat` events |
| **ShopSystem** | Delegates to | `ShopFormController.OpenShop()`, receives `OnShopClosed` callback |
| **EventSystem** | Delegates to | `EventNodeComponent.ProcessEvent()`, receives `OnEventResolved` |
| **TowerAssembly** | Reads/writes | Tower config persists in `NodeComponent` run state; Assembly Phase reads current inventory |
| **Inventory** | Reads | Component drops from combat are added to inventory via `InventoryComponent.AddItem` |
| **MapEntity / MapTopologyService** | Reads | Combat nodes query `MapTopologyService` for path data to pass to `CombatNodeComponent` via `MapData` |
| **Progression** | Writes | On `RunEnd`, final stats (Gold, nodes completed, Boss killed) are written to Progression |
```
RunState (data container, owned by Procedure layer)
├── RunStateAdvanceService (state transition logic)
├── CombatNodeComponent (delegates combat entry)
├── ShopNodeComponent (opens Shop UI on Shop node)
├── EventNodeComponent (processes Event node choices)
└── TowerAssembly (read/written during Assembly Phase)
```
Note: There is no `NodeComponent` class. Orchestration is handled by the Procedure layer.
## Formulas
**Important note on `completedLoopCount`**: This refers to the **number of completed combat cycles within a single Boss node encounter** — i.e., when a Boss fight loops (e.g., VictoryType requires surviving N rounds), each completed cycle increments the count. This is independent of the run's node count. The formula does NOT use `nodesCompleted` from the run state.
Gold earned per node completed, plus boss bonus:
`TotalGold = Σ DRLevel.RewardGold(CompletedCombatNodes) + (HasDefeatedBoss ? BossBonus : 0)`
Each Combat node's gold reward is determined by its linked level's `DRLevel.RewardGold` value. Event and Shop nodes do not award gold directly. The Boss node's own reward comes from its linked level (`DRLevel.RewardGold` at level index for Boss) plus the `BossBonus`.
| Variable | Symbol | Type | Range | Description |
|----------|--------|------|-------|-------------|
| Combat nodes cleared | n | int | 0–6 | Number of non-boss combat nodes cleared (nodes 1, 2, 3, 5, 7, 9). Event and Shop nodes award 0 gold and are excluded from the sum. |
| Boss bonus | BossBonus | int | 200 | Flat bonus for defeating Boss (applied only if `HasDefeatedBoss = true`) |
**Per-node gold (REVISED — economy rebalanced):**
After rebalancing, the illustrative values for the Plain theme sequence (Combat at L1, L2, L3, L1; Boss at L4) are:
| Node | Type | Level | Gold (illustrative) |
|------|------|-------|-------------------|
| 1 | Combat | Level 1 | 90 |
| 2 | Combat | Level 2 | 90 |
| 3 | Combat | Level 3 | 120 |
| 4 | Shop | — | 0 |
| 5 | Combat | Level 1 | 90 |
| 6 | Event | — | 0 |
| 7 | Combat | Level 2 | 90 |
| 8 | Shop | — | 0 |
| 9 | Combat | Level 3 | 120 |
| 10 | BossCombat | Level 4 | 300 + BossBonus(200) |
**Output Range:** 0 to 1100 (6×Combat=600 + BossBonus=200 + BossLevelGold=300) depending on how far the player progressed and whether Boss was defeated.
**Constraint:** Plain theme places exactly one Event node at position 6. Events may only occupy positions 4–8. Combat nodes 1, 2, 3, 5, 7, 9 use the Plain cycle L1, L2, L3, L1, L2, L3. **Shop tiering**: The first shop (Node 4) offers only White and Green rarity components; Blue and above appear only from Node 8 onward.
---
### 2. Boss Difficulty Scaling
Boss difficulty scales with both the Boss node's own loop/round count **and** the number of non-boss nodes the player has completed in the run.
`BossEffectiveHp = DRLevel.BaseHp × 2^(completedLoopCount) × (1 + 0.1 × nodesCompleted)`
| Variable | Symbol | Type | Range | Description |
|----------|--------|------|-------|-------------|
| Boss base HP | DRLevel.BaseHp | int | ≥ 1 | Fixed HP from level config (note: `DRLevel` only has `BaseHp`, not a separate `BossBaseHp` field). Floor of 1 applied at data load time. |
| Completed loop count | completedLoopCount | int | 0–31 | Number of **combat rounds/cycles completed within the current Boss encounter** — NOT the count of nodes completed in the run. When a Boss fight loops (e.g., VictoryType requires surviving N rounds), each completed cycle increments the count. Resets when a new Boss fight begins. Hard cap of 31 loops before `BossEffectiveHp` would reach `int.MaxValue`; implementation clamps at `int.MaxValue`. |
| Nodes completed | nodesCompleted | int | 0–9 | Number of non-boss combat nodes (nodes 1, 2, 3, 5, 7, 9) successfully completed this run. Does not include Shop or Event nodes. Resets each run. |
| Difficulty multiplier | (1 + 0.1 × nodesCompleted) | float | 1.0–1.9 | Run-progress multiplier. More nodes completed → harder Boss. Caps naturally at 1.9× (at 9 nodes completed). Does not exceed 2.0×. |
| Boss effective HP | BossEffectiveHp | int | ≥ 1 | Final boss HP |
**Constraint:** Players who lost early AND players who breezed through will face different Boss HP values at the same loop count — the run-progress multiplier differentiates them. A player with `nodesCompleted=3` and `completedLoopCount=0` faces `BaseHp × 1.3`; a player with `nodesCompleted=9` and `completedLoopCount=0` faces `BaseHp × 1.9` at the same level.
**Note:** Formula extends `EnemyConfigProvider.ResolveScaledEnemyBaseHp` with a run-progress factor. The `DRLevel` fields used are: `Id`, `LevelThemeType`, `BaseHp`, `StartCoin`, `VictoryType`, `VictoryParam`, `RewardGold`.
## Edge Cases
- **If Player loses Combat at any node**: Run ends in failure immediately. No partial rewards are awarded. The run is complete at the point of failure.
- **If Player has 0 components entering Assembly Phase**: Player cannot assemble or modify any towers. Assembly phase is effectively a no-op pass-through. Player proceeds to the next node with existing tower state unchanged.
- **If Player encounters two consecutive Shop nodes**: Player has back-to-back purchase opportunities. If Gold is insufficient at Shop 1, no mitigation occurs — Shop 2 may also be unaffordable. No rule forces spending or guarantees affordability.
- **If Player encounters Shop Node 4 (first shop)**: Only White and Green rarity components are available. Blue and higher rarities are excluded from this shop. If the player's gold is insufficient for all available items, no additional items appear — the player may proceed with insufficient purchases.
- **If Player encounters Shop Node 8 (second shop)**: All rarity tiers (White through Red) are available. There is no tier restriction on the second shop.
- **If Player loses at Boss node**: Run ends in failure immediately with **no partial rewards**. The Boss node awards no rewards on loss.
## Dependencies
### Upstream Dependencies (what Node System depends on)
| System | Type | Interface | Status |
|--------|------|-----------|--------|
| **CombatNodeComponent** | Hard | Fires `NodeCompleteEventArgs` with `CombatWon` field after combat resolves. Calls to `CombatNodeComponent.StartCombat()` enter combat. | Implemented (`Assets\GameMain\Scripts\CustomComponent\CombatNode\CombatNodeComponent.cs`) |
| **ShopSystem** | Hard | Calls `ShopNodeComponent.StartShop()`; receives `OnShopClosed` callback. | Designed (`design/gdd/shop.md`). `ShopContext` contract and buy/sell behavior are defined and consistent with Assembly Phase timing. |
| **EventSystem** | Hard | Calls `EventNodeComponent.ProcessEvent()`; receives `OnEventResolved`. | Designed (`design/gdd/event-system.md`). `EventContext` contract and risk/reward resolution flow are defined and consistent with `NodeEntry → AssemblyPhase` atomic transition model. |
| **TowerAssembly** | Soft | Reads/writes tower configs during Assembly Phase. Tower state persists in `NodeComponent` run context. | Designed (`design/gdd/tower-assembly.md`). `TryAssembleTower()` and `TryDisassembleTower()` interfaces are defined; Assembly Phase timing and inventory access patterns are consistent with this GDD. |
| **Inventory** | Soft | Reads component drops from combat; adds items via `PlayerInventoryComponent.MergeInventory`. | Implemented |
| **MapEntity / MapTopologyService** | Hard | Reads path/topology data for combat map setup; assembles `MapData` passed to `CombatNodeComponent`. | Implemented (see `Assets\GameMain\Scripts\CustomComponent\Map\`) |
| **Progression** | Soft | Writes final run stats (Gold, nodes completed, Boss killed) on `RunEnd`. | Designed (`design/gdd/progression.md`). `RecordRunEnd()` interface is defined; all acceptance criteria involving `Progression` are now implementable. |
### Downstream Dependents (what depends on Node System)
| System | Type | Interface | Status |
|--------|------|-----------|--------|
| **Progression** | Hard | Reads run completion data (Gold, nodes cleared, Boss defeat) from `RunState` on `RunEnd`. | Designed (`design/gdd/progression.md`). Interface contract is defined. |
### Bidirectional Consistency Check
- [x] `CombatNodeComponent` → listed as upstream (Node System receives its events) ✅
- [x] `Progression` → downstream only; Node System writes to it ✅
- [x] `ShopSystem` → GDD exists; interface contract aligned ✅
- [x] `EventSystem` → GDD exists; interface contract aligned ✅
- [x] `TowerAssembly` → GDD exists; interface contract defined ✅
### Provisional Assumptions
- `ShopSystem` receives a `ShopContext` from the orchestrating component containing current Gold/Coin and run node index
- `EventSystem` receives an `EventContext` containing run state (node index)
- `TowerAssembly` is called during Assembly Phase and writes back to `RunState`'s inventory snapshot
- There is no `NodeComponent` — orchestration is handled by the Procedure layer via `RunStateAdvanceService`
## Tuning Knobs
All designer-adjustable values for the Node System. Changing these does not require code changes.
### Run Structure
| Knob | Default | Safe Range | Extreme: Too Low | Extreme: Too High |
|------|---------|-----------|-----------------|------------------|
| `TotalNodesPerRun` | 10 | 5–20 | Run feels too short; boss arrives too quickly | Run feels repetitive; pacing drags |
| `BossNodeIndex` | 10 | = `TotalNodesPerRun` | N/A | N/A |
| `OutgoingEdgesPerNode` | 2 | 2–3 | Fewer choices reduces strategic depth | More choices may overwhelm UI/decision-making |
### Boss Scaling
| Knob | Default | Safe Range | Extreme: Too Low | Extreme: Too High |
|------|---------|-----------|-----------------|------------------|
| `BossBonusGold` | 200 | 100–500 | Boss reward feels trivial | Boss trivializes economy |
### Assembly Phase
| Knob | Default | Safe Range | Extreme: Too Low | Extreme: Too High |
|------|---------|-----------|-----------------|------------------|
| `AssemblyPhaseIsMandatory` | true | true/false | Player skips assembly (reduces strategy depth) | N/A |
| `AssemblyPhaseHasTimeLimit` | false | false or 30–120s | N/A | Time pressure reduces quality of decisions |
## Visual/Audio Requirements
### VFX Event Specifications
| Event | Visual Effect | Audio Cue | Duration |
|-------|--------------|-----------|----------|
| **Node Completion** | Node pulses with type-color glow (1.0x → 1.1x → 1.0x), emits 8–12 geometric particles (triangles/diamonds) in radial burst | Soft ascending chime (C5-E5-G5), low volume | ~600ms |
| **Choice Made** | Selected edge animates from dashed to solid (300ms); unselected edge fades to 20% opacity | Subtle "lock-in" percussive click | ~400ms |
| **Loss Suffered** | Screen flashes red at 30% opacity for 150ms; failed node icon cracks/dims permanently; screen transitions to Run Failure screen | Low thud + dissonant minor-2nd tone | ~300ms |
| **Boss Defeated** | Full-screen golden particle shower (64+ hexagonal particles); Boss node explodes into geometric shards reforming as victory badge | Triumphant rising major chord fanfare (1.5s) | ~2000ms |
| **Run Victory** | All past nodes illuminate sequentially bottom-to-top (80ms each) forming a completed-path glow; Boss transforms into trophy/star icon; golden vignette | Extended C-E-G-C chord sustain + chime cascade | ~2500ms |
| **Run Failure** | Screen desaturates over 500ms; node track cracks along failed node; fade to dark with red tinge | Descending minor tone + deep bell | ~1500ms |
### Color Palette
| Node Type | Color | Hex | Icon |
|-----------|-------|-----|------|
| Combat | Crimson Red | `#FF4A4A` | Sword |
| Event | Royal Purple | `#9B59B6` | Question mark |
| Shop | Gold | `#FFD700` | Coin |
| Boss | Golden Crown | `#FF8C00` | Crown |
| Locked/Future | Slate Gray | `#4A5568` | — |
| Completed | Dimmed type color | 50% brightness | Checkmark |
| Failed | Desaturated + Red X | — | Broken node |
### Node Visual States
| State | Treatment |
|-------|-----------|
| **Current** | Full opacity, type color, white 3px border, pulse animation (1.0x–1.05x, 1.5s loop), outer glow ring |
| **Completed** | 40% opacity, grayscale tint, no glow, checkmark overlay |
| **Failed** | 30% opacity, desaturated, crack texture, red X overlay |
| **Future/Locked** | 25% opacity, no edges visible |
| **Future/Revealed** | 70% opacity, type color, dashed interactive edges |
| **Boss** | Full opacity, 1.3x scale, crown icon, amber/gold particle aura (#FF8C00) |
### Track Layout
- Vertical orientation, Boss at top (fixed beacon glow), Node 1 at bottom
- Current node centered in viewport; past nodes slide up and compress (0.9x scale per node above)
- Edges: 2px type-colored lines, dashed when active, solid when locked
- All particle shapes: triangles, diamonds, hexagons only — no organic curves
### Audio Style
- Sounds: clean sine/triangle waves — digital-mathematical, not organic
- SFX duration: 100–400ms typical
- Ambient: C major chord drone/pad during track exploration
- Boss entry: dedicated boss music stinger
### Accessibility
- Color + iconography always paired (color alone never conveys type)
- Loss flash 150ms at 30% opacity — accompanied by a brief screen shake; **an accessibility toggle in Settings allows this flash to be replaced with a slow fade (500ms)** for players with photosensitive concerns
- Boss Defeated particle shower (64+ hexagonal particles) — **an accessibility toggle in Settings reduces particle count to 16** for players with visual sensitivity
- Audio cues have visual alternatives (state changes, screen flashes)
- High-contrast mode: node border brightens to 4px white
- **Colorblind differentiation**: Failed state uses a distinct shape treatment (jagged/broken frame outline) in addition to desaturated color + red X, so it is distinguishable from Completed without relying on color perception. Boss node aura uses golden-orange (#FF8C00) rather than crimson to differentiate from Combat node red (#FF4A4A); crown icon and particle aura provide additional differentiation.
- **Node state opacity minimum**: Future/Locked state uses minimum 25% opacity (not 15%) so it remains visible rather than appearing as blank space.
- **Inventory access**: Player can view (but not modify) inventory and Gold/Coin balance from the Node Map Screen at any time. Modification is restricted to Assembly Phase only. This enables informed node choice decisions without violating assembly-phase-only build modification.
## UI Requirements
### Node Map Screen
**Layout**: Full-screen vertical scrollable node track.
- Boss node fixed at top with persistent beacon glow.
- Current node centered vertically.
- Past nodes stacked above (dimmed, compressed 0.9x per node).
- Future nodes hidden below fold.
**Node Card** (per node):
- Size: ~80×80px base, Boss 1.3x (104×104px)
- Content: type icon (sword/question/coin/crown), node index number
- Border: 3px white on current, type-colored on revealed, none on locked
- Background: type color fill
**Edge Display**:
- Lines connecting current node to 2 revealed destinations
- Dashed while unchosen, solid after selection
- Type-colored
**Run Progress HUD**:
- Top-left: Run index badge ("Run #3")
- Top-right: Gold counter and Coin counter (displayed separately with distinct icons; tooltip on hover explains each)
### Node Choice Overlay
**Trigger**: Appears when entering `NodeReveal` state after Assembly Phase.
**Content**:
- Title: "Choose Your Path" (or equivalent)
- Two node cards displayed horizontally, each showing node type + type icon
- Cards highlight on hover; selection locks on click
- "Locked in" confirmation animation on selection
**Constraints**:
- No third option visible
- Player cannot advance without choosing
- ESC/Cancel not supported — choice is mandatory
### Assembly Phase Screen
**Trigger**: Auto-enters after any node resolves.
**Content**:
- Tower slots (current tower configs, 3 component slots each)
- Inventory grid (all owned components)
- Repository grid (all stored components)
- **Next Node Preview**: The 2 outgoing edge destinations from the completed node are displayed on-screen during Assembly Phase, showing each destination's node type and index. This allows the player to optimize their tower build based on known upcoming challenges — matching the "preparing and adapting" Player Fantasy.
- "Ready" button (bottom-right, large, pulsing until confirmed)
**Interactions**:
- Drag components between inventory and tower slots
- Click "Ready" to confirm and advance
- No time limit in default configuration (tunable)
**Empty State**:
- When inventory is empty: tower slots show placeholder silhouettes (dotted outline) with "Empty" label
- "Ready" button is still present and functional when inventory is empty — it pulses to indicate action is available
- Repository grid shows empty state with "No stored components" message
### Run End Screen
**Trigger**: Appears on `RunEnd` state.
**Variants**:
- **Victory**: Golden theme, Boss defeated badge, Gold total, nodes cleared, "Return to Menu" button
- **Failure**: Desaturated/red theme, "Run Failed" message, furthest node reached, "Return to Menu" button
**No retry button** — runs are single-attempt
### Interaction Constraints
- No back button during node choice
- No undo after node selection is confirmed
- **Mandatory commitment confirmation**: A "This path cannot be undone." message must appear in the Node Choice Overlay before the player can confirm. This is not optional flavor text.
- ESC does not cancel Assembly Phase (must click "Ready")
- Player can **view** inventory and Gold/Coin balance from the Node Map Screen at any time; player can only **modify** inventory and tower builds during Assembly Phase
- **Next node types visible during Assembly Phase**: The 2 outgoing edge destinations are displayed on the Assembly Phase screen before the player clicks Ready, enabling informed build optimization
## Acceptance Criteria
### Run Structure
- **GIVEN** a new run, **WHEN** the player starts, **THEN** a 10-node track is generated with Node 10 as BossCombat, and node types follow the fixed sequence (Combat, Combat, Combat, Shop, Combat, Event, Combat, Shop, Combat, BossCombat) for the duration of the run.
- **GIVEN** the player is at NodeReveal, **WHEN** they see the outgoing edges, **THEN** exactly 2 destination nodes are displayed with their `RunNodeType` enum values visible. The full track (all 10 nodes) is visible from the run start.
- **GIVEN** the player has selected a node edge, **WHEN** they confirm, **THEN** the choice is locked, a modal dialog displays the text "This Path Cannot Be Undone" with a "Confirm" button, and previously visited nodes remain in Completed state and are not present in the NodeReveal choice set.
- **GIVEN** the player has completed Node N (N < 10), **WHEN** they are on the Node Choice Overlay or any subsequent screen, **THEN** no UI element, back button, or code path allows navigation back to Node N-1 or any previously completed node.
- **GIVEN** the player completes Node 10 (Boss), **WHEN** the run end resolves, **THEN** the run enters `RunEnd` state and does not return to the node track or generate additional nodes.
### Node Resolution
- **GIVEN** the player completes Combat node n, **WHEN** they win, **THEN** they receive Gold equal to `DRLevel.RewardGold` for the linked level, plus component drops as defined by the level's component drop table.
- **GIVEN** the player completes Event node, **WHEN** the event resolves, **THEN** the event's outcome modifiers (Gold delta, HP delta, buffs/debuffs) are reflected in the player's run state immediately, the Event UI is dismissed, and the transition to Assembly Phase begins within 2 seconds.
- **GIVEN** the player completes Shop node, **WHEN** they exit the shop, **THEN** all purchases and sales are committed to the player's inventory and the UI transitions to Assembly Phase within 2 seconds.
- **GIVEN** the player opens a Shop node, **WHEN** they click "Leave" without making any purchases or sales, **THEN** no changes are made to the player's inventory or Gold, and the UI transitions to Assembly Phase.
- **GIVEN** the player completes Shop node 4, **WHEN** they later reach Shop node 8, **THEN** Shop node 8 functions normally with the same rules; there is no special mitigation or bonus for consecutive shop nodes.
### Combat Loss
- **GIVEN** the player loses Combat at any node (including Node 10), **WHEN** the loss is recorded, **THEN** the run ends immediately in failure. The run's Gold, Coin, Inventory, and TowerConfig are not written to Progression; the in-memory run state is cleared; and the player is placed on the RunEnd (Failure) screen.
- **GIVEN** the player loses at the Boss (Node 10), **WHEN** Node 10 resolves, **THEN** the Boss node does not fire `NodeCompleteEventArgs` with `CombatWon = true`, and no `DRLevel.RewardGold` or `BossBonus` is added to run state.
### Boss
- **GIVEN** the player defeats the Boss, **WHEN** Node 10 resolves, **THEN** they receive the Boss level's `DRLevel.RewardGold` plus `BossBonus = 200` gold.
- **GIVEN** the player faces the Boss, **WHEN** the Boss spawns, **THEN** `BossEffectiveHp = DRLevel.BaseHp × 2^(completedLoopCount) × (1 + 0.1 × nodesCompleted)`, where `completedLoopCount` is the number of completed boss cycles and `nodesCompleted` is the count of non-boss combat nodes cleared this run.
### Assembly Phase
- **GIVEN** the player is in Assembly Phase, **WHEN** the screen is displayed, **THEN** the 2 outgoing edge destinations from the completed node are visible on-screen, each showing its node type and index.
- **GIVEN** the player is in Assembly Phase, **WHEN** they click the "Ready" button, **THEN** the Assembly Phase ends, the Node Choice Overlay appears, and the player selects from the 2 already-revealed destinations.
- **GIVEN** the player has 0 components in inventory, **WHEN** they enter Assembly Phase, **THEN** the "Ready" button is enabled and clicking it proceeds to the next node without modification.
- **GIVEN** a node resolves (Combat victory, Event completed, Shop exited), **WHEN** the resolution completes, **THEN** Assembly Phase is entered automatically. The player is never required to take an action to trigger Assembly Phase entry.
- **GIVEN** the player is in Assembly Phase, **WHEN** they have not yet clicked "Ready", **THEN** the player may re-enter and modify tower configurations freely. The "Ready" button is the only mandatory action to proceed.
### Data Persistence
- **GIVEN** a completed run ending in victory, **WHEN** the run ends, **THEN** a subsequent read of `Progression.GetRunHistory()` returns an entry containing the Gold total, nodesCompleted count, and BossDefeated flag for this run, and the player is returned to main menu.
- **GIVEN** a new run starts, **WHEN** the player begins, **THEN** Gold, Coin, Inventory, and Tower configs are reset to `DRRunConfig.StartGold`, `DRRunConfig.StartCoin`, empty inventory, and default tower configs respectively.
### Cross-System
- **GIVEN** the player completes a Combat node, **WHEN** they win, **THEN** component drops are added to the player's inventory before the Assembly Phase UI is shown.
- **GIVEN** the player completes a Combat node, **WHEN** `NodeCompleteEventArgs` with `CombatWon = true` is dispatched, **THEN** the transition to Assembly Phase begins within 2 seconds of the event.
## Open Questions
### 1. Should Event Nodes Appear in the First 3 Nodes?
**Status**: ✅ RESOLVED — Option [C] adopted: Events restricted to positions 4–8. Node sequence updated accordingly (Events only at node 6 in Plain theme).
### 2. Does the Player See the Full Track at Run Start?
**Status**: ✅ RESOLVED — Option [A] adopted: Full track visible from run start. Player Fantasy updated to reflect this. All nodes visible, current node highlighted, past nodes dimmed.
### 3. Blocked Systems Before Node System Implementation
**Status**: ✅ RESOLVED — All blocking GDDs have been completed:
- **Shop System GDD** (`design/gdd/shop.md`) — Status: Designed. `ShopContext` contract and buy/sell behavior are defined.
- **Event System GDD** (`design/gdd/event-system.md`) — Status: Designed. `EventContext` contract and risk/reward resolution flow are defined.
- **Progression GDD** (`design/gdd/progression.md`) — Status: Designed. `RecordRunEnd()` and `GetLifetimeStats()` interfaces are defined.
+421
View File
@@ -0,0 +1,421 @@
# Progression (成长系统)
> **Status**: Designed
> **Author**: SepComet + agents
> **Last Updated**: 2026-04-29
> **Implements Pillar**: [To be defined in game-pillars.md — Progression serves the "collection and mastery" fantasy; pillar text not yet written]
## Overview
Progression is the **permanent state manager** that persists across runs. It owns the read/write of unlock state (themes, difficulty tiers, component pools) and the lifetime statistics record. It does not hold run-level state (Gold, Inventory, Tower configs — those live and die within a run). On every `RunEnd`, the Node System sends final run stats to Progression; Progression evaluates whether any unlock thresholds are crossed; if so, the player's unlock pool expands. The player sees this as "my account is more powerful" — new options available at the start of every subsequent run.
## Player Fantasy
**"Complete your collection. Fill every gap. Nothing left on the table."**
The Progression fantasy is the feeling of **relentless collection** — every run earns something toward a permanent expansion of what's possible. A component type unlocked, a difficulty tier cracked, a theme revealed. The next run the player opens, they notice the new option immediately and feel the game acknowledge their effort. The goal is to empty the unlock list — to have seen everything the game offers. This is the roguelike's fundamental pull: *the set of available tools today is larger than it was ten runs ago*.
The player should feel:
- **Driven by gaps** — the unlock list shows what's missing; completing a set feels like closing a circuit
- **Rewarded for exploration** — trying a new difficulty or theme unlocks more content as a side effect
- **Invested in permanence** — nothing earned is ever lost; the account is a record of everything accomplished
## Detailed Design
### Core Rules
**SR1. Data Persistence**: `ProgressionData` is a persistent save-file object. It is loaded at game start and saved after every `RecordRunEnd()` call. It holds: `UnlockedDifficulties`, `UnlockedThemes`, `UnlockedComponentPools`, `UnlockedStartingLoadouts`, `CompletionCounts`, and `LifetimeStats`.
**SR2. Unlock Evaluation Trigger**: On every `RunEnd` with `bossDefeated = true`, the Node System calls `Progression.RecordRunEnd(runStats)`. Progression evaluates all unlock conditions against the current `ProgressionData`. Unlocked items are added to the appropriate pool immediately and an `UnlockResult[]` is returned. Loss runs record stats only; no unlock evaluation.
**SR3. Difficulty Unlock Chain**: `Normal` is always unlocked. `Hard` unlocks when player defeats Boss on Normal. `Expert` unlocks when player defeats Boss on Hard. `Nightmare` unlocks when player defeats Boss on Expert.
**SR4. Theme Unlock**: Themes are parallel — any theme whose unlock condition is met becomes available. Player selects theme at run start from the New Game screen. Unlock condition per theme stored in `DRTheme.UnlockCondition`.
**SR5. Component Pool Unlock**: Component pools (rarity tiers) unlock based on difficulty and win-count conditions. Pools are additive — when a pool unlocks, its components become available in shop and drop tables. `DRComponentPool.UnlockCondition` defines the gating condition per pool.
**SR6. Starting Loadout Selection**: At run start, player chooses one loadout from all `UnlockedStartingLoadouts`. Starting bonuses are applied immediately when the run begins: gold added to `PlayerInventoryComponent.Gold`, pre-built towers assembled and placed in roster.
**SR7. Lifetime Stats**: `LifetimeStats` is updated on every run end (win or loss): `TotalRunsStarted++`, `TotalGoldEarned += gold`, `FurthestNodeReached = max(previous, nodesCompleted)`, etc. Win stats updated only on `bossDefeated = true`.
**SR8. Unlock Feedback**: On successful unlock evaluation, a `UnlockedEventArgs` is fired. UI listens and shows an animated toast popup listing the newly unlocked item(s). The toast appears on the RunEnd victory screen before returning to menu.
### States and Transitions
Progression is **purely passive** — it has no runtime state machine. It exposes interfaces that other systems call.
| State | Description |
|-------|-------------|
| `Loaded` | Save file loaded into memory. Progression data is current. |
| `Evaluating` | `RecordRunEnd()` is executing; unlock conditions are being checked. |
| `Dirty` | New unlocks found; waiting for save. |
| `Saved` | Dirty state persisted to disk. |
*No user-facing state machine — UI screens that display Progression data (Profile, New Game) are owned by the UI layer, not by Progression.*
### Interactions with Other Systems
| System | Direction | Interface |
|--------|-----------|------------|
| **Node System** | Receives from | `RecordRunEnd(runStats)` — called on every run end (win or loss). `runStats` contains `{goldEarned, nodesCompleted, bossDefeated, coinsEarned, componentsDropped}`. |
| **Shop System** | Reads from | `GetUnlockedComponentPools()` — shop uses this to determine which component rarities appear in `BuildShopGoods()`. |
| **UI / New Game Screen** | Reads from | `GetUnlockedDifficulties()`, `GetUnlockedThemes()`, `GetUnlockedStartingLoadouts()` — populate run setup UI. |
| **UI / Profile Screen** | Reads from | `GetLifetimeStats()` — displays career statistics. |
| **UI / Run End Screen** | Receives from | `OnUnlockedEventArgs` — triggers toast popup for new unlocks. |
| **Event System** | Soft read | Event outcomes do not directly affect Progression. Events may reference `GetLifetimeStats()` for conditional text. |
## Formulas
### 1. UnlockEvaluation — Run-End Unlock Check
The `UnlockEvaluation(runStats)` function is called by `Progression.RecordRunEnd()` on every winning run. It checks all locked unlockables and returns a list of `UnlockResult` objects for newly unlocked items.
**Function signature:**
```
UnlockResult[] UnlockEvaluation(RunStats runStats)
```
**Variables:**
| Variable | Symbol | Type | Range | Description |
|----------|--------|------|-------|-------------|
| bossDefeated | b | bool | {true, false} | Whether Boss was defeated this run |
| difficulty | d | DifficultyType | Normal..Nightmare | Difficulty at run start |
| totalWins | w | int | ≥ 0 | Cumulative wins across all runs |
| totalEnemiesDefeated | e | int | ≥ 0 | Cumulative enemies killed (lifetime) |
| nodesCompleted | n | int | 0–10 | Nodes cleared this run |
**Output Range:** 0 to N newly unlocked items per run. In practice, typically 0–2.
---
**A. Difficulty Unlock (chain)**
```
nextDifficulty(d) = {
Normal → Hard,
Hard → Expert,
Expert → Nightmare,
Nightmare → null (no next tier)
}
IF b == true AND nextDifficulty(d) != null THEN
unlock(nextDifficulty(d))
```
**Example:** Player defeats Boss on Normal → `nextDifficulty(Normal) = Hard` → Hard is unlocked.
---
**B. Theme Unlock (per-theme condition from DRTheme)**
```
FOR each locked theme T:
IF T.condition.type == WinOnDifficulty
AND b == true AND d >= T.condition.targetDifficulty
OR T.condition.type == TotalWins
AND w >= T.condition.targetWins
OR T.condition.type == Mixed
AND b == true AND d >= T.condition.minDifficulty
AND w >= T.condition.minWins
THEN unlock(T)
```
**Example:** Frost theme has condition `WinOnDifficulty(Normal)`. Player wins on Normal → Frost unlocked.
---
**C. Component Pool Unlock (rarity tiers)**
```
poolUnlocked(r, d, w) = (
(r == White) OR
(r == Green AND d >= Normal) OR
(r == Blue AND d >= Hard AND w >= 2) OR
(r == Purple AND d >= Expert AND w >= 5) OR
(r == Red AND d >= Nightmare AND w >= 10)
)
FOR each locked component pool P:
IF poolUnlocked(P.rarity, d, w) == true THEN unlock(P)
```
**Example:** Player wins on Hard (d=Hard, w now = 1). Blue pool: `d >= Hard (true), w >= 2 (false)` → not yet. After 2 total wins: Blue pool unlocks.
---
**D. Starting Loadout Unlock (milestone conditions)**
```
FOR each locked loadout L:
IF L.condition.type == TotalWins AND w >= L.condition.targetWins
OR L.condition.type == EnemiesDefeated AND e >= L.condition.targetEnemies
OR L.condition.type == NodesCompleted AND n >= L.condition.targetNodes
OR L.condition.type == Combined AND w >= L.condition.wins
AND e >= L.condition.enemies
THEN unlock(L)
```
**Example:** "Starter Pack" loadout has condition `TotalWins(3)`. After 3rd win → unlocked.
---
### 2. LifetimeStats.Update
Called on every `RecordRunEnd()` for both win and loss runs.
```
LifetimeStatsUpdate(runStats):
// All runs
totalRunsStarted += 1
totalGoldEarned += runStats.goldEarned
furthestNodeReached = max(furthestNodeReached, runStats.nodesCompleted)
totalNodesCompleted += runStats.nodesCompleted
totalEnemiesDefeated += runStats.enemiesDefeatedThisRun
totalComponentsCollected += runStats.componentsCollectedThisRun
// Win runs only (bossDefeated == true)
IF runStats.bossDefeated == true:
totalWins += 1
winsByDifficulty[runStats.difficulty] += 1
winsByTheme[runStats.theme] += 1
bossesDefeated += 1
```
**Output:** `LifetimeStats` updated in-place. No return value.
---
### 3. StartingBonusResolve
Called at run start when player selects a starting loadout.
```
StartingBonus StartingBonusResolve(loadoutId):
LOADOUT = DRStartingLoadout[loadoutId]
IF LOADOUT == null:
return StartingBonus { goldAmount=0, prebuiltTower=null }
goldAmount = LOADOUT.goldBonus
IF LOADOUT.hasPrebuiltTower == true:
prebuiltTower = AssembleTower(LOADOUT.prebuiltTowerComponents)
ELSE:
prebuiltTower = null
return StartingBonus { goldAmount, prebuiltTower }
```
**Edge case:** If `loadoutId` not found → return `{goldAmount=0, prebuiltTower=null}`. If tower components unavailable → return `{goldAmount=LOADOUT.goldBonus, prebuiltTower=null}`.
---
### 4. Starting Gold Cap Check
When `StartingBonusResolve` returns a gold amount, it is added to the run's starting gold, which is then subject to `MaxPlayerGold` (9999) per the Shop GDD.
```
effectiveStartingGold = Min(defaultStartGold + goldAmount, MaxPlayerGold)
```
`defaultStartGold` comes from `DRRunConfig.StartGold`.
## Edge Cases
- **If player loses any run**: No unlock evaluation. LifetimeStats still updated (run count, gold, furthest node). Losses contribute to stats but not unlocks.
- **If all unlock conditions already satisfied**: `UnlockEvaluation` returns empty array. No-op. Player keeps their unlocks.
- **If save file is corrupted or missing on load**: Initialize fresh `ProgressionData` with defaults (Normal difficulty only, Plain theme only, no bonus loadouts). Log error.
- **If `RecordRunEnd()` is called twice for the same run**: Deduplicated by run ID. Only first call processes.
- **If `totalEnemiesDefeated` or `totalComponentsCollected` would overflow**: Use `long` (Int64) for these cumulative fields. `int` for other counters.
- **If `runStats.goldEarned < 0`**: Treat as 0, log error. Gold should never be negative.
- **If pre-built tower components are not yet unlocked**: Return `{goldAmount=LOADOUT.goldBonus, prebuiltTower=null}`. Log warning. Player can still start run.
- **If `defaultStartGold + goldBonus > MaxPlayerGold` (9999)**: Cap at 9999. Excess discarded.
- **If Alt+F4 mid-run (no RunEnd dispatched)**: No stats recorded for that partial run. Next run starts clean.
- **If two unlocks trigger in same run**: Both appear in the `UnlockResult[]`. `UnlockedEventArgs` fired once with all new unlocks. Player sees both in toast.
- **If difficulty enum is invalid in runStats**: Skip difficulty unlock evaluation. Log error. Other unlocks (themes, pools) still processed.
- **If player wins on Hard but has not unlocked Hard (impossible by SR3)**: `UnlockEvaluation` still processes — the difficulty field in runStats reflects what was played. Player having unlocked Hard is pre-checked at run start, not re-checked at evaluation.
- **If pre-built tower loadout selected but player has no inventory space**: Pre-built tower is placed directly into combat roster (slot 1), not inventory. No inventory space required.
## Dependencies
### Upstream Dependencies (what Progression depends on)
| System | Type | Interface | Status |
|--------|------|-----------|--------|
| **Node System** | Hard | `RecordRunEnd(runStats)` is called by the Procedure layer after every run end. `runStats` contains `{difficulty, theme, goldEarned, nodesCompleted, bossDefeated, coinsEarned, componentsDropped}`. No run-level state is stored in Progression between calls. | GDD exists (`design/gdd/node-system.md`) — In Review |
| **Shop System** | Soft | Progression reads `GetUnlockedComponentPools()` to determine which component rarities appear in shop. If shop needs to filter by rarity, it calls this method. | GDD exists (`design/gdd/shop.md`) — In Design |
### Downstream Dependents (what depends on Progression)
| System | Type | Interface | Status |
|--------|------|-----------|--------|
| **Node System** | Hard | Node System's `RunEnd` state cannot persist without Progression. All acceptance criteria involving `Progression.RecordRunEnd()` and `Progression.GetLifetimeStats()` are blocked until this GDD is completed. | GDD exists — In Review; blocked by this GDD |
| **Shop System** | Soft | Shop may read `GetLifetimeStats()` for conditional text or future features (e.g., "you've spent X gold across all runs"). Not currently required. | GDD exists |
| **UI / New Game Screen** | Hard | Populates difficulty, theme, and starting loadout selection from `GetUnlockedDifficulties()`, `GetUnlockedThemes()`, `GetUnlockedStartingLoadouts()`. | Pending implementation |
| **UI / Profile Screen** | Hard | Displays lifetime statistics from `GetLifetimeStats()`. | Pending implementation |
| **UI / Run End Screen** | Hard | Listens for `OnUnlockedEventArgs`. Shows toast popup listing new unlocks on victory. | Pending implementation |
| **Event System** | Soft | Events may read `GetLifetimeStats()` for conditional flavor text (e.g., "You've won X times"). Not required for MVP. | GDD exists |
### Bidirectional Consistency Check
- [x] Node System → upstream (writes to Progression via `RecordRunEnd`) ✅
- [x] Progression → downstream to Node System (Node System blocked until Progression exists) ✅
- [x] Shop System → reads component pool unlock state ✅
- [ ] Event System → soft dependency, no hard coupling ✅
## Tuning Knobs
All unlock conditions are data-table-driven. No code changes are required to add or modify unlock conditions.
### Data-Driven Tuning Tables
| Table | Controls | Designer Knobs |
|-------|----------|---------------|
| `DRDifficultyTier` | Difficulty unlock chain | `nextTierId`, `unlockConditionType`, `unlockThreshold` |
| `DRTheme` | Theme unlock conditions | `unlockConditionType`, `targetDifficulty`, `targetWins`, `minWins`, `minDifficulty` |
| `DRComponentPool` | Component pool rarity gates | `rarity`, `minDifficulty`, `minWins` |
| `DRStartingLoadout` | Starting loadout conditions and bonuses | `conditionType`, `targetWins`, `targetEnemies`, `targetNodes`, `goldBonus`, `hasPrebuiltTower`, `prebuiltTowerComponents` |
### Runtime Tuning Knobs
| Knob | Default | Safe Range | Extreme: Too Low | Extreme: Too High |
|------|---------|-----------|-----------------|------------------|
| `MaxPlayerGold` | 9999 | 5000–99999 | Starting bonuses feel large; shop loses tension | Gold feels pointless; player never feels rich |
| `DefaultStartGold` (`DRRunConfig.StartGold`) | varies | 0–5000 | Player starts too poor; first shop feels bad | Player starts too rich; first shop trivial |
| `DRStartingLoadout.goldBonus` | varies | 0–5000 | Bonus too low; no meaningful start change | Bonus too high; shop becomes irrelevant |
| `DRComponentPool.minWins` | varies | 0–50 | Higher pools accessible too early; power spike | Higher pools locked too long; mid-game feels boring |
| `DRTheme UnlockDifficulty` | varies | varies | Easier themes → faster content exhaustion | Harder themes → grindy; "one more run" becomes frustrating |
### Knob Interactions
- **Starting gold bonus + MaxPlayerGold**: If `defaultStartGold + goldBonus > MaxPlayerGold`, excess is silently discarded. Ensure bonuses respect the cap.
- **Difficulty unlock + Component pool**: When a new difficulty tier unlocks, the component pool for that tier becomes available. Ensure pool content (components) exists before the difficulty is unlockable.
- **Theme unlock conditions**: Some themes reference `minWins`. Ensure the intended playtime before unlocking matches the expected pacing curve.
## Visual/Audio Requirements
Progression is a data-layer system with no inherent visual or audio identity. The player's interaction with Progression is mediated entirely through UI screens (New Game, Profile, Toast). No dedicated VFX or audio events are owned by the Progression system itself.
However, the **Toast Popup** (Section UI Requirements) does have visual requirements: the unlock notification should use the game's geometric shape vocabulary (diamonds, triangles, hexagons) consistent with the shop rarity shimmer and node completion VFX.
**Visual Style for Toast Popup:**
- Shape: diamond outline frame around unlock icon
- Rarity color coding: theme unlocks use theme's accent color; difficulty unlocks use difficulty's color; component pool unlocks use the newly available rarity's color
- Entry animation: scale 0.8x → 1.0x, 250ms ease-out
- Particle burst on unlock: geometric particles matching the unlock category
**Audio for Toast Popup:**
- Ascending 3-note arpeggio (C4-E4-G4) at volume 0.5, 200ms
- Distinct from shop purchase arpeggio (which is rarity-keyed and longer)
## UI Requirements
### New Game Screen (Run Setup)
**Trigger**: Player selects "New Run" from main menu.
**Layout**:
- Title: "New Run"
- Difficulty row: horizontal list of unlocked difficulties; locked ones shown as silhouettes with lock icon and tooltip showing unlock condition
- Theme row: horizontal grid of theme cards; locked themes hidden or shown as silhouettes
- Starting Loadout row: horizontal list of owned loadouts; radio-button selection (one active at a time)
- Start Run button: bottom-center, large, disabled until at least one valid combination is selected
**Unlock Feedback on Screen:**
- When a new unlock becomes available between when the player last viewed New Game and pressed Start, the newly available item pulses briefly (once) to draw attention
**Constraint**: Player cannot select a locked difficulty or theme. UI enforces this; no runtime validation needed.
---
### Profile Screen (Lifetime Statistics)
**Trigger**: Player selects "Profile" or "Stats" from main menu.
**Layout**:
- Header: total runs, total wins, win rate (percentage)
- Primary stats grid: Total Gold Earned, Furthest Node Reached (1–10), Bosses Defeated
- Secondary stats (tabbed or collapsible): Components Collected, Wins by Difficulty (table), Wins by Theme (table)
- No editing — display only
**Constraint**: All stats are read from `GetLifetimeStats()`. No modification is possible from this screen.
---
### Toast Popup (Unlock Notification)
**Trigger**: `OnUnlockedEventArgs` fires on `RecordRunEnd` with non-empty `UnlockResult[]`.
**Layout**:
- Position: bottom-center of screen, above Run End screen content
- Content: unlock category icon (geometric shape), unlocked item name, "UNLOCKED" label
- Shape: diamond outline frame
- Entry: scale 0.8x → 1.0x, 250ms ease-out
- Exit: fade out over 200ms on click or after 5 seconds
**Constraints**:
- Multiple unlocks in same run: show one toast per item, staggered 300ms apart
- If player clicks away immediately, toast dismisses immediately — do not block
---
### Accessibility
- All unlock conditions shown as text in tooltip (no icon-only indicators)
- Toast is keyboard-accessible (Enter/Space to dismiss)
- Profile screen stats are screen-reader friendly with labeled values
## Acceptance Criteria
### Data Persistence
- **GIVEN** a new player starts the game for the first time, **WHEN** they begin a run, **THEN** only Normal difficulty, Plain theme, and the default starting loadout are available.
- **GIVEN** the player's save file is corrupted or missing, **WHEN** the game loads, **THEN** a fresh `ProgressionData` is initialized with defaults (Normal only, Plain only, no bonus loadouts) and no crash occurs.
### Unlock Evaluation
- **GIVEN** a player completes a run with `bossDefeated=false` (loss), **WHEN** `RecordRunEnd` is called, **THEN** `totalRunsStarted` increments, `totalGoldEarned` increases by `goldEarned`, `furthestNodeReached` and `totalNodesCompleted` update correctly, but no unlock evaluation occurs.
- **GIVEN** a player defeats the Boss on Normal difficulty for the first time, **WHEN** `RecordRunEnd` is called with `bossDefeated=true` and `difficulty=Normal`, **THEN** Hard difficulty is unlocked and appears in the New Game screen.
- **GIVEN** a player defeats the Boss on Hard difficulty, **WHEN** `RecordRunEnd` is called, **THEN** Expert difficulty is unlocked.
- **GIVEN** a player defeats the Boss on Expert difficulty, **WHEN** `RecordRunEnd` is called, **THEN** Nightmare difficulty is unlocked.
### Theme Unlock
- **GIVEN** a theme has `UnlockCondition = WinOnDifficulty(Normal)` and the player defeats the Boss on Normal, **WHEN** `RecordRunEnd` completes, **THEN** that theme appears in the theme selection on the New Game screen.
- **GIVEN** a theme has `UnlockCondition = TotalWins(5)` and the player earns their 5th total win, **WHEN** `RecordRunEnd` completes, **THEN** that theme becomes available in the New Game screen.
### Component Pool Unlock
- **GIVEN** a player wins on Normal difficulty for the first time, **WHEN** `RecordRunEnd` completes, **THEN** the Green component pool is unlocked and Green rarity components appear in the shop's offered goods.
- **GIVEN** a player has 2+ wins and wins on Hard difficulty, **WHEN** `RecordRunEnd` completes, **THEN** the Blue component pool is unlocked.
### Starting Loadout
- **GIVEN** a player has 3 total wins and has unlocked Hard, **WHEN** they complete a run on Hard difficulty, **THEN** any Starting Loadout with condition `TotalWins(3)` becomes unlocked and visible in the loadout selection.
- **GIVEN** a player selects a Starting Loadout with `goldBonus=500`, **WHEN** the run begins, **THEN** `PlayerInventoryComponent.Gold` equals `defaultStartGold + 500`, capped at `MaxPlayerGold` (9999).
- **GIVEN** a player selects a Starting Loadout with a pre-built tower, **WHEN** the run begins, **THEN** slot 1 of the combat roster contains the pre-built tower assembled from the loadout's components.
- **GIVEN** a player's pre-built tower loadout references components not yet unlocked, **WHEN** `StartingBonusResolve` is called, **THEN** the gold bonus is applied, `prebuiltTower` is `null`, and a warning is logged.
### Lifetime Stats (All Runs)
- **GIVEN** a player completes 3 runs with `nodesCompleted` of 4, 7, and 5 respectively, **WHEN** `LifetimeStats` is queried, **THEN** `totalNodesCompleted` equals 16.
- **GIVEN** a player Alt+F4s mid-run without triggering `RunEnd`, **WHEN** the game restarts, **THEN** no stats from that partial run appear in `LifetimeStats`.
### Lifetime Stats (Win Runs Only)
- **GIVEN** a player wins a run, **WHEN** `RecordRunEnd` is called with `bossDefeated=true`, **THEN** `totalWins` increments, `winsByDifficulty[difficulty]` increments, `winsByTheme[theme]` increments, and `bossesDefeated` increments.
- **GIVEN** `totalEnemiesDefeated` would exceed `int.MaxValue` with a large cumulative value, **WHEN** `LifetimeStats.Update` is called, **THEN** the field uses `long` (Int64) and does not overflow.
### Unlock Feedback
- **GIVEN** a player wins a run that triggers two new unlocks simultaneously, **WHEN** `RecordRunEnd` completes, **THEN** the Run End screen shows a toast popup listing all newly unlocked items.
### Edge Cases
- **GIVEN** `RecordRunEnd` has already been called for run ID "abc123", **WHEN** it is called again with the same run ID, **THEN** `LifetimeStats` only increments once and only one unlock evaluation occurs.
- **GIVEN** `runStats.goldEarned` is negative (e.g., −100) due to an upstream bug, **WHEN** `LifetimeStats.Update` is called, **THEN** `totalGoldEarned` increases by 0, not −100, and the error is logged.
## Open Questions
### 1. Standalone Binary Achievements
**Status**: OPEN — Should there be standalone binary achievements separate from the 4 unlock categories (e.g., "Defeat 100 Bosses", "Collect 1000 Components", "Win on all difficulties")? These would be display-only badges with no gameplay effect. Currently all unlocks are gated by the 4 categories. Adding achievements as a 5th category would increase content without adding mechanical variety.
### 2. Cloud Save Sync
**Status**: OPEN — Any cloud sync considerations for `ProgressionData` across devices? Single-player desktop game — likely local-only. If multiplayer or cross-device play is ever added, ProgressionData would need serialization parity and conflict resolution.
### 3. Starting Loadout Components Catalog
**Status**: OPEN — When a pre-built tower loadout references `prebuiltTowerComponents`, which component pool do those components draw from? If the referenced components are from a pool the player hasn't unlocked yet, `StartingBonusResolve` returns null tower (per edge case). Should pre-built loadouts source from a special "Starter" component pool that's always available, rather than the normal pool?
### 4. "First Time" Callback to Audio/VFX
**Status**: OPEN — The GDD specifies a Toast popup for unlock feedback. Should there also be a dedicated "first time ever" callback signal that the audio system can hook for a distinct sound on the very first unlock of any category? Or is the Toast audio sufficient?
@@ -0,0 +1,47 @@
# node-system.md — Review Log
## Review — 2026-04-29 — Verdict: MAJOR REVISION NEEDED (first pass)
**Scope signal**: XL
**Specialists**: game-designer, systems-designer, qa-lead, ux-designer, creative-director
**Blocking items**: 8 | **Recommended**: 9
**Summary**: First review found critical spec/implementation contradiction (node types are fixed in code, not randomized as spec stated), arithmetic impossibility in TotalGold tables (3 inconsistent values: 830/750/670), missing Shop/Event system definitions, and LossPenalty system completely unimplemented. Creative director synthesis concluded this constitutes "fundamental design integrity failure" requiring major revision.
**Prior verdict resolved**: N/A (first review)
---
## Review — 2026-04-29 — Verdict: NEEDS REVISION (post-revision)
**Scope signal**: XL
**Specialists**: game-designer, systems-designer, qa-lead, ux-designer, creative-director
**Blocking items**: 0 (resolved) | **Recommended**: 9 (partially addressed)
**Summary**: All 8 blocking items resolved in-session. Key changes: spec updated to fixed node sequence (matching implementation), TotalGold rebuilt using DRLevel.RewardGold, Position 9 probability gap fixed, LossPenalty marked [NOT YET IMPLEMENTED], all 7 broken ACs corrected, BaseHp=0 design resolved (any loss = run end), architecture references fixed. Remaining recommended items: Shop/Event systems need separate GDDs, accessibility improvements pending.
**Prior verdict resolved**: Yes — original MAJOR REVISION NEEDED addressed; design now internally consistent and implementable pending Shop/Event GDDs.
---
## Review — 2026-04-29 — Verdict: MAJOR REVISION NEEDED (second review — pre-revision)
**Scope signal**: L
**Specialists**: game-designer, systems-designer, qa-lead, creative-director
**Blocking items**: 6 | **Recommended**: 8
**Summary**: Second review found economy mathematically broken (Boss=70% total gold, shop decorative), fantasy contradiction (enemy composition never revealed to player), BaseHp structurally meaningless (any loss=instant run end), AC5/7/9/10/13 not independently testable, Shop/Event code exists but no GDD, stale NodeComponent reference in diagram. All 6 blocking items resolved in-session.
**Prior verdict resolved**: Yes — first NEEDS REVISION addressed; new issues were economy balance, fantasy consistency, and testability.
---
## Review — 2026-04-29 — Verdict: NEEDS REVISION (third review — post revision)
**Scope signal**: L
**Specialists**: game-designer, systems-designer, qa-lead, ux-designer, creative-director
**Blocking items**: 3 | **Recommended**: 6
**Summary**: Third review found 3 blocking issues: (1) TotalGold range stated as 780 but table sums to 1100 — fixed to 1100; (2) BossEffectiveHp GDD formula (linear × LoopScaling, 5× cap) didn't match code (exponential × 2^n, no cap) — GDD reconciled to match code; (3) Assembly Phase forced blind commitment before seeing next node types, contradicting stated Player Fantasy — fixed by showing Next Node Preview on Assembly screen before Ready. All 3 blocking items resolved in-session. Remaining recommended items: 2-choice differentiation unspecified, Boss scaling disconnected from run performance, Event node design unspecified, accessibility gaps, "within 2 seconds" unenforceable, single-loss zero-partial-rewards design.
**Prior verdict resolved**: Yes — second MAJOR REVISION NEEDED addressed; new issues were economy arithmetic, spec/code mismatch, and Assembly Phase UX flow.
---
## Review — 2026-04-30 — Verdict: MAJOR REVISION NEEDED (fourth review)
**Scope signal**: XL
**Specialists**: game-designer, systems-designer, economy-designer, qa-lead, ux-designer, creative-director
**Blocking items**: 8 | **Recommended**: 9
**Summary**: Fourth review found 8 blocking issues: (1) Both edges lead to identical node types — cosmetic choice, not tactical (all 5 specialists converged); (2) Early economy starvation — 300g first shop arrival, Red costs 200-220g, shop non-functional; (3) Boss difficulty completely uncorrelated with run performance (nodesCompleted has zero effect); (4) Core Rules vs UI Requirements contradiction on Assembly Phase entry; (5) TotalGold n=1-9 ambiguous (count vs indices), Boss loop count domain 0-∞ but clamped; (6) Coin currency has no documented sink; (7) AC coverage gaps for Core Rules 3, 5, 9; (8) Boss VFX color crimson (Combat color) contradicts Color Palette gold/amber. All 8 resolved in-session. Key changes: edge divergence clarified as level-variant model; first shop tiered to White/Green only; Boss formula extended with (1 + 0.1 × nodesCompleted) multiplier; Assembly Phase set to auto-enter; Boss VFX color reconciled to amber/gold; Coin sink clarified (CombatNode intra-combat tower building); 5 new ACs added. Re-review in fresh session recommended.
**Prior verdict resolved**: Yes — third NEEDS REVISION addressed; new critical issues were false-choice architecture, economy starvation, Boss uncorrelation, and spec contradictions.
+509
View File
@@ -0,0 +1,509 @@
# Shop System
> **Status**: In Design
> **Author**: SepComet
> **Last Updated**: 2026-04-29
> **Implements Pillar**: [To be designed]
## Overview
The Shop system is a **run-time economy service** that surfaces component goods to the player at designated Shop nodes during a run. It reads available component templates from `InventoryGenerationComponent.BuildShopGoods()`, resolves per-item pricing from `DRShopPrice`, and processes purchase transactions against the player's current gold via `PlayerInventoryComponent`. Purchased components are instantiated with stable `InstanceId`s, applied Tags, and Endurance, then added to the player's inventory. The Shop is not a passive display — it is a **tactical decision point** where the player evaluates their gold reserves and build gaps against the current component offering, deciding whether to invest or conserve for future nodes. Shop nodes appear in the node graph as a distinct node type; their placement frequency and pricing are the primary levers for run-level economy balance.
## Player Fantasy
**"Your gold is your ammunition. Spend it like you mean it."**
The Shop fantasy is the feeling of **tactical urgency and deliberate investment**. The player has earned gold from the last combat — the question isn't "can I afford it?" but "do I need it right now, or will I need it more later?" Every component purchase is a bet on the future: this Muzzle closes a gap in my build, this Bearing makes my existing towers better, this Base hedges against an unknown threat two nodes from now. The shop should feel like a **weaponised pause** — a moment of calm strategy between fights, where the stakes are real because gold is finite and so are the shop's offerings.
The player should feel:
- **Assessing scarcity** — the shop doesn't have everything; what it has is what you get this run
- **Valuing anticipation** — saving gold for a future shop node, or spending it now on a component that "completes" a tower, both feel like valid strategies
- **Experiencing consequence** — a spent gold coin is gone; the tower it enabled (or didn't) is the consequence
## Detailed Design
### Core Rules
**SR1. Shop Node Entry**: When the player navigates to a Shop node in the node graph, `InventoryGenerationComponent.BuildShopGoods(goodsCount=6, runSeed, sequenceIndex)` generates a fixed pool of offered goods. The pool is deterministic for a given `runSeed + sequenceIndex` — revisiting the same shop node with the same seed produces the same goods. The pool is fixed for this visit once generated.
**SR2. Shop Offerings Display**: Each `GoodsItemRawData` is displayed as a component card showing: type (Muzzle/Bearing/Base), name, rarity, Tags, description, and buy price. `IsPurchased = true` items are removed from display for this visit.
**SR3. Purchase Transaction** (`PlayerInventoryTradeService.TryPurchaseComponent`): Player selects a component and pays the pre-rolled `GoodsItemRawData.Price`. `TryConsumeGold(price)` deducts gold; on success, `InventoryCloneUtility.CloneXxxComp()` clones the component with a new stable `InstanceId` and adds it to inventory. `IsPurchased` is set to `true`.
**SR4. Gold Cap**: Player gold is capped at `MaxPlayerGold` (hard cap). `TryConsumeGold` fails if insufficient; `AddGold` caps at `MaxPlayerGold`. Excess earned gold is lost.
**SR5. Shop Visit Exit**: Player may exit at any time via "Leave". No purchase is mandatory.
**SR6. Selling (via RepoForm)**: `PlayerInventoryTradeService.TrySellItems(itemIds)` processes sales. Sale price = midpoint of `MinPrice..MaxPrice` for the component's rarity. Assembled components must be disassembled first. Towers in the combat roster may not be sold.
**SR7. Determinism**: Shop goods are generated via `InventoryGenerationRandomContext(runSeed, sequenceIndex, Shop, goodsIndex)`. Same inputs always produce the same goods and prices — run reproducibility is guaranteed.
### States and Transitions
Shop has no persistent state machine of its own — it is entered and exited via the Node System. State is held in the `GoodsItemRawData.IsPurchased` flags and `PlayerInventoryComponent.Gold`.
**Shop Visit States**:
| State | Description | Exits |
|-------|-------------|-------|
| `Browsing` | Player is viewing the shop; no purchase attempted yet | → `PurchaseConfirmed` on successful buy; → `Left` on "Leave" |
| `PurchaseConfirmed` | A purchase just succeeded; shop display updates (item marked purchased) | → `Browsing` — player can continue shopping |
| `Left` | Player exited via "Leave" or all 6 items purchased | → (shop phase ends; Node System advances) |
**Player Gold States**:
| State | Condition | Display |
|-------|-----------|---------|
| `CanAfford` | `Gold >= min(shopPrices)` | Normal display |
| `CannotAffordAny` | `Gold < min(shopPrices)` | Greyed-out buy buttons |
| `AtCap` | `Gold == MaxPlayerGold` | Gold display shows cap icon; "Earned gold capped" note |
### Interactions with Other Systems
| System | Direction | Interface |
|--------|-----------|------------|
| **Node System** | Driven by | Shop node type triggers the Shop phase. "Leave" exits → Node System advances to next node choice. |
| **InventoryGenerationComponent** | Reads | `BuildShopGoods(goodsCount=6, runSeed, sequenceIndex)` generates the deterministic goods pool. |
| **PlayerInventoryComponent** | Reads/writes | Gold balance read for affordability; purchase deducts gold; sold items added via `MergeInventory()`. |
| **PlayerInventoryTradeService** | Reads | `TryPurchaseComponent(item, price)` executes purchase. `TrySellItems(itemIds)` executes sales. |
| **Tower Assembly** | Supplies goods to | Purchased components are available for assembly in the Assembly Phase. |
| **RepoForm** | Owns sell UI | RepoForm (not ShopNode) owns the selling interaction. `TrySellItems` is called by RepoForm's UseCase. |
## Formulas
### 1. Buy Price (Component)
`buyPrice = Random.Range(minPrice, maxPrice + 1)` — uniform random integer in `[MinPrice, MaxPrice]` inclusive, rolled at shop generation time and stored in `GoodsItemRawData.Price`.
**Variables:**
| Variable | Type | Range | Description |
|----------|------|-------|-------------|
| MinPrice | int | ≥ 0 | Per-rarity floor from `DRShopPrice[row].MinPrice` |
| MaxPrice | int | ≥ MinPrice | Per-rarity ceiling from `DRShopPrice[row].MaxPrice` |
**Output Range:** `[MinPrice, MaxPrice]` per purchase.
**Example** — White rarity, `MinPrice=50`, `MaxPrice=150`: `buyPrice ∈ [50, 150]`, uniformly random.
### 2. Sell Price (Component)
`sellPrice = Round((minPrice + maxPrice) / 2.0f)` — midpoint, rounded. Called by `ShopPriceRuleService.ResolveComponentSalePrice`.
**Example** — Blue rarity, `MinPrice=300`, `MaxPrice=600`: `sellPrice = 450`.
### 3. Sell Price (Tower)
`towerSellPrice = ResolveComponentSalePrice(muzzleComp) + ResolveComponentSalePrice(bearingComp) + ResolveComponentSalePrice(baseComp)` — sum of all three component sell prices via `ShopPriceRuleService.TryResolveTowerSalePrice`. Tower must be fully assembled and not in the combat roster.
### 4. Gold Cap
`effectiveGold = Min(actualGold, MaxPlayerGold)` where `MaxPlayerGold = 9999`. Hard cap applied on every `AddGold` call. Excess gold is discarded.
## Edge Cases
- **If `playerGold < itemPrice`**: Buy button is disabled. `TryConsumeGold` returns `false`. No change to gold or inventory.
- **If player has 0 gold at shop entry**: All buy buttons are disabled. Player may browse and skip.
- **If all 6 goods are purchased**: Shop shows empty state "All items sold". Player may exit.
- **If a purchased component is disassembled after purchase**: Component returns to inventory. Shop does not re-offer it — `IsPurchased` is visit-scoped. Disassembled components can be sold via RepoForm.
- **If `runSeed` or `sequenceIndex` changes between visits**: `BuildShopGoods` produces a different deterministic pool. Revisiting the same node with a different seed generates different goods.
- **If the same component config appears twice in one run**: Each `GoodsItemRawData` has its own `InstanceId`. Buying twice produces two separate component instances. No deduplication.
- **If player sells a component shown in the current shop visit**: Shop display is unaffected. `IsPurchased` is visit-scoped.
- **If a tower being sold has no sellable components**: `TryResolveTowerSalePrice` returns `false`. `FailureReason = MissingTowerComponent`. Tower cannot be sold.
- **If `MaxPlayerGold` is reached during a reward payout**: `AddGold` silently caps at 9999. Excess gold is discarded.
- **If player attempts to sell an assembled component via RepoForm**: `FailureReason = AssembledComponent`. Player must disassemble first.
- **If player attempts to sell a tower in the combat roster via RepoForm**: `FailureReason = ParticipantTower`. Tower must be removed from roster first.
## Dependencies
### Upstream Dependencies (what Shop depends on)
| System | Type | Interface | Status |
|--------|------|-----------|--------|
| **Node System** | Hard | Shop node type triggers Shop phase. `runSeed` and `sequenceIndex` passed to `BuildShopGoods`. | GDD exists (`design/gdd/node-system.md`) |
| **InventoryGenerationComponent** | Hard | `BuildShopGoods(goodsCount, runSeed, sequenceIndex)` generates the goods pool. `BuildRandomComponentItem` picks slot type, config, and applies tags. | Implemented |
| **DRShopPrice** | Hard | Price ranges per rarity tier (`MinPrice`, `MaxPrice`). Missing rows cause 0-price fallback. | Implemented |
| **DRMuzzleComp / DRBearingComp / DRBaseComp** | Hard | Component config lookup for shop-offered items. Missing rows cause null returns. | Implemented |
### Downstream Dependents (what depends on Shop)
| System | Type | Interface | Status |
|--------|------|-----------|--------|
| **Tower Assembly** | Soft | Purchased components become available for assembly. No direct coupling — Tower Assembly reads from inventory. | GDD exists |
| **Combat System** | Soft | Gold earned from combat is spent at Shop. Indirect — no direct coupling. | GDD exists |
| **Progression** | Soft | May read total gold spent at shop across runs. Pending Progression GDD. | Not yet designed |
| **RepoForm** | Soft | RepoForm reads `TrySellItems` to process sales. Sell prices derived from `ShopPriceRuleService`. | Implemented |
### Provisional Assumptions
- `MaxPlayerGold = 9999` is a design constant — pending confirmation from balance tuning.
- Shop node count per run is governed by Node System GDD — Shop GDD assumes at least one shop node appears per run.
## Tuning Knobs
| Knob | Default | Safe Range | Extreme: Too Low | Extreme: Too High |
|------|---------|-----------|-----------------|------------------|
| `MaxPlayerGold` | 9999 | 5000–99999 | Gold feels pointless — player never feels rich | Gold never feels scarce — no tension |
| `GoodsCountPerShop` | 6 | 3–12 | Fewer choices — shop feels unrewarding | More choices — decision paralysis |
| `DRShopPrice[rarity].MinPrice` | varies | varies | Cheap high-rarity items — gold becomes abundant | Expensive high-rarity — shop feels futile |
| `DRShopPrice[rarity].MaxPrice` | varies | ≥ MinPrice | High price variance — luck dominates | Low variance — price is predictable but boring |
| Sell price multiplier | 0.5 | 0.3–0.7 | Selling too rewarding — players flip components freely | Selling too punishing — players hoard everything |
**Data-table-driven knobs**:
- `DRShopPrice.MinPrice / MaxPrice` — per rarity tier, controls both buy and sell prices
- `DRShopPrice.Rarity` — which rarity tiers are available in the shop
## Visual/Audio Requirements
### VFX Event Specifications
All shop VFX is **localized to relevant cards and the gold display** — no full-screen flashes or global pulses. The shop is a tactical pause, not a cinematic. Every effect should reinforce the geometric/mathematical aesthetic and rarity hierarchy established in the Art Bible.
#### Rarity Shimmer Specification (Purple / Red)
Purple and Red rarity cards carry a persistent shimmer during the entire shop visit:
| Rarity | Shimmer Behavior | Shape |
|--------|----------------|-------|
| Purple `#C084FC` | Continuous horizontal sweep, 60% opacity, 2-second cycle, ease-in-out | Thin diamond outline traveling across card border |
| Red `#F87171` | Continuous horizontal sweep, 70% opacity, 1.5-second cycle, ease-in-out | Thin diamond outline + 2 small triangle particles orbiting card corners |
White through Blue cards have no shimmer — their rarity is communicated through border color and particle density on purchase only.
---
### Event: Shop Open / Card Cascade
**Trigger**: Player enters a Shop node; shop form appears.
**Sequence**:
1. **Background pulse** (t=0): A single hexagonal ring expands from screen center to full shop panel bounds, opacity 20%, rarity-neutral white `#E8E8E8`, 250ms ease-out. Signals "shop materialized."
2. **Gold display appears** (t=50ms): Gold counter scales in from 0.8x to 1.0x, 200ms ease-out.
3. **Card cascade** (t=100ms–700ms): 6 component cards enter sequentially, 100ms apart. Each card: scale 0.7x → 1.0x, opacity 0 → 1, 250ms ease-out per card. Stagger: card 1 at t=100ms, card 6 at t=600ms.
4. **Rarity shimmer starts** (t=700ms): Purple/Red cards begin shimmer loop immediately upon entering. No shimmer during card-in animation.
**Particle spec for card cascade**: On each card's entry, 3 small triangles burst from the card's bottom edge, rarity-colored, 200ms lifetime, fade out. Originates from card center-bottom. This reinforces the geometric theme without being distracting.
**Audio**: Soft two-tone chord (C3-G3, 100ms each). No melody — the shop should feel like a calm briefing, not a reward screen.
**Duration**: ~800ms total.
---
### Event: Card Hover — Affordable
**Trigger**: Player cursor enters an affordable component card.
**Sequence**:
1. **Card lift** (t=0): Card translates Y+8px, 150ms ease-out. Shadow deepens (Y offset increases, blur expands, opacity 0.15 → 0.25).
2. **Border glow** (t=0): Card border brightens to full rarity color at 90% opacity, 150ms.
3. **Type icon pulse** (t=0): Component type icon (Muzzle/Bearing/Base geometric symbol) scales 1.0x → 1.15x → 1.0x, 300ms ease-out.
4. **Description reveal** (t=100ms): Description text fades in if previously truncated, 200ms ease-out. Name and tags remain visible always.
**Particle spec**: 4 tiny triangles emit from card corners (one per corner), rarity-colored, 150ms lifetime, outward drift 20px, fade out. Static emit — no continuous particle stream.
**Audio**: Subtle tick/chirp (C5, 40ms, volume 0.3). Very quiet — the player should barely notice it consciously.
**Duration**: Hover effects play while cursor is over card; reverse animation on cursor exit (150ms ease-out, no particle burst on exit).
---
### Event: Card Hover — Cannot Afford
**Trigger**: Player cursor enters a component card whose price exceeds current gold.
**Sequence**:
1. **Card tint** (t=0): Card background dims to 60% opacity, 150ms. Full-color border remains visible so rarity is still readable.
2. **Price badge shake** (t=0): Buy-price badge shakes horizontally — triangle waveform: `+3px → -3px → +3px → 0`, 300ms. Signals "I see the price but I can't."
3. **Gold display flash** (t=100ms): Gold display border flashes red `#F87171` at 40% opacity, 200ms, then returns to normal.
4. **No lift**: Card does NOT translate up. It stays in place, visually communicating "not interactable."
5. **Rarity shimmer continues**: Purple/Red shimmer plays normally — affordability state does not suppress shimmer.
**Particle spec**: None on hover (cannot afford = absence, not presence).
**Audio**: Low dissonant thud (C2, 60ms, volume 0.4). Subconsciously communicates rejection without being alarming.
**Duration**: Persists while cursor is over card; reverses on exit (150ms ease-out).
---
### Event: Purchase — Click and Confirm
**Trigger**: Player clicks an affordable component card's Buy button.
**Sequence**:
1. **Click confirmation** (t=0): Buy button scales 1.0x → 0.92x → 1.0x, 100ms (press feel).
2. **Gold deduction** (t=80ms): Gold counter does a rapid countdown animation — numbers tick down rapidly (50ms per digit-change), final value reached at t=200ms. No bounce or overshoot.
3. **Particle burst** (t=80ms): 8–12 geometric particles (triangles/diamonds mixed) burst from the card's center, rarity-colored. Particles: random velocity 80–200px/s, 400ms lifetime, fade out over last 150ms. Burst is roughly circular but shaped by triangle/diamond outlines at edges.
4. **Rarity shimmer stop** (t=80ms): Purple/Red shimmer on this card ceases immediately — the card is now "spoken for."
5. **Card fade-out** (t=300ms): The purchased card fades to 0% opacity and scales to 0.85x over 250ms ease-out. Remaining cards do NOT shift to fill the gap — the empty slot remains as a visual record of purchase.
6. **Inventory indicator** (t=400ms): A small rarity-colored diamond icon pulses once near the inventory/accessory panel, 200ms ease-out. Signals "this component is now yours."
**Particle spec (rarity-weighted)**:
| Rarity | Particle Count | Extra |
|--------|--------------|-------|
| White | 8 | — |
| Green | 8 | — |
| Blue | 10 | — |
| Purple | 10 | + shimmer trace line connecting 3 particles (diamond outline, 60% opacity, 300ms) |
| Red | 12 | + shimmer trace line connecting 4 particles (diamond outline, 70% opacity, 250ms) |
**Audio**: Ascending arpeggio keyed to rarity:
| Rarity | Sound |
|--------|-------|
| White | C4-E4, 80ms total |
| Green | C4-E4-G4, 120ms total |
| Blue | C4-E4-G4-C5, 160ms total |
| Purple | C4-E4-G4-C5-E5 + shimmer overtone, 200ms total |
| Red | C4-E4-G4-C5-E5-G5 + shimmer overtone, 240ms total |
Volume: 0.7. Pitch-shifted slightly higher than tower assembly sounds — shop purchases feel like a personal acquisition, not a construction event.
**Duration**: ~450ms total. Player can continue shopping during this sequence.
---
### Event: All 6 Items Purchased / Empty State
**Trigger**: Final card is purchased; no items remain.
**Sequence**:
1. **Empty state fade-in** (t=0): "All items sold" message fades in at center of card grid, 300ms ease-out. Background behind message: `#1A1A2E` at 60% opacity, geometric diamond shape as backdrop.
2. **Geometric pulse** (t=100ms): A large diamond outline pulses once (scale 0.9x → 1.1x → 1.0x, 400ms ease-out) behind the message.
3. **Gold display remains visible**: Player can review their remaining gold.
**Audio**: Single resonant tone (G3, 400ms, volume 0.5, soft decay). Signals conclusion without urgency.
---
### Event: Shop Leave / Exit
**Trigger**: Player clicks "Leave" button or all items purchased.
**Sequence**:
1. **Leave button click** (t=0): Button press animation (scale 1.0x → 0.95x → 1.0x, 80ms).
2. **Card fade-out** (t=100ms): All remaining cards fade to 0% opacity, scale to 0.9x, staggered 50ms apart (back-to-front order), 200ms each ease-out.
3. **Gold display fade** (t=300ms): Gold counter fades out, 200ms ease-out.
4. **Hexagonal ring collapse** (t=350ms): Single hexagonal ring contracts from panel edges to center, opacity 15%, 200ms ease-out. Geometric "door closing."
5. **Panel exit** (t=500ms): Shop panel slides out or fades, standard node-system transition.
**Audio**: Reverse of shop-open chord (G3-C3, 150ms total). Clean, no reverb.
**Duration**: ~600ms total.
---
### Event: Gold Display — At Cap
**Trigger**: Player gold equals `MaxPlayerGold` (9999).
**Visual**: Gold display shows a cap icon (small filled hexagon) beside the gold number. Icon pulses once every 3 seconds — scale 1.0x → 1.2x → 1.0x, 500ms ease-out. Color: `#F87171` (red, warning) at 60% opacity.
**Audio**: No sound on cap indicator pulse. The cap warning is purely visual.
---
### Event: Sell Interaction (RepoForm)
> Selling is owned by RepoForm, not ShopNode. These effects apply when the player sells from the inventory view, not during a shop visit. They are documented here for VFX consistency.
**Sell confirm click**: A geometric diamond splits into two triangles that fly toward the gold display. Gold display increments with rarity-keyed arpeggio (same as purchase, one octave lower: C3-E3-G3 for Red).
**Sell price reveal**: When hovering over a sellable item in RepoForm, a small triangle pointer indicates the sell price (midpoint), color `#4ADE80` (green = positive return). On click: green particle burst, gold counter ticks up.
---
### Rarity Color Palette — Shop Application
| Rarity | Hex | Card Border | Shimmer | Purchase Particle | Purchase Audio |
|--------|-----|-------------|---------|-------------------|----------------|
| White | `#E8E8E8` | `#E8E8E8` solid | None | 8 white triangles | C4-E4, 80ms |
| Green | `#4ADE80` | `#4ADE80` solid | None | 8 green triangles | C4-E4-G4, 120ms |
| Blue | `#60A5FA` | `#60A5FA` solid | None | 10 blue diamonds | C4-E4-G4-C5, 160ms |
| Purple | `#C084FC` | `#C084FC` solid | Diamond sweep, 2s cycle | 10 purple diamonds + shimmer trace | C4-E4-G5-C5-E5 + overtone, 200ms |
| Red | `#F87171` | `#F87171` solid | Diamond sweep + corner triangles, 1.5s cycle | 12 red diamonds + shimmer trace | C4-E4-G4-C5-E5-G5 + overtone, 240ms |
---
### Animation & Style Constraints
**Shape vocabulary**: Triangles, diamonds, hexagons ONLY. No circles, no curves, no organic forms. Every particle, every icon, every UI border uses one of these three.
**Waveform character**: All motion uses clean triangle or sine waveforms — no exponential easing, no elastic overshoot, no bounce. Duration: 80–250ms for micro-interactions, up to 500ms for structural transitions.
**Easing rule**: Ease-out for all entry animations. Linear for countdown/countup number animations (gold ticks). No ease-in-only — nothing should feel like it is "warming up."
**Rarity shimmer constraints**:
- Sweep direction: left-to-right, continuous loop
- Particle size: 4–8px (small, not dominant)
- Shimmer opacity: never exceeds 70% — rarity is communicated by shimmer quality, not intensity
- Corner triangles for Red: orbit path is a small equilateral triangle 16px from each corner
**Shop-form layout**: 6 cards in a 3-column × 2-row grid. Card aspect ratio: 3:4 (portrait). Card border radius: 0 (sharp corners — no rounded corners, consistent with geometric aesthetic). Cards must have 8px gaps minimum.
**No full-screen effects**: Shop VFX is card-scoped or gold-display-scoped. A full-screen flash or color wash is never appropriate for a shop event. The exception is the hexagonal ring on shop open/leave, which is a border-to-border panel effect, not full-screen.
**Particle lifecycle**: All particles fade over their final 30% of lifetime. No particles simply disappear (pop out of existence). Particle count per burst: max 12. Particle lifetime: 200–400ms.
**Performance budget**: At most 3 simultaneous particle emitters active in the shop (card cascade burst, purchase burst, empty state pulse). Do not stack more.
---
### DataTable Extension — Shop Sounds
| SoundId | AssetName | Volume | Duration | Notes |
|---------|-----------|--------|----------|-------|
| ShopOpen | Shop_Open | 0.5 | 200ms | C3-G3 chord, no reverb |
| ShopCardHover | Shop_Card_Hover | 0.3 | 40ms | C5 tick, very quiet |
| ShopCardHover_CannotAfford | Shop_Card_Hover_Deny | 0.4 | 60ms | C2 thud, dissonant |
| ShopPurchase_White | Shop_Purchase_White | 0.7 | 80ms | C4-E4 |
| ShopPurchase_Green | Shop_Purchase_Green | 0.7 | 120ms | C4-E4-G4 |
| ShopPurchase_Blue | Shop_Purchase_Blue | 0.7 | 160ms | C4-E4-G4-C5 |
| ShopPurchase_Purple | Shop_Purchase_Purple | 0.7 | 200ms | C4-E4-G4-C5-E5 + shimmer |
| ShopPurchase_Red | Shop_Purchase_Red | 0.7 | 240ms | C4-E4-G4-C5-E5-G5 + shimmer |
| ShopEmpty | Shop_Empty | 0.5 | 400ms | G3, soft decay |
| ShopLeave | Shop_Leave | 0.5 | 150ms | G3-C3 reverse chord |
| GoldAtCap_Pulse | Gold_AtCap_Pulse | 0.0 | — | No audio — visual only |
| RepoForm_SellConfirm | RepoForm_Sell_Confirm | 0.7 | 200ms | C3-E3-G3 arpeggio (purchase sounds one octave lower) |
---
## UI Requirements
### Shop Form Layout
**Trigger**: Shop node type in Node System triggers this form.
**Gold Display** (top of form):
- Shows current gold as a numeric value with a small hexagon coin icon
- Position: top-center of shop panel, horizontally centered
- Size: enough for 4-digit display (up to "9999")
- States: normal (white text `#E8E8E8`), at-cap (red cap icon pulses)
- Animation: gold value counts up/down with rarity-keyed arpeggio on change
**Component Cards** (center of form):
- 6 cards in 3×2 grid
- Each card displays: component type icon (geometric symbol), name, rarity border (full-color), Tags (small icons), description (2-line max), buy price badge
- Card layout order: cards arranged left-to-right, top-to-bottom (1-2-3 / 4-5-6)
- Cards do NOT reorder after purchase — empty slots remain visible
**Card Anatomy** (per card):
```
[ Rarity Border (2px solid, full rarity color) ]
[ Type Icon (geometric symbol, 32x32) | Name (localized) ]
[ Rarity Label (text, rarity color) ]
[ Tag Icons (row of small geometric tag markers) ]
[ Description (2 lines max, truncated with "..." if needed) ]
[ Price Badge: [Gold Icon] [Price Number] [BUY] ]
```
**Buy Button**:
- Integrated into card bottom row
- States: Enabled (rarity-colored, pointer cursor), Disabled/cannot-afford (40% opacity, no-shader hover effect)
- No tooltip — all information is on the card itself
**Leave Button**:
- Position: bottom-right of shop panel
- Label: "LEAVE" or equivalent localized string
- Style: outlined button, white border `#E8E8E8`, no fill
- Hover: border brightens, 150ms
**Empty State** (all purchased):
- Geometric diamond shape as backdrop
- "All items sold" centered in card grid area
- Leave button remains accessible
### Accessibility
- All rarity colors are paired with distinct geometric symbols (triangle=Muzzle, diamond=Bearing, hexagon=Base) — color-blind safe
- Price badges use absolute number display, not icon counts
- Cannot-afford state uses border shake (triangle waveform) + red tint, not color alone
- All interactions have keyboard equivalents: Tab to navigate cards, Enter to purchase, Escape to leave
- Gold counter updates are announced via UIFocus system (not audio-only)
### Component Type Symbols
| Type | Geometric Symbol | Shape |
|------|-----------------|-------|
| Muzzle | Triangle | Equilateral triangle, pointing up |
| Bearing | Diamond | Rhombus / rotated square |
| Base | Hexagon | Regular hexagon |
These symbols appear as: card type icon, hover type icon pulse, particle shape origin. They are the primary shape vocabulary of the game.
### Animation Timing Reference
| Event | Entry Duration | Exit Duration | Notes |
|-------|---------------|---------------|-------|
| Shop Open | 800ms total | 600ms total | — |
| Card Cascade | 250ms per card, 100ms stagger | 200ms per card, 50ms stagger | — |
| Card Hover (affordable) | 150ms | 150ms | — |
| Card Hover (cannot afford) | 150ms | 150ms | No lift; price shake instead |
| Purchase Burst | 400ms particles | — | Card fade 250ms concurrent |
| Gold Countdown | ~150ms (rate: 50ms/digit) | — | Linear, not ease-out |
| Empty State | 400ms | — | — |
| Leave | 500ms total | — | — |
| Rarity Shimmer (Purple) | 2000ms cycle | — | Continuous while card visible |
| Rarity Shimmer (Red) | 1500ms cycle | — | Continuous while card visible |
## Acceptance Criteria
### Shop Visit
- **Given** the player enters a Shop node, **then** 6 component cards cascade into view in 3x2 grid with staggered entry animation (card 1 at t=100ms, card 6 at t=600ms).
- **Given** the player enters a Shop node, **then** the hexagonal ring expand animation plays once, opacity 20%, 250ms ease-out.
- **Given** Purple or Red rarity cards are in the shop, **then** the rarity shimmer loop runs continuously until the card is purchased or the player leaves.
### Card Interaction
- **Given** the player hovers over an affordable component card, **then** the card lifts Y+8px with shadow deepening, border glows full rarity color, and 4 corner triangles emit.
- **Given** the player hovers over a cannot-afford card, **then** the card dims to 60% opacity, price badge shakes (triangle waveform), and gold display border flashes red.
- **Given** the player clicks the Buy button on an affordable card, **then** the purchase burst plays (rarity-keyed particle count), gold counter ticks down, and the card fades to empty slot.
- **Given** the player clicks Buy with insufficient gold, **then** the buy button does not activate, gold display shakes, and no purchase occurs.
### Rarity Shimmer
- **Given** a Purple card is visible in the shop, **then** a diamond outline sweeps left-to-right across the card border every 2 seconds, 60% opacity, ease-in-out.
- **Given** a Red card is visible in the shop, **then** a diamond outline sweeps left-to-right every 1.5 seconds (70% opacity) and 2 small triangles orbit the card corners simultaneously.
### Gold Display
- **Given** the player purchases a component, **then** the gold counter counts down rapidly (linear, ~50ms per digit change) to the new value.
- **Given** the player reaches `MaxPlayerGold` (9999), **then** a red hexagon cap icon pulses once every 3 seconds beside the gold display.
### Empty State
- **Given** all 6 shop items are purchased, **then** the empty state appears with a diamond backdrop shape and "All items sold" message.
- **Given** the empty state is shown, **then** the Leave button remains accessible and functional.
### Shop Exit
- **Given** the player clicks Leave or all items are purchased, **then** remaining cards fade out staggered, gold display fades, and the hexagonal ring collapses to center (500ms total).
### Audio
- **Given** the shop opens, **then** a C3-G3 chord plays (200ms, volume 0.5).
- **Given** a component is purchased, **then** an ascending arpeggio plays keyed to rarity (White=2-note 80ms, Red=6-note 240ms + shimmer overtone).
- **Given** a cannot-afford card is hovered, **then** a low dissonant C2 thud plays (60ms, volume 0.4).
- **Given** the shop leaves, **then** a reverse G3-C3 chord plays (150ms).
### Accessibility
- **Given** all rarity colors are displayed, **then** each rarity also has a distinct geometric symbol (Muzzle=triangle, Bearing=diamond, Base=hexagon) visible on every card.
- **Given** the player navigates by keyboard, **then** Tab cycles through cards, Enter purchases, Escape leaves.
- **Given** a color-blind player views the shop, **then** the cannot-afford state is communicated via price badge shake + dim, not color alone.
## Open Questions
### 1. Shop Sell Multiplier
**Status**: OPEN — The sell price formula uses midpoint (`Round((min+max)/2)`), which is approximately 50% of average buy price. Should there be an explicit sell multiplier (e.g., `Round(midpoint * sellMultiplier)`)? Without it, the effective return rate is implicit. Adding an explicit multiplier makes it a tunable knob (see Tuning Knobs).
### 2. Shop Node Frequency per Run
**Status**: OPEN — How many Shop nodes appear per run is governed by the Node System GDD. Shop GDD needs this to calibrate `MaxPlayerGold` against expected gold income. Minimum recommended: 2 shop nodes per run to create meaningful save-vs-spend tension.
### 3. Duplicate Component Exclusion Across Shop Visits
**Status**: OPEN — Design decision: once a component config is purchased, it should not appear in subsequent shop visits this run. `BuildShopGoods` does not currently track purchased configs. This exclusion logic needs to be added: either as a filter in `ShopGoodsBuilder` or as a parameter passed to `BuildShopGoods`. **This is an implementation gap — the GDD specifies the behavior but the code does not yet implement it.**
### 4. Minimum Purchase Requirement
**Status**: OUT OF SCOPE — Not adopted. Design chose optional visits (SR5). Revisiting this would create a mandatory gold sink but risks feeling punitive on early runs with bad RNG.
+37
View File
@@ -0,0 +1,37 @@
# Systems Index
> **Last Updated**: 2026-04-29
## Index
| Priority | System | Layer | Category | Status | Design Doc |
|---------|--------|-------|----------|--------|------------|
| 1 | Node System (节点系统) | Game Flow | Level/World | In Review | `design/gdd/node-system.md` |
| 2 | Combat System (战斗系统) | Gameplay | Combat | Designed | `docs/CombatNodeArchitecture.md` |
| 3 | Tower Assembly (塔组装系统) | Gameplay | Economy | Needs Revision | `design/gdd/tower-assembly.md` |
| 4 | Shop System (商店系统) | Gameplay | Economy | Designed | `design/gdd/shop.md` |
| 5 | Event System (事件系统) | Gameplay | Narrative | Designed | `design/gdd/event-system.md` |
| 6 | Progression (成长系统) | Meta | Progression | Needs Revision | `design/gdd/progression.md` |
## Progress Tracker
- **Total Systems**: 6
- **Designed**: 3 (Shop, Event, Combat)
- **Needs Revision**: 3 (Node System, Tower Assembly, Progression)
## Layer Definitions
| Layer | Description |
|-------|-------------|
| Meta | Outer loop (progression, unlocks) |
| Game Flow | Navigation, state machine, save/load |
| Gameplay | Core game mechanics |
| Foundation | Engine integration, services |
## Category Definitions
- **Combat**: Damage, health, enemy AI, tower behavior
- **Economy**: Currency, shop, crafting, loot
- **Level/World**: Maps, nodes, terrain, world state
- **Progression**: XP, levels, unlocks, meta progression
- **Narrative**: Events, dialogue, story beats
+367
View File
@@ -0,0 +1,367 @@
# Tower Assembly (塔组装系统)
> **Status**: Designed
> **Author**: SepComet + agents
> **Last Updated**: 2026-04-29
> **Implements Pillar**: Tactical preparation and adaptation — players optimize tower builds between combat encounters
## Overview
The Tower Assembly system is the **component combination engine** that transforms individual Muzzle, Bearing, and Base components into functional Tower instances. During Assembly Phase (between combat nodes), the `PlayerInventoryTowerAssemblyService.TryAssembleTower()` method accepts three component instance IDs and produces a `TowerItemData` containing aggregated stats across 5 level tiers. The system resolves tower rarity from component rarities, computes per-level stat arrays from rarity-scaled base values plus data-table-defined per-level deltas, and merges Tags from all constituent components. Assembled towers can be rostered for combat (max 4 active) via the `PlayerInventoryTowerRosterService`. The system operates purely on in-memory inventory state; all data is ephemeral per run.
## Player Fantasy
**"Every battle is a puzzle. You have the pieces—arrange them to solve it."**
The Tower Assembly delivers the fantasy of **tactical threat assessment and counter-build satisfaction**. The player arrives at Assembly Phase knowing exactly what challenges lie ahead (the 2 outgoing node types are visible), and must decide which components to combine into towers that answer those specific threats. The core feeling is **the satisfaction of feeling clever** — not raw power, but the right tool for the known job.
The player should feel:
- **Analyzing incoming threats** — the next node types are known; the question is "what does this enemy fear?"
- **Making irreversible commitments** — once components are assembled into a tower, they cannot be reclaimed until the tower is disassembled or the run ends. Every build decision is permanent within its context.
- **Seeing the consequences of their choices** — a well-matched tower against the right enemy type is viscerally effective; a mismatched build feels wrong and the player knows exactly what they could have done differently.
- **Building toward the boss** — each assembly decision accumulates toward the final confrontation; the boss fight is where build quality is ultimately tested.
**Reference**: Into the Breach's "I can see exactly what will happen and I planned for it" feeling — the Tower Assembly achieves this through visible upcoming threats during assembly, and transparent tower stats that make the outcome predictable.
## Detailed Design
### Core Rules
**Tower Assembly** combines exactly one Muzzle, one Bearing, and one Base component into a Tower instance.
**R1. Assembly Eligibility**: A component is eligible for assembly if and only if:
- It exists in the player's inventory (identified by `InstanceId`)
- Its `IsAssembledIntoTower` flag is `false`
**R2. Assembly Process** (`PlayerInventoryTowerAssemblyService.TryAssembleTower`):
1. Player selects one Muzzle, one Bearing, and one Base component from inventory
2. System validates all three components are eligible (R1)
3. System looks up per-level delta values from data tables (`DRMuzzleComp.AttackDamagePerLevel`, `DRBearingComp.RotateSpeedPerLevel`/`AttackRangePerLevel`, `DRBaseComp.AttackSpeedPerLevel`)
4. Tower rarity computed via `InventoryRarityRuleService.ResolveTowerRarity(muzzleRarity, bearingRarity, baseRarity)` — arithmetic mean of three rarities, rounded and clamped to `RarityType` enum range
5. Stat arrays built at `TowerLevelCount = 5` granularity using rarity-scaled base values plus per-level deltas
6. Tags aggregated via `TowerTagAggregationService.AggregateTowerTags()` — stack counts merged across all three components
7. New `TowerItemData` created with a system-allocated `InstanceId`, display name, and the computed stats
8. All three components' `IsAssembledIntoTower` flags are set to `true`
9. The new tower is added to `inventory.Towers`
**R3. Disassembling**: Any assembled tower may be disassembled at no cost. Disassembly:
- Removes the tower from `inventory.Towers`
- Sets all three constituent components' `IsAssembledIntoTower` flags to `false`
- Components' `Endurance` values are preserved (not reset)
- If the tower is currently in the combat roster (`ParticipantTowerInstanceIds`), it is automatically removed from the roster before disassembling
**R4. Tower Roster** (`PlayerInventoryTowerRosterService`):
- Maximum 4 towers may be rostered for combat (`MaxParticipantTowerCount`)
- Roster is set during Assembly Phase via `TryAddParticipantTower(towerInstanceId)` / `TryRemoveParticipantTower(towerInstanceId)`
- Only rostered towers participate in combat
- Roster state persists in `inventory.ParticipantTowerInstanceIds` and is reset on each new run
**R5. Tower Endurance**: Each component has an `Endurance` value (0–100). When a tower participates in combat, all three components' endurance is reduced proportionally via `InventoryTowerEnduranceUtility.ReduceTowerEndurance()`.
**R6. Endurance at Zero**: If any constituent component's `Endurance` reaches 0:
- The tower **cannot be rostered** for combat (`TryAddParticipantTower` returns failure)
- The tower **cannot be used** in combat — it is treated as non-functional
- The components retain their 0 endurance state until repaired or the run ends
- A 0-endurance component cannot be disassembled (must be repaired first — repair is out of scope for this GDD, see Open Questions)
**R7. No Component Compatibility Constraints**: Any MuzzleCompItemData may combine with any BearingCompItemData and any BaseCompItemData. No affinity rules, type matching, or stat constraints are enforced. `AttackMethodType` and `AttackPropertyType` are independent dimensions that do not affect assembly eligibility.
### States and Transitions
Tower Assembly has no standalone state machine — it operates as a service within the `PlayerInventoryComponent`. State transitions are driven by the Node System's Assembly Phase.
**Component States** (per component instance):
| State | Description | Exits |
|-------|-------------|-------|
| `InInventory` | Component resides in inventory, not assembled | → `Assembled` on successful `TryAssembleTower` |
| `Assembled` | Component is part of a tower, `IsAssembledIntoTower = true` | → `InInventory` on successful `TryDisassembleTower`; → `InRoster` when tower is added to roster |
| `InRoster` | Tower containing this component is in the combat roster | → `Assembled` when tower removed from roster |
| `Degraded` | Any constituent component has `Endurance = 0`; tower cannot be rostered | → `Assembled` if endurance is repaired (out of scope) |
**Tower States** (per tower instance):
| State | Description | Exits |
|-------|-------------|-------|
| `AssembledIdle` | Tower exists in `inventory.Towers`, not in combat roster | → `AssembledRostered` on `TryAddParticipantTower` |
| `AssembledRostered` | Tower is in `ParticipantTowerInstanceIds`, eligible for combat | → `AssembledIdle` on `TryRemoveParticipantTower`; → `Degraded` if any component reaches 0 endurance |
| `Degraded` | Tower cannot be rostered due to 0-endurance component | → `AssembledRostered` if endurance is repaired (out of scope) |
### Interactions with Other Systems
| System | Direction | Interface |
|--------|-----------|------------|
| **Node System** | Driven by | Assembly Phase is triggered by `NodeSystem` after each node resolves. During Assembly Phase, the player may call `TryAssembleTower`, `TryDisassembleTower`, `TryAddParticipantTower`, and `TryRemoveParticipantTower`. |
| **PlayerInventoryComponent** | Reads/writes | Tower Assembly operates entirely through `PlayerInventoryComponent`'s inventory state. Components and towers are stored in `BackpackInventoryData`. |
| **Combat System** | Delegates to | `PlayerInventoryComponent.GetParticipantTowerSnapshot()` returns the current combat roster (up to 4 towers). Combat system reads tower stats from this snapshot. |
| **Progression** | Writes | On `RunEnd`, final tower configurations are not persisted (run resets). Progression may read aggregate stats (e.g., total towers assembled across all runs) — pending Progression GDD. |
## Formulas
### 1. Tower Stat Per-Level Scaling
Each tower stat (AttackDamage, RotateSpeed, AttackRange, AttackSpeed) is built as a 5-element array via `BuildLevelIntArray` or `BuildLevelFloatArray`. All stats follow the same structural formula:
`statValue[i] = baseValue + perLevel * i` for `i` in `0..4`
**Variables:**
| Variable | Symbol | Type | Range | Description |
|----------|--------|------|-------|-------------|
| rarityBaseArray | B | int[5] or float[5] | varies | Per-rarity base values indexed by rarity (White=0..Red=4) |
| rarity | R | RarityType | White..Red | Component's rarity, converted to 0-based index |
| rarityIndex | ri | int | 0–4 | `Clamp((int)R - 1, 0, 4)` |
| baseValue | B[ri] | int or float | varies | Starting value at level 0, selected by rarity |
| perLevel | P | int or float | varies | Per-level increment from data table |
| statValue[i] | V_i | int or float | varies | Stat value at level i (level = i + 1) |
**Output Range:** Level 1 value = `B[ri]`; Level 5 value = `B[ri] + P * 4`. Actual range depends on component data tables.
**Example — AttackDamage** (Muzzle, Green rarity, `perLevel=3`, `AttackDamage = [10, 20, 30, 40, 50]`):
| Level | Index | Value |
|-------|-------|-------|
| 1 | 0 | 20 + 3×0 = **20** |
| 2 | 1 | 20 + 3×1 = **23** |
| 3 | 2 | 20 + 3×2 = **26** |
| 4 | 3 | 20 + 3×3 = **29** |
| 5 | 4 | 20 + 3×4 = **32** |
---
### 2. Tower Rarity Resolution
`InventoryRarityRuleService.ResolveTowerRarity(muzzleRarity, bearingRarity, baseRarity)` resolves tower rarity as the arithmetic mean of the three constituent component rarities, rounded and clamped:
`towerRarity = Clamp(Round((mR + bR + baseR) / 3), White, Red)`
**Variables:**
| Variable | Symbol | Type | Range | Description |
|----------|--------|------|-------|-------------|
| muzzleRarity | mR | RarityType | White..Red | Muzzle component rarity |
| bearingRarity | bR | RarityType | White..Red | Bearing component rarity |
| baseRarity | baseR | RarityType | White..Red | Base component rarity |
| normalized | n | int | 1–5 | (int)clampedRarity - 1 (0-based index) |
| average | avg | float | 1–5 | (mR + bR + baseR) / 3f |
| rounded | rnd | int | 1–5 | Math.Round(average) |
| towerRarity | — | RarityType | White..Red | Final tower rarity |
**Output Range:** White to Red. Average rounding: 1.50–1.99 → Green; 2.50–2.99 → Blue; etc.
**Example** — Muzzle=Green(2), Bearing=Blue(3), Base=Purple(4): `(2+3+4)/3 = 3.0` → **Blue**
---
### 3. Tag Aggregation
`TowerTagAggregationService.AggregateTowerTags(muzzleTags, bearingTags, baseTags)` merges tags from all three components into `TagRuntimeData[]`:
**Variables:**
| Variable | Type | Description |
|----------|------|-------------|
| componentTags | `TagType[][]` | Tags array from each of the 3 components |
| stackByTag | `Dictionary<TagType, int>` | Running count of tag occurrences |
| TotalStack | int | `Max(1, occurrenceCount)` per tag — guaranteed at least 1 |
**Output:** `TagRuntimeData[]` sorted by `TagType`. `TotalStack` represents how many of the 3 components carry this tag. Flat unique tag list (`Tags[]`) is derived by `FlattenUniqueTags(TagRuntimeData[])`.
**Example** — Muzzle=[Fire], Bearing=[Ice], Base=[Fire]:
`{ Fire: 2, Ice: 1 }` → `[{ Fire, TotalStack=2 }, { Ice, TotalStack=1 }]`
## Edge Cases
- **If the same component instance ID is passed for multiple slots**: Assembly fails. `TryGetComponentById` searches type-specific lists, so the second slot lookup fails and returns `false`.
- **If any component is already assembled into a tower** (`IsAssembledIntoTower = true`): Assembly fails immediately. Components may only belong to one tower at a time.
- **If any component's DR config row is missing** (`DRMuzzleComp`, `DRBearingComp`, or `DRBaseComp` returns `null` for the component's `ConfigId`): Assembly fails. Components without valid data table entries cannot be assembled.
- **If all three components have duplicate tags** (e.g., all have `TagType.Fire`): `TagRuntimeData` is produced with `TotalStack = 3`. Stack count equals the number of components carrying that tag (max 3).
- **If one or more components have empty or null tags arrays**: `AggregateTowerTags` skips null/empty arrays. The resulting tower only has tags from components with non-empty arrays.
- **If all three components have empty/null tags**: Tower has no tags. `AggregateTowerTags` returns `Array.Empty<TagRuntimeData>()`.
- **If tags contain `TagType.None` or invalid enum values**: These are filtered out by `AggregateTowerTags` via `if (tagType == TagType.None || !Enum.IsDefined(typeof(TagType), tagType)) continue;`. Invalid tags do not appear in output.
- **If the combat roster is full (4 towers) and user attempts to add another**: `TryAddParticipantTower` returns `ParticipantTowerAssignFailureReason.ParticipantAreaFull`. The tower is not added.
- **If a tower with 0-endurance component is attempted to be rostered**: `CombatParticipantTowerValidationService.ValidateTower` returns a validation failure (`BrokenMuzzleComponent`/`BrokenBearingComponent`/`BrokenBaseComponent`). `TryAddParticipantTower` returns `ParticipantTowerAssignResult` with `FailureReason = InvalidTower`. The tower cannot participate.
- **If a tower in the combat roster has a component reach 0 endurance mid-combat**: The tower remains in `ParticipantTowerInstanceIds` but becomes degraded. `CombatParticipantTowerValidationService.ValidateParticipantTowers` marks it invalid on the next validation. No automatic removal from roster occurs.
- **If a tower's component stat arrays are shorter than 5 elements**: `ResolveRarityBaseValue` uses `Clamp(rarityIndex, 0, array.Length - 1)`. If the array is empty, the stat defaults to 0.
- **If per-level delta is negative** (e.g., `AttackSpeedPerLevel = -0.25`): The formula `baseValue + perLevel * i` correctly handles negative values. Level 5 stat will be lower than Level 1 stat for that dimension.
- **If a tower's rarity resolves to a boundary value** (e.g., `(Green + Blue + Purple) / 3 = 3.0`): `Mathf.RoundToInt(3.0f) = 3` (Blue). Standard rounding applies.
## Dependencies
### Upstream Dependencies (what Tower Assembly depends on)
| System | Type | Interface | Status |
|--------|------|-----------|--------|
| **Node System** | Hard | Assembly Phase triggers Tower Assembly. `PlayerInventoryComponent.TryAssembleTower` and roster management are called during Assembly Phase. | GDD exists (`design/gdd/node-system.md`) |
| **Inventory** | Hard | `PlayerInventoryComponent` owns all component and tower state. Assembly reads/writes via `BackpackInventoryData`. | Implemented |
| **DataTable (DRMuzzleComp, DRBearingComp, DRBaseComp)** | Hard | Per-level delta lookups during stat building. Missing rows cause assembly failure. | Implemented |
### Downstream Dependents (what depends on Tower Assembly)
| System | Type | Interface | Status |
|--------|------|-----------|--------|
| **Node System** | Soft | Reads assembled tower configs during Assembly Phase; passes roster to combat. | GDD exists |
| **Combat System** | Hard | `PlayerInventoryComponent.GetParticipantTowerSnapshot()` returns up to 4 rostered towers with their stats. Combat reads `TowerStatsData` for damage calculations. | GDD exists (`docs/CombatNodeArchitecture.md`) |
| **Progression** | Soft | May read aggregate tower assembly stats across runs. Pending Progression GDD. | Not yet designed |
### Provisional Assumptions
- `TryDisassembleTower` method is not yet implemented — the design doc R3 specifies it should exist. Implementation is pending.
- Repair mechanism for 0-endurance components is out of scope for this GDD (see Open Questions).
- `CombatParticipantTowerValidationService` handles degraded tower detection — this is owned by the Combat System GDD.
## Tuning Knobs
| Knob | Default | Safe Range | Extreme: Too Low | Extreme: Too High |
|------|---------|-----------|-----------------|------------------|
| `TowerLevelCount` | 5 | 3–10 | Fewer upgrade tiers — less meaningful progression within a tower's lifetime | More tiers — stat arrays grow; UI scalability issues; balance complexity |
| `MaxParticipantTowerCount` | 4 | 2–6 | Too few towers — limited tactical variety in combat roster | Too many — combat UI cluttered; player decision paralysis |
| Per-level delta (`DRMuzzleComp.AttackDamagePerLevel`, etc.) | varies by row | varies | Too high → towers scale exponentially; late-game dominance | Too low → leveling feels pointless; stats converge |
| Rarity base arrays | varies by component row | varies | Too high → rarity gaps become massive | Too low → rarity feels meaningless |
**Data-table-driven knobs** (not code constants):
- `DRMuzzleComp.AttackDamagePerLevel` — flat AttackDamage increase per tower level
- `DRBearingComp.RotateSpeedPerLevel` — rotation speed increase per level
- `DRBearingComp.AttackRangePerLevel` — range increase per level
- `DRBaseComp.AttackSpeedPerLevel` — attack speed change per level (can be negative)
## Visual/Audio Requirements
### VFX Event Specifications
| Event | Visual Effect | Audio Cue | Duration |
|-------|--------------|-----------|----------|
| **Tower Assembled** | 6–10 geometric particles (triangles/diamonds) burst from assembly point, rarity-colored. Component icons collapse inward, tower card materializes with scale pulse (1.0x→1.15x→1.0x). Purple/Red rarity adds golden shimmer particles. | Ascending C5-E5-G5 arpeggio (200ms). Blue+ adds C6 for premium signal. | ~300ms |
| **Tower Added to Roster** | Roster slot glows rarity color (200ms). Tower icon animates to roster slot (200ms ease-out). Roster full: ring pulse from panel border. | Two-tone lock-in (G5→E5→G5, 80ms). Roster full adds C6. | ~250ms |
| **Tower Removed from Roster** | Slot fades from rarity color to empty (150ms). Tower icon animates back to inventory. Roster-full indicator: "-1" pulse above panel. | Descending G5→D5 (100ms) — "slot opened". | ~200ms |
| **Tower Degraded (0 Endurance)** | Red-orange geometric crack propagates across tower icon (300ms). Tower dims to 40% opacity, desaturated. Broken shard overlay icon. Roster slot flashes red-orange if affected. | Dissonant minor-2nd (C5→C5♭, 150ms) + descending power-down sweep (400Hz→200Hz, 200ms). | ~400ms |
| **Tower Disassembled** | Tower icon explodes into 3 component icons flying to inventory positions. Particle burst at tower's former position. Components pulse on arrival. | Reverse arpeggio G5→E5→C5 (200ms). 3x short clicks (30ms each) as components snap back. | ~300ms |
### Rarity Color Palette
| Rarity | Hex | VFX Color | Audio Signal |
|--------|-----|-----------|--------------|
| White | `#E8E8E8` | White particles | 1-note chime |
| Green | `#4ADE80` | Green particles | 2-note chime |
| Blue | `#60A5FA` | Blue particles | 3-note chime |
| Purple | `#C084FC` | Purple + shimmer | 4-note + shimmer VFX |
| Red | `#F87171` | Red + shimmer | 5-note + shimmer VFX |
### Animation & Style Constraints
- **Particle shapes**: Triangles, diamonds, hexagons ONLY — no circles, no organic curves
- **Waveforms**: Clean sine or triangle waves — digital-mathematical character
- **Easing**: All UI animations use ease-out entry. No bounce, no elastic overshoot
- **Duration budget**: Assembly/disassemble 200–300ms; roster add/remove 200–250ms; degraded 300–400ms. Max 400ms per transition
- **Accessibility**: All audio cues have visual alternatives (color flash, icon change, screen pulse). Degraded state uses geometric crack overlay, not color alone
- **No simultaneous full-screen effects**: VFX is localized to the relevant card/icon; full-screen flashes reserved only for degraded warning at ≤30% opacity
### DataTable Extension
`DRTowerAssemblySound` (or extension of `DRSound`):
| SoundId | AssetName | Volume | Notes |
|---------|-----------|--------|-------|
| TowerAssemble | Tower_Assemble | 0.8 | C5-E5-G5 arpeggio, 200ms |
| TowerAssemblePremium | Tower_Assemble_Premium | 0.8 | C5-E5-G5-C6, 250ms (Blue+) |
| TowerRosterAdd | Tower_Roster_Add | 0.6 | G5-E5-G5, 80ms |
| TowerRosterRemove | Tower_Roster_Remove | 0.5 | G5-D5, 100ms |
| TowerDegrade | Tower_Degrade | 0.7 | Minor-2nd + power-down sweep, 350ms |
| TowerDisassemble | Tower_Disassemble | 0.7 | G5-E5-C5 reverse arpeggio, 200ms |
## UI Requirements
### Assembly Phase Screen
**Trigger**: Auto-displayed after any node resolves (per Node System).
**Content**:
- **Inventory Grid**: All owned unassembled components, grouped by type (Muzzle, Bearing, Base)
- **Tower Slots**: 3 assembly slots (Muzzle, Bearing, Base) — drop targets for components
- **Assembled Towers Panel**: All towers built so far in this run
- **Combat Roster**: 4 slots showing currently rostered towers (ready for next combat)
- **Next Node Preview**: 2 outgoing edge destinations visible during Assembly Phase (from Node System)
- **Ready Button**: Confirms Assembly Phase is complete; triggers Node Choice
**Interactions**:
- Drag components from inventory into assembly slots
- Click "Assemble" button when 3 slots are filled → creates tower
- Click tower → shows tower stats, rarity, tags; options to "Add to Roster" or "Disassemble"
- Drag tower from Assembled Towers to Roster slots
- Click "Disassemble" on tower → free disassemble, components return to inventory
- Click "Ready" → proceeds to Node Choice
**Empty State**:
- No unassembled components: inventory grid shows "No components — combat drops will appear here"
- No assembled towers: Assembled Towers panel shows "Assemble towers from components above"
- Roster empty: slots show dotted outline placeholder
**Tower Info Tooltip/Panel** (on tower click):
- Tower name and rarity (color-coded)
- Stats: AttackDamage (5 levels), RotateSpeed, AttackRange, AttackSpeed
- Tags with stack counts
- Component sources (Muzzle/Bearing/Base names)
- Endurance bars for each component (0–100%)
### Roster Management UI
**Roster Slots** (4 slots):
- Each slot shows: tower icon, rarity color border, tower name
- Drag tower to roster slot to add
- Click "X" on rostered tower to remove from roster
- Degraded tower (0 endurance): slot shows crack overlay, cannot be deployed
- Roster full (4/4): slots show "FULL" indicator; drag-and-drop returns tower to Assembled Towers
### Accessibility
- All rarity colors are paired with distinct iconography
- Degraded state uses geometric crack shape, not color alone
- Tag stack counts shown numerically, not just visually
- Component endurance shown as percentage + bar
- All interactions possible via keyboard (tab navigation, enter to confirm)
### Assembly
- **GIVEN** the player has 3 unassembled components (Muzzle, Bearing, Base), **WHEN** `TryAssembleTower(muzzleId, bearingId, baseId)` is called, **THEN** a new `TowerItemData` is created with aggregated stats, rarity is computed correctly, tags are merged, and all three components' `IsAssembledIntoTower` flags are set to `true`.
- **GIVEN** a Muzzle component is already assembled into a tower, **WHEN** the player attempts to use that component in a new `TryAssembleTower` call, **THEN** the call returns `false` and no tower is created.
- **GIVEN** any component's DR config row is missing, **WHEN** `TryAssembleTower` is called, **THEN** the call returns `false` and no tower is created.
### Disassembling
- **GIVEN** an assembled tower is not in the combat roster, **WHEN** `TryDisassembleTower(towerInstanceId)` is called, **THEN** the tower is removed from `inventory.Towers`, all three components' `IsAssembledIntoTower` flags are set to `false`, and their `Endurance` values are preserved.
- **GIVEN** an assembled tower is currently in the combat roster, **WHEN** `TryDisassembleTower(towerInstanceId)` is called, **THEN** the tower is automatically removed from the roster before disassembling.
### Roster Management
- **GIVEN** fewer than 4 towers are in the roster, **WHEN** `TryAddParticipantTower(towerInstanceId)` is called with a valid non-degraded tower, **THEN** the tower is added to `ParticipantTowerInstanceIds`.
- **GIVEN** 4 towers are already in the roster, **WHEN** `TryAddParticipantTower` is called with a valid tower, **THEN** the call returns `ParticipantTowerAssignFailureReason.ParticipantAreaFull` and no change occurs.
- **GIVEN** a tower has a component with `Endurance = 0`, **WHEN** `TryAddParticipantTower` is called, **THEN** the call returns `ParticipantTowerAssignResult` with `FailureReason = InvalidTower` and the tower is not added.
### Stats and Formulas
- **GIVEN** a Green-rarity Muzzle with `AttackDamage = [10, 20, 30, 40, 50]` and `AttackDamagePerLevel = 3`, **WHEN** a tower is assembled from it, **THEN** the tower's `AttackDamage` array is `[20, 23, 26, 29, 32]`.
- **GIVEN** a tower is assembled from Muzzle=Green, Bearing=Blue, Base=Purple, **WHEN** rarity is computed, **THEN** the tower rarity is Blue (average of 2+3+4 = 3.0).
- **GIVEN** a tower is assembled from Muzzle=[Fire], Bearing=[Ice], Base=[Fire], **WHEN** tags are aggregated, **THEN** the tower has Fire with `TotalStack=2` and Ice with `TotalStack=1`.
### Endurance
- **GIVEN** a tower with all components at `Endurance > 0` is in the roster, **WHEN** combat ends and `ReduceTowerEndurance` is called, **THEN** all three components' endurance is reduced.
- **GIVEN** a component in an assembled tower reaches `Endurance = 0`, **WHEN** `TryAddParticipantTower` is called for that tower, **THEN** the call fails and the tower cannot be rostered.
## Open Questions
### 1. TryDisassembleTower Implementation Gap
**Status**: OPEN — The design doc (R3) specifies free disassembling, but `TryDisassembleTower` method does not exist in `PlayerInventoryComponent` or `PlayerInventoryTowerAssemblyService`. Implementation is needed.
### 2. Auto-Cleanup of Degraded Rostered Towers
**Status**: OPEN — If a tower in the roster has a component reach 0 endurance mid-combat, the tower remains in `ParticipantTowerInstanceIds` but becomes non-functional. Should there be an automatic removal from roster when a tower becomes degraded? Currently no such mechanism exists.
### 3. Repair Mechanism for 0-Endurance Components
**Status**: OUT OF SCOPE — Design doc R6 notes that 0-endurance components cannot be disassembled and must be repaired. Repair mechanism (e.g., gold cost to restore endurance) is out of scope for this GDD. A future Repair GDD should address this.
### 4. Component Compatibility Rules
**Status**: RESOLVED — No affinity or compatibility rules are enforced. Any Muzzle+Bearing+Base combination is valid. `AttackMethodType` and `AttackPropertyType` are independent dimensions.
+155
View File
@@ -0,0 +1,155 @@
# Entity Registry
# Auto-generated from GDD design sessions. Do not edit manually.
# To update: run /design-system for each system, the registry is populated in Phase 5b.
entries:
# ─── Tower Assembly (design/gdd/tower-assembly.md) ───────────────────────────
- name: TowerLevelCount
type: constant
value: 5
unit: levels
source: design/gdd/tower-assembly.md
description: Fixed number of tower level tiers. All tower stat arrays have exactly 5 elements.
- name: MaxParticipantTowerCount
type: constant
value: 4
unit: towers
source: design/gdd/tower-assembly.md
description: Maximum number of towers that can be rostered for combat.
- name: TowerEnduranceRange
type: constant
value: "0–100"
unit: percent
source: design/gdd/tower-assembly.md
description: Valid range for component Endurance. 0 means non-functional.
- name: TowerStatScaling
type: formula
variables:
- name: rarityBaseArray
symbol: B
type: "int[5] or float[5]"
description: Per-rarity base values from component stat array
- name: rarity
symbol: R
type: RarityType
description: Component rarity (White=1 to Red=5)
- name: perLevel
symbol: P
type: int or float
description: Per-level increment from data table row
output:
range: "varies by component data table"
description: "statValue[i] = B[Clamp(R-1,0,4)] + P*i, for i in 0..4"
source: design/gdd/tower-assembly.md
- name: TowerRarityResolution
type: formula
variables:
- name: muzzleRarity
symbol: mR
type: RarityType
description: Muzzle component rarity (White=1 to Red=5)
- name: bearingRarity
symbol: bR
type: RarityType
description: Bearing component rarity (White=1 to Red=5)
- name: baseRarity
symbol: baseR
type: RarityType
description: Base component rarity (White=1 to Red=5)
output:
range: "White..Red"
description: "Clamp(Round((mR + bR + baseR) / 3), White, Red)"
source: design/gdd/tower-assembly.md
- name: TagAggregation
type: formula
variables:
- name: muzzleTags
type: "TagType[]"
description: Tags from Muzzle component
- name: bearingTags
type: "TagType[]"
description: Tags from Bearing component
- name: baseTags
type: "TagType[]"
description: Tags from Base component
output:
range: "TagRuntimeData[]"
description: "Merged tags with TotalStack = count of components carrying each tag. Min TotalStack=1."
source: design/gdd/tower-assembly.md
# ─── Node System (design/gdd/node-system.md) ────────────────────────────────
- name: BossBonusGold
type: constant
value: 200
unit: gold
source: design/gdd/node-system.md
description: Flat bonus gold awarded for defeating the Boss node.
- name: TotalNodesPerRun
type: constant
value: 10
unit: nodes
source: design/gdd/node-system.md
description: Fixed number of nodes per run. Node 10 is always BossCombat.
# ─── Shop System (design/gdd/shop.md) ────────────────────────────────
- name: MaxPlayerGold
type: constant
value: 9999
unit: gold
source: design/gdd/shop.md
description: Hard cap on player gold. AddGold silently discards excess above this value.
# ─── Event System (design/gdd/event-system.md) ────────────────────────
- name: EventSelectionSeedFormula
type: formula
variables:
- name: runSeed
symbol: S
type: int
description: Run seed from RunNodeExecutionContext
- name: sequenceIndex
symbol: I
type: int
description: Node sequence index in the run
- name: nodeId
symbol: N
type: int
description: Unique node identifier
output:
range: "int"
description: "seed = (((S * 31) + I) * 31) + N"
source: design/gdd/event-system.md
- name: EventProbabilityRollSeedFormula
type: formula
variables:
- name: runSeed
symbol: S
type: int
description: Run seed
- name: sequenceIndex
symbol: I
type: int
description: Node sequence index
- name: eventId
symbol: E
type: int
description: Event identifier from DREvent
- name: optionIndex
symbol: O
type: int
description: Option index within the event
output:
range: "int"
description: "seed = S + I + E + O + 0 + 17"
source: design/gdd/event-system.md