update design

This commit is contained in:
SepComet 2026-04-30 10:36:25 +08:00
parent 55506eff9a
commit a38a8d4b39
25 changed files with 999 additions and 113 deletions

View File

@ -2,7 +2,8 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Skill(consistency-check)", "Skill(consistency-check)",
"Bash(xargs grep *)" "Bash(xargs grep *)",
"Skill(design-review)"
] ]
} }
} }

1
.gitignore vendored
View File

@ -109,3 +109,4 @@ InitTestScene*.unity*
/node_modules /node_modules
package.json package.json
package-lock.json package-lock.json
/.omc

View File

@ -1 +1 @@
{"session_id":"7ba87a7d-413e-40a1-84b0-fe18f7a31f33","transcript_path":"C:\\Users\\September\\.claude\\projects\\D--Learn-GameLearn-UnityProjects-GeometryTD\\7ba87a7d-413e-40a1-84b0-fe18f7a31f33.jsonl","cwd":"D:\\Learn\\GameLearn\\UnityProjects\\GeometryTD","model":{"id":"claude-sonnet-4-6","display_name":"Sonnet 4.6"},"workspace":{"current_dir":"D:\\Learn\\GameLearn\\UnityProjects\\GeometryTD","project_dir":"D:\\Learn\\GameLearn\\UnityProjects\\GeometryTD","added_dirs":[]},"version":"2.1.114","output_style":{"name":"default"},"cost":{"total_cost_usd":0,"total_duration_ms":2177,"total_api_duration_ms":0,"total_lines_added":0,"total_lines_removed":0},"context_window":{"total_input_tokens":0,"total_output_tokens":0,"context_window_size":200000,"current_usage":null,"used_percentage":null,"remaining_percentage":null},"exceeds_200k_tokens":false} {"session_id":"71ce8c2b-9037-4d0b-867e-e80cb4ecc576","transcript_path":"C:\\Users\\Administrator\\.claude\\projects\\C--UnityProjects-GeometryTowerDefense\\71ce8c2b-9037-4d0b-867e-e80cb4ecc576.jsonl","cwd":"C:\\UnityProjects\\GeometryTowerDefense","model":{"id":"MiniMax-M2.7-highspeed","display_name":"MiniMax-M2.7-highspeed"},"workspace":{"current_dir":"C:\\UnityProjects\\GeometryTowerDefense","project_dir":"C:\\UnityProjects\\GeometryTowerDefense","added_dirs":[]},"version":"2.1.119","output_style":{"name":"default"},"cost":{"total_cost_usd":159.46623649999992,"total_duration_ms":8594239,"total_api_duration_ms":6407450,"total_lines_added":1047,"total_lines_removed":155},"context_window":{"total_input_tokens":27661900,"total_output_tokens":289868,"context_window_size":200000,"current_usage":{"input_tokens":135597,"output_tokens":193,"cache_creation_input_tokens":0,"cache_read_input_tokens":26275},"used_percentage":81,"remaining_percentage":19},"exceeds_200k_tokens":false,"fast_mode":false,"effort":{"level":"high"},"thinking":{"enabled":true}}

View File

@ -145,6 +145,12 @@ namespace GeometryTD.CustomComponent
return _towerRosterService.ReduceTowerEndurance(towerInstanceIds, enduranceLoss); return _towerRosterService.ReduceTowerEndurance(towerInstanceIds, enduranceLoss);
} }
public bool TryDisassembleTower(long towerInstanceId)
{
EnsureInitialized();
return _towerAssemblyService.TryDisassembleTower(towerInstanceId);
}
public bool TryGetSaleCandidate(long itemId, out PlayerInventorySaleCandidate candidate) public bool TryGetSaleCandidate(long itemId, out PlayerInventorySaleCandidate candidate)
{ {
EnsureInitialized(); EnsureInitialized();

View File

@ -123,7 +123,7 @@ namespace GeometryTD.CustomComponent
int[] values = new int[TowerLevelCount]; int[] values = new int[TowerLevelCount];
for (int i = 0; i < values.Length; i++) for (int i = 0; i < values.Length; i++)
{ {
values[i] = baseValue + perLevel * i; values[i] = Mathf.Max(0, baseValue + perLevel * i);
} }
return values; return values;
@ -135,7 +135,7 @@ namespace GeometryTD.CustomComponent
float[] values = new float[TowerLevelCount]; float[] values = new float[TowerLevelCount];
for (int i = 0; i < values.Length; i++) for (int i = 0; i < values.Length; i++)
{ {
values[i] = baseValue + perLevel * i; values[i] = Mathf.Max(0f, baseValue + perLevel * i);
} }
return values; return values;
@ -180,5 +180,37 @@ namespace GeometryTD.CustomComponent
_drBaseComp ??= GameEntry.DataTable.GetDataTable<DRBaseComp>(); _drBaseComp ??= GameEntry.DataTable.GetDataTable<DRBaseComp>();
return _drBaseComp; return _drBaseComp;
} }
public bool TryDisassembleTower(long towerInstanceId)
{
BackpackInventoryData inventory = _queryModel.Inventory;
if (towerInstanceId <= 0)
{
return false;
}
if (!_queryModel.TryGetTowerById(towerInstanceId, out TowerItemData tower) || tower == null)
{
return false;
}
if (!_queryModel.TryGetComponentById(inventory.MuzzleComponents, tower.MuzzleComponentInstanceId, out MuzzleCompItemData muzzleComp) ||
!_queryModel.TryGetComponentById(inventory.BearingComponents, tower.BearingComponentInstanceId, out BearingCompItemData bearingComp) ||
!_queryModel.TryGetComponentById(inventory.BaseComponents, tower.BaseComponentInstanceId, out BaseCompItemData baseComp))
{
return false;
}
InventoryParticipantUtility.TryRemoveParticipantTower(inventory, towerInstanceId, MaxParticipantTowerCount);
muzzleComp.IsAssembledIntoTower = false;
bearingComp.IsAssembledIntoTower = false;
baseComp.IsAssembledIntoTower = false;
inventory.Towers.Remove(tower);
return true;
}
private const int MaxParticipantTowerCount = 4;
} }
} }

View File

@ -24,8 +24,8 @@ namespace GeometryTD.Definition
int normalizedBase = (int)NormalizeComponentRarity(baseRarity); int normalizedBase = (int)NormalizeComponentRarity(baseRarity);
float average = (normalizedMuzzle + normalizedBearing + normalizedBase) / 3f; float average = (normalizedMuzzle + normalizedBearing + normalizedBase) / 3f;
int rounded = Mathf.RoundToInt(average); int floored = Mathf.FloorToInt(average);
int clamped = Mathf.Clamp(rounded, (int)RarityType.White, (int)RarityType.Red); int clamped = Mathf.Clamp(floored, (int)RarityType.White, (int)RarityType.Red);
return (RarityType)clamped; return (RarityType)clamped;
} }
} }

View File

@ -15,7 +15,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 启动流程 ## 启动流程
游戏启动路径:`Assets/Launcher.unity` → `ProcedureMenu``Menu.unity``Main.unity`。流程切换通过 `ChangeState<T>` 驱动。 `GameEntry` 是游戏主 bootstrap 类。流程切换通过 `ChangeState<T>` 驱动,数据传递使用 `procedureOwner.SetData`
游戏启动路径:`Assets/Launcher.unity` → `ProcedureMenu``Menu.unity``Main.unity`
## 架构概览 ## 架构概览
@ -126,12 +128,55 @@ DROutGameDropPool, DRTagConfig, DRRarityTagBudget
### 标签系统 (Tag System) ### 标签系统 (Tag System)
组件产出Tag 生成/掉落/奖励候选)由 `InventoryGenerationComponent` 统一运行时入口,编排 `DropPoolRoller`、`RewardCandidateBuilder`、`OutGameDropRuleService`。详见 `docs/TagSystemDesign.md``docs/TagSystemRoadmap.md` 组件产出Tag 生成/掉落/奖励候选)由 `InventoryGenerationComponent` 统一运行时入口,编排 `DropPoolRoller`、`RewardCandidateBuilder`、`OutGameDropRuleService`。
**正式首发 7 Tag** `Fire`、`Ice`、`Crit`、`Execution`、`Shatter`、`Inferno`、`AbsoluteZero`
**明确后移 5 Tag仅占位配置未生效** `BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate`
**运行时规则:**
- Tag 在组件实例创建时随机;组塔阶段只汇总,不重新随机
- 重复 Tag 不丢弃,组塔时转为塔级 `Stack`
- 战斗载荷保存塔级 `TagRuntimeData[]`
详见 `docs/TagSystemDesign.md`
### 商店与 RepoForm ### 商店与 RepoForm
`ShopNode` 只承载玩家购买组件的逻辑;`RepoForm` 负责出售功能commit `2e54acb`)。两者职责分离,不得交叉。 `ShopNode` 只承载玩家购买组件的逻辑;`RepoForm` 负责出售功能commit `2e54acb`)。两者职责分离,不得交叉。
### 战斗节点系统 (CombatNode)
`CombatNodeComponent` 是战斗域的入口门面Facade不持有战斗内资源真值。核心架构
```
CombatNodeComponent入口
└── CombatScheduler状态机管理器
├── CombatLoadingState
├── CombatRunningPhaseState
├── CombatWaitingForPhaseEndState
├── CombatSettlementState
├── CombatRewardSelectionState
├── CombatFinishFormState
├── CombatWaitingForReturnState
└── CombatFailedState
CombatScheduler 持有:
├── CombatRunResourceStore # Coin/Gold/BaseHp/背包快照 唯一真值来源
├── EnemyManager # 敌人域 Facade
├── PhaseLoopRuntime # Phase 运行时与结束标记
└── InventoryGenerationComponent # 组件产出统一入口
```
**核心不变量(必须保持):**
- `CombatScheduler` 只做状态机管理与共享运行时收口,不继续吸收具体业务细节
- 状态切换只能通过 `CombatScheduler.ChangeState(...)` 完成
- 敌人事件处理入口不直接切状态,状态只能在自己的 `OnUpdate` 中决定迁移
- `MapEntity` 通过 `MapData + Event` 获取战斗上下文,不反查 Combat 域内部运行时
- 组件产出规则以 `InventoryGenerationComponent` 为统一运行时入口
详见 `docs/CombatNodeArchitecture.md`
## 测试 ## 测试
测试位于 `Assets/Tests/EditMode/` 目录下,使用 Unity Test Framework 运行。在 Unity Editor 中通过 `Window > General > Test Runner` 执行。运行单个测试:选中目标测试,点击 `Run Selected` 测试位于 `Assets/Tests/EditMode/` 目录下,使用 Unity Test Framework 运行。在 Unity Editor 中通过 `Window > General > Test Runner` 执行。运行单个测试:选中目标测试,点击 `Run Selected`

View File

@ -1,9 +1,9 @@
# Event System # Event System
> **Status**: Designed > **Status**: Revised — blocking fixes applied (Pillar placeholder defined; Node System dependency updated to Approved; OQ1 resolved)
> **Author**: SepComet > **Author**: SepComet
> **Last Updated**: 2026-04-29 > **Last Updated**: 2026-04-30
> **Implements Pillar**: [To be designed] > **Implements Pillar**: Narrative surprise and meaningful stakes — events break combat rhythm, adding story variety and meaningful choices
## Overview ## Overview
@ -181,7 +181,7 @@ If `TrySelectOption()` receives an out-of-range `optionIndex`, it returns `false
| System | Status | Interface Contract | | System | Status | Interface Contract |
|--------|--------|-------------------| |--------|--------|-------------------|
| Node System | Designed (`design/gdd/node-system.md`) | Fires `StartEvent(context)`, receives `NodeCompleteEventArgs` | | Node System | **Approved** (`design/gdd/node-system.md`) | Fires `StartEvent(context)`, receives `NodeCompleteEventArgs` |
| PlayerInventoryComponent | Code only | `GetInventorySnapshot()` / `ReplaceInventorySnapshot()` | | PlayerInventoryComponent | Code only | `GetInventorySnapshot()` / `ReplaceInventorySnapshot()` |
| InventoryGenerationComponent | Code only | `BuildEventRewardComponents()` | | InventoryGenerationComponent | Code only | `BuildEventRewardComponents()` |
| DataTable (DREvent) | Code only | Event definitions with JSON `OptionsRaw` | | DataTable (DREvent) | Code only | Event definitions with JSON `OptionsRaw` |
@ -323,7 +323,7 @@ Probability values in `Event.txt` are designer-facing only. The UI must not reve
## Open Questions ## Open Questions
**OQ1 — Event Frequency in Run** **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. **Status**: RESOLVED — Per the approved Node System GDD, the Plain theme places exactly one Event node at position 6. Events may only occupy positions 48 (future themes may vary). The Event System is not dead code — exactly 1 Event node appears per run in the Plain theme.
**OQ2 — Player-Driven Event Avoidance** **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? 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?

View File

@ -0,0 +1,140 @@
# Cross-GDD Review Report
> **Date**: 2026-04-30
> **Reviewer**: Claude Code (consistency + design theory agents)
> **GDDs Reviewed**: 6 (node-system.md, tower-assembly.md, shop.md, event-system.md, progression.md, CombatNodeArchitecture.md)
> **Systems Covered**: Node System, Combat System, Tower Assembly, Shop System, Event System, Progression
---
## Consistency Issues
### Warnings
⚠️ **[C-W1] Tower Assembly Dependency Direction Is Inverted**
- **Files**: `tower-assembly.md` (Dependencies, line 207)
- Tower Assembly lists **Node System as an upstream (hard) dependency** — but the relationship is reversed. Node System **calls into** Tower Assembly during Assembly Phase. Tower Assembly is the **downstream** service, not the upstream.
- **Correct model**: Node System → (calls) → Tower Assembly. Tower Assembly is downstream.
- **Impact**: Logical error in dependency model. Does not block implementation.
- **Fix**: Swap direction — Tower Assembly should list Combat System as downstream consumer, and remove Node System as upstream dependency.
⚠️ **[C-W2] RunStats.goldEarned Loss-Path Contract Not Formally Sealed**
- **Files**: `node-system.md` (Edge Cases), `progression.md` (AC5b), `CombatNodeArchitecture.md` (§12.3.2)
- Design intent is **consistent**: zero gold on loss. `CombatNodeArchitecture.md §12.3.2` correctly states `GainedGold = 0` on loss, and `node-system.md` Edge Cases states "no partial rewards."
- However: `progression.md` AC5b says `totalGoldEarned += goldEarned` on loss — correct only if `goldEarned == 0`. The **RunStats schema** (`goldEarned >= 0`) allows non-zero values without a loss-specific constraint.
- No AC in `node-system.md` formally seals: `runStats.goldEarned == 0` on loss.
- **Impact**: Design intent is consistent; GDD contract is not airtight. A future implementer could introduce a non-zero gold-on-loss path without violating any AC.
- **Fix**: Add explicit AC in node-system.md: `"**GIVEN** the player loses Combat at any node, **WHEN** `RecordRunEnd` is called, **THEN** `runStats.goldEarned == 0`."`
⚠️ **[C-W3] Event System Does Not List Node System as Dependency Source for BossBonusGold**
- **Files**: `event-system.md` (Dependencies, line 178), `node-system.md` (line 122, line 223)
- `BossBonusGold = 200` is declared in `node-system.md` but `event-system.md` does not list `node-system.md` as a dependency source for this value.
- Not a functional issue — `BossBonusGold` is not referenced in Event System mechanics.
- **Fix**: Add `BossBonusGold` sourcing note in `event-system.md` Dependencies if the value is ever used in event design.
---
## Game Design Issues
### Warnings
⚠️ **[G-W1] Tag System GDD Does Not Exist — Tag Stacking Is Unconstrained (HIGHEST PRIORITY)**
- **Files**: `tower-assembly.md` (TagAggregation), `design/gdd/tag-system.md` (**DOES NOT EXIST**)
- `TowerAssembly.AggregateTowerTags()` produces `TotalStack = occurrence count (13)` per tag. The GDD explicitly states: "the effective power multiplier semantics of this count... are defined in the Tag System GDD."
- **The Tag System GDD does not exist in the codebase.**
- **Structural dominant strategy**: If `Stack=3` is strictly better than `Stack=2` or `Stack=1` for any tag, the optimal tower always uses 3 copies of the best available tag. The "optimization puzzle" has a single solution.
- **Fix**: Write `design/gdd/tag-system.md` before implementing Tower Assembly. Define stacking semantics (linear? diminishing returns? multiplicative ceiling?).
⚠️ **[G-W2] Event Rewards Share Rarity Budget with Shop — No Distinct Tier**
- **Files**: `event-system.md` (ER4: AddRandomComps), `shop.md` (SR1), `progression.md` (pool unlock chain)
- `AddRandomComps` calls `InventoryGenerationComponent.BuildEventRewardComponents()` — the same generation pipeline as shop.
- Events cannot reward a rarity the player hasn't unlocked in Progression.
- Event System pillar ("narrative surprise and meaningful stakes") is undermined when the reward tier is identical to shop.
- **Fix**: Consider a separate event rarity budget (e.g., events drop at most Blue even if player has Red unlocked), or a distinct reward category.
⚠️ **[G-W3] Sell Price at ~50% Actively Discourages Experimentation**
- **Files**: `shop.md` (SR6, Formula 2), `tower-assembly.md` (Fantasy line 20)
- Tower Assembly fantasy: "free reconfiguration," "optimization mastery under constraint"
- Shop sell formula: `Round((minPrice + maxPrice) / 2.0f)` — ~50% of max buy price
- Player who buys a White component for 100, tries it, sells it back gets 50. Half their experiment cost is destroyed.
- Free disassemble partially offsets this — but if inventory is full and player wants to try a new component, they must sell something.
- **Fix**: Make sell ratio a tunable knob (recommend 6070%) or add a component enhancement sink that uses excess components.
⚠️ **[G-W4] Assembly Phase Has 5 Simultaneous Panels — Cognitive Load Risk**
- **Files**: `tower-assembly.md` (Assembly Phase Screen, UI Requirements)
- Panels: Inventory Grid + 3 Assembly Slots + Assembled Towers + Combat Roster + Next Node Preview
- The GDD's own design note: "Ready button is enabled and clicking it proceeds... without modification." — the designed workaround is to click Ready when nothing needs doing.
- **Structural problem**: Players who understand the game will click Ready almost every time after the first few runs. The elaborate UI becomes noise.
- **Fix**: Merge Assembled Towers panel with Combat Roster (single "Your Towers" panel with roster checkboxes), or introduce a "Quick Assemble" mode that hides unused panels.
⚠️ **[G-W5] "Build Toward the Boss" Fantasy Conflicts with Boss Scaling Formula**
- **Files**: `tower-assembly.md` (Fantasy line 22), `node-system.md` (Boss HP formula)
- Tower Assembly fantasy: "each assembly decision accumulates toward the final confrontation; the boss fight is where build quality is ultimately tested."
- `BossEffectiveHp = BaseHp × 2^completedLoopCount` — Boss HP scales with rounds survived, not player power.
- Better build → faster kill → fewer loops survived → lower effective HP. The boss IS a test of kill speed, not raw survivability.
- The stated fantasy implies build quality = being powerful enough to win, not kill-speed pressure.
- **Fix**: Revise Tower Assembly Fantasy line 22 to reflect kill-speed pressure, not "build quality tested."
⚠️ **[G-W6] Difficulty Spike at Normal→Hard Before Component Pool Catches Up**
- **Files**: `progression.md` (pool unlock chain), `node-system.md` (Plain theme node sequence)
- Green pool unlocks on first Normal win. Hard difficulty unlocks after defeating Boss on Normal.
- Player enters Hard with only Green components. The first 4 Hard combat nodes are fought with inadequate tools.
- **Fix**: Consider starting Green pool available before first win, or give Hard difficulty a more generous starting loadout.
⚠️ **[G-W7] Gold Cap (9999) Is Effectively Unreachable in Normal Play**
- **Files**: `shop.md` (MaxPlayerGold), `node-system.md` (per-run gold ~1100)
- ~1100 gold per full winning run. 9999 cap. ~9 wins to cap, assuming minimal spending.
- With free disassemble and no repair mechanism, spending pressure is low. A skilled player accumulates excess gold with no outlet.
- **Fix**: Consider a prestige mechanic that converts excess gold into permanent upgrades, or significantly lower `MaxPlayerGold`.
---
## Cross-System Scenario Issues
**Scenarios walked:** 1 (Full Run End chain)
### Full Winning Run — RunEnd Chain
1. Node System: Boss defeated → fires `NodeCompleteEventArgs(CombatWon=true)`
2. Node System → `Progression.RecordRunEnd(runStats)` with `{goldEarned, nodesCompleted=10, bossDefeated=true, coinsEarned}`
3. Progression: `LifetimeStats` updated → unlock evaluation → `UnlockedEventArgs` with `UnlockResult[]`
4. UI / Run End Screen: Toast popup for new unlocks
**Issues found:**
⚠️ **G-W7 (display gap)**: Run End Victory screen cannot display "Gold earned this run" without a data path. `UnlockedEventArgs` carries no `goldEarnedThisRun` field. This was G2 in the 2026-04-29 review — flagged as blocking — but is a **display gap, not a crash**. The screen shows 0 gold implicitly. Recommend adding `goldEarnedThisRun` to `UnlockedEventArgs` for UX completeness.
---
## GDDs Flagged for Revision
| GDD | Reason | Type | Priority |
|-----|--------|------|----------|
| `tower-assembly.md` | C-W1: dependency direction inverted; G-W1: Tag System GDD missing; G-W5: boss fantasy conflict | Consistency + Design Theory | **High** |
| `event-system.md` | G-W2: event rarity budget not distinct from shop | Design Theory | Medium |
| `shop.md` | G-W3: sell ratio tunable knob; G-W7: gold cap tuning | Design Theory | Medium |
| `node-system.md` | C-W2: loss gold AC gap; G-W6: difficulty spike | Consistency + Design Theory | Medium |
---
## Verdict: **CONCERNS**
No blocking issues — no system is mechanically broken. All 6 systems are implementable and internally coherent.
Warnings present — 7 warnings and 1 scenario info item. These should be resolved before or during implementation but do not prevent architecture work from beginning.
**Most urgent**: G-W1 (Tag System GDD missing) — without it, `TagAggregation` output is undefined and Tower Assembly cannot be fully implemented.
---
## Recommended Actions
1. **G-W1 (HIGHEST)**: Write `design/gdd/tag-system.md` — define tag stacking semantics before implementing Tower Assembly
2. **C-W1**: Fix Tower Assembly dependency direction — swap Node System from upstream to downstream
3. **C-W2**: Add formal AC in node-system.md sealing `runStats.goldEarned == 0` on loss
4. **G-W5**: Revise Tower Assembly Fantasy line 22 — "build toward the boss" should reflect kill-speed pressure
5. **G-W2**: Event System — consider separate rarity budget for event rewards
6. **G-W3**: Shop — make sell ratio a tunable knob
7. **G-W6**: Progression — address Normal→Hard difficulty spike before component pool upgrade
8. **G-W7**: Shop — consider gold cap tuning or prestige mechanic
9. **C-W3**: Event System — add BossBonusGold sourcing reference to Node System

View File

@ -1,8 +1,8 @@
# Node System (节点系统) # Node System (节点系统)
> **Status**: Revised — post-design-review fixes applied (edge divergence, economy, Boss scaling, Assembly Phase entry, Boss color, Coin sink, AC gaps) > **Status**: Approved — Fantasy Into-the-Breach reference corrected; completedLoopCount cap description corrected; all 6th-review blocking items resolved; loss-gold AC added (C-W2 resolved)
> **Author**: SepComet + agents > **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) > **Last Updated**: 2026-04-30 (post-review: Fantasy line 37 corrected to reflect opaque level variants; completedLoopCount cap description corrected from 2^31 to 2^30 × BaseHp)
> **Implements Pillar**: Core game loop navigation — players drive their own path through the run > **Implements Pillar**: Core game loop navigation — players drive their own path through the run
## Overview ## Overview
@ -34,7 +34,7 @@ The player should feel:
- **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 - **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" - **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. **Reference**: Into the Breach's "visible consequences of choices" feeling — the player can see the full run track from start, sees the Boss glowing at the end, and must prepare their tower builds 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 (the specific variant is revealed at the node, not beforehand), 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.
## Detailed Design ## Detailed Design
@ -57,7 +57,7 @@ The player should feel:
9. Repeat steps 48 until Node 10 (Boss) is reached. 9. Repeat steps 48 until Node 10 (Boss) is reached.
**Combat Loss Rules** **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. 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. `CombatNodeComponent` fires `NodeCompleteEventArgs(CombatWon=false)`; the Procedure layer receives this and transitions to `RunEnd(Failure)`.
**Data Persistence** **Data Persistence**
11. Within a run: Inventory, Repository, Gold, Coin, Tower configs, visited node history, and active buffs/debuffs **persist across nodes**. 11. Within a run: Inventory, Repository, Gold, Coin, Tower configs, visited node history, and active buffs/debuffs **persist across nodes**.
@ -78,6 +78,7 @@ The player should feel:
| `NodeEntry` | Player arrives at the node. Node-type logic triggers (Combat/Event/Shop/Boss). | → `AssemblyPhase` on node resolved | | `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) | | `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` | | `RunEnd` | Victory or failure screen. Stats recorded. Return to main menu. | → `RunIdle` |
| `RunNodeStatus.Exception` | Entered only when a node encounters an **unexpected error** (e.g., data load failure, invalid state). This is not normal gameplay flow. Treated as `RunEnd(Failure)` for all downstream purposes. | → `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.* *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.*
@ -108,16 +109,18 @@ Note: There is no `NodeComponent` class. Orchestration is handled by the Procedu
**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. **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: Gold earned per node completed, plus boss level reward and bonus:
`TotalGold = Σ DRLevel.RewardGold(CompletedCombatNodes) + (HasDefeatedBoss ? BossBonus : 0)` `TotalGold = Σ DRLevel.RewardGold(CompletedCombatNodes) + BossLevelGold + (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`. 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` if defeated. `HasDefeatedBoss` is determined by whether the player won the Boss encounter; losing at the Boss does not set this flag.
| Variable | Symbol | Type | Range | Description | > **Note on impossible states**: `HasDefeatedBoss = true` with `n = 0` is unreachable — the Boss cannot be reached without completing at least one combat node.
|----------|--------|------|-------|-------------| |----------|--------|------|-------|-------------|
| Combat nodes cleared | n | int | 06 | 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. | | Combat nodes cleared | n | int | 06 | 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 defeated flag | HasDefeatedBoss | bool | — | True if the Boss was successfully defeated in this run. |
| Boss bonus | BossBonus | int | 200 | Flat bonus for defeating Boss (applied only if `HasDefeatedBoss = true`) | | Boss bonus | BossBonus | int | 200 | Flat bonus for defeating Boss (applied only if `HasDefeatedBoss = true`) |
| Boss level gold | BossLevelGold | int | ≥ 0 | Gold from `DRLevel.RewardGold` of the Boss level (Node 10). Added regardless of `HasDefeatedBoss` when Boss is reached. |
**Per-node gold (REVISED — economy rebalanced):** **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: After rebalancing, the illustrative values for the Plain theme sequence (Combat at L1, L2, L3, L1; Boss at L4) are:
@ -142,20 +145,17 @@ After rebalancing, the illustrative values for the Plain theme sequence (Combat
### 2. Boss Difficulty Scaling ### 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. Boss difficulty scales with the Boss node's loop/round count.
`BossEffectiveHp = DRLevel.BaseHp × 2^(completedLoopCount) × (1 + 0.1 × nodesCompleted)` `BossEffectiveHp = DRLevel.BaseHp × 2^completedLoopCount`
| Variable | Symbol | Type | Range | Description | | 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. | | 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 | 031 | 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`. | | Completed loop count | completedLoopCount | int | 030 | 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 30 loops — at 30, `2^30 × BaseHp` reaches ~2×10^9; implementation returns `int.MaxValue` at the 30th cap to prevent overflow. |
| Nodes completed | nodesCompleted | int | 09 | 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.01.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 | | 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 matches `EnemyConfigProvider.ResolveScaledEnemyBaseHp`. The `DRLevel` fields used are: `Id`, `LevelThemeType`, `BaseHp`, `StartCoin`, `VictoryType`, `VictoryParam`, `RewardGold`.
**Note:** Formula extends `EnemyConfigProvider.ResolveScaledEnemyBaseHp` with a run-progress factor. The `DRLevel` fields used are: `Id`, `LevelThemeType`, `BaseHp`, `StartCoin`, `VictoryType`, `VictoryParam`, `RewardGold`.
## Edge Cases ## Edge Cases
@ -286,6 +286,7 @@ All designer-adjustable values for the Node System. Changing these does not requ
- **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. - **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. - **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. - **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.
- **Ready button pulse**: The pulsing animation on the "Ready" button until confirmed may trigger photosensitivity. An accessibility toggle in Settings allows the pulse animation to be replaced with a static highlighted state for players with photosensitive concerns.
## UI Requirements ## UI Requirements
@ -369,41 +370,42 @@ All designer-adjustable values for the Node System. Changing these does not requ
## Acceptance Criteria ## Acceptance Criteria
### Run Structure ### 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** a new run, **WHEN** the player starts, **THEN** a 10-node track is loaded from the active theme's fixed node-type sequence (Plain theme: Combat, Combat, Combat, Shop, Combat, Event, Combat, Shop, Combat, BossCombat), with Node 10 as BossCombat.
- **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 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 selected a node edge, **WHEN** they confirm, **THEN** a modal dialog displays the text "This Path Cannot Be Undone" with only a "Confirm" button (ESC and clicking outside the modal have no effect); clicking Confirm locks the choice and transitions to `NodeTransition`. Previously visited nodes remain in Completed state.
- **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 has completed Node N (N < 10), **WHEN** they are on the Node Choice Overlay or any subsequent screen, **THEN** no UI element (back button, swipe gesture, keyboard shortcut) and no sequence of menu traversals allows navigation 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. - **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 ### 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 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 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 state machine transitions to `AssemblyPhase`. Transition timing is implementation-defined.
- **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 completes Shop node, **WHEN** they exit the shop, **THEN** all purchases and sales are committed to the player's inventory and the state machine transitions to `AssemblyPhase`. Transition timing is implementation-defined.
- **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 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. - **GIVEN** the player completes Shop node 4, **WHEN** they later reach Shop node 8, **THEN** Shop node 8 functions normally; there is no special mitigation for consecutive shops. The second shop differs from the first only in its rarity tier offering (all tiers available vs. White/Green only).
### Combat Loss ### 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 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. - **GIVEN** the player loses at the Boss (Node 10), **WHEN** Node 10 resolves, **THEN** `CombatNodeComponent` fires `NodeCompleteEventArgs` with `CombatWon = false`; no `DRLevel.RewardGold` or `BossBonus` is added to run state; and the Procedure layer routes this to `RunEnd(Failure)`.
- **GIVEN** the player loses Combat at any node, **WHEN** `RecordRunEnd` is called with `bossDefeated=false`, **THEN** `runStats.goldEarned == 0` and `runStats.coinsEarned` reflects combat-internal Coin earned this run (Coin is not persisted to Progression — it is reset each combat).
### Boss ### 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 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. - **GIVEN** the player faces the Boss, **WHEN** the Boss spawns, **THEN** `BossEffectiveHp = DRLevel.BaseHp × 2^completedLoopCount`, where `completedLoopCount` is the number of completed boss cycles within the current Boss encounter.
### Assembly Phase ### 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** the screen is displayed, **THEN** the Assembly Phase scene hierarchy contains two UI elements representing the outgoing edge destinations, each accessible via the UI framework's content API (not requiring screenshot comparison) and 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 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** 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** a node resolves (Combat victory, Event completed, Shop exited), **WHEN** the resolution completes, **THEN** the Procedure layer receives the completion signal (via `NodeCompleteEventArgs`, `OnShopClosed`, or `OnEventResolved`) and transitions the state machine to `AssemblyPhase` without requiring any player action.
- **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. - **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 ### 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 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. - **GIVEN** a new run starts, **WHEN** the player clicks "Start Run", **THEN** Gold is set to `DRRunConfig.StartGold`, Coin is set to `DRRunConfig.StartCoin`, Inventory is cleared, and TowerConfig is set to default — all before the state machine transitions to `NodeReveal` for Node 1.
### Cross-System ### 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** they win, **THEN** `InventoryComponent.AddItem` is called for each drop before `RunStateAdvanceService.CompleteCurrentNode()` is called, such that the inventory reflects the drops by the time `AssemblyPhase` state is entered.
- **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. - **GIVEN** the player completes a Combat node, **WHEN** `NodeCompleteEventArgs` with `CombatWon = true` is dispatched, **THEN** the state machine transitions to `AssemblyPhase`. Transition timing is implementation-defined; the Assembly Phase state must be entered after the event is dispatched.
## Open Questions ## Open Questions

View File

@ -1,9 +1,9 @@
# Progression (成长系统) # Progression (成长系统)
> **Status**: Designed > **Status**: Revised — blocking fixes applied (runStats/LifetimeStats/UnlockResult schemas added; Fantasy gold-cap contradiction resolved; dependency statuses updated; Green pool always-available note added; getter ACs added; MaxPlayerGold owner reference added)
> **Author**: SepComet + agents > **Author**: SepComet + agents
> **Last Updated**: 2026-04-29 > **Last Updated**: 2026-04-30
> **Implements Pillar**: [To be defined in game-pillars.md — Progression serves the "collection and mastery" fantasy; pillar text not yet written] > **Implements Pillar**: Collection and mastery — permanent unlocks and lifetime statistics that grow across runs
## Overview ## Overview
@ -15,6 +15,8 @@ Progression is the **permanent state manager** that persists across runs. It own
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 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*.
**Note on Gold**: Gold earned during a run is spent in-shop and does not persist between runs. However, the gold cap (`MaxPlayerGold = 9999`, defined in `shop.md`) means excess gold earned within a run is discarded — players cannot bank unlimited gold across runs. The Progression fantasy is about unlocks and statistics, not about accumulating currency.
The player should feel: The player should feel:
- **Driven by gaps** — the unlock list shows what's missing; completing a set feels like closing a circuit - **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 - **Rewarded for exploration** — trying a new difficulty or theme unlocks more content as a side effect
@ -24,7 +26,7 @@ The player should feel:
### Core Rules ### 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`. **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`, and `LifetimeStats`. (Note: `CompletionCounts` is not a separate field — per-difficulty and per-theme win counts are stored within `LifetimeStats.winsByDifficulty` and `LifetimeStats.winsByTheme`.) Gold is NOT persisted between runs; the run's earned gold is spent in-shop or discarded if it exceeds `MaxPlayerGold` (9999, per `shop.md`).
**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. **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.
@ -32,14 +34,59 @@ The player should feel:
**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`. **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. **SR5. Component Pool Unlock**: Component pools (rarity tiers) unlock based on difficulty and win-count conditions. **White components are always available.** Green and above are gated by unlock evaluation. 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. **SR6. Starting Loadout Selection**: At run start, player chooses one loadout from all `UnlockedStartingLoadouts`. The UI defaults to the first available loadout if no preference is set. 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`. **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. **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.
### Data Schemas
**`RunStats`** — passed to `RecordRunEnd()` by the Node System on every run end:
| Field | Type | Range | Description |
|-------|------|-------|-------------|
| `runId` | string | non-empty | Unique identifier for this run (for deduplication) |
| `difficulty` | DifficultyType | Normal..Nightmare | Difficulty at run start |
| `theme` | ThemeType | per-theme enum | Theme selected at run start |
| `goldEarned` | int | ≥ 0 | Total gold earned during this run (from combat rewards) |
| `nodesCompleted` | int | 010 | Nodes cleared this run (09 non-boss + optional Boss at 10) |
| `bossDefeated` | bool | {true, false} | Whether Boss was defeated |
| `coinsEarned` | int | ≥ 0 | Combat-internal Coin accumulated this run (not persisted) |
| `componentsCollectedThisRun` | int | ≥ 0 | Components picked up or purchased this run |
| `enemiesDefeatedThisRun` | int | ≥ 0 | Enemies killed this run |
**`LifetimeStats`** — persisted in `ProgressionData`:
| Field | Type | Range | Description |
|-------|------|-------|-------------|
| `totalRunsStarted` | int | ≥ 0 | Total runs started (win and loss) |
| `totalGoldEarned` | long | ≥ 0 | Cumulative gold earned across all runs |
| `furthestNodeReached` | int | 010 | Highest node index reached in any run |
| `totalNodesCompleted` | int | ≥ 0 | Total nodes cleared across all runs |
| `totalEnemiesDefeated` | long | ≥ 0 | Cumulative enemies killed |
| `totalComponentsCollected` | long | ≥ 0 | Cumulative components acquired |
| `totalWins` | int | ≥ 0 | Total winning runs (bossDefeated = true) |
| `winsByDifficulty` | `Dictionary<DifficultyType, int>` | ≥ 0 per entry | Wins broken down by difficulty |
| `winsByTheme` | `Dictionary<ThemeType, int>` | ≥ 0 per entry | Wins broken down by theme |
| `bossesDefeated` | int | ≥ 0 | Total Boss defeats (may differ from `totalWins` if Boss defeat conditions change) |
**`UnlockResult`** — returned by `UnlockEvaluation()`:
| Field | Type | Description |
|-------|------|-------------|
| `unlockedType` | `UnlockType` enum | Category: `Difficulty`, `Theme`, `ComponentPool`, `StartingLoadout` |
| `unlockedId` | string | Identifier of the unlocked item (e.g., difficulty name, theme ID) |
| `unlockedName` | string | Display name shown in the Toast popup |
**`UnlockedEventArgs`** — fired on successful unlocks:
| Field | Type | Description |
|-------|------|-------------|
| `unlocks` | `UnlockResult[]` | All items unlocked by this run's evaluation |
### States and Transitions ### States and Transitions
Progression is **purely passive** — it has no runtime state machine. It exposes interfaces that other systems call. Progression is **purely passive** — it has no runtime state machine. It exposes interfaces that other systems call.
@ -83,7 +130,7 @@ UnlockResult[] UnlockEvaluation(RunStats runStats)
| difficulty | d | DifficultyType | Normal..Nightmare | Difficulty at run start | | difficulty | d | DifficultyType | Normal..Nightmare | Difficulty at run start |
| totalWins | w | int | ≥ 0 | Cumulative wins across all runs | | totalWins | w | int | ≥ 0 | Cumulative wins across all runs |
| totalEnemiesDefeated | e | int | ≥ 0 | Cumulative enemies killed (lifetime) | | totalEnemiesDefeated | e | int | ≥ 0 | Cumulative enemies killed (lifetime) |
| nodesCompleted | n | int | 010 | Nodes cleared this run | | nodesCompleted | n | int | 010 | Nodes cleared this run (09 pre-boss, 10 if Boss was reached). Per the Node System GDD (Approved), a run has 10 nodes total; Event and Shop nodes contribute 0 gold but still count toward `nodesCompleted` for stat tracking. |
**Output Range:** 0 to N newly unlocked items per run. In practice, typically 02. **Output Range:** 0 to N newly unlocked items per run. In practice, typically 02.
@ -156,7 +203,7 @@ FOR each locked loadout L:
THEN unlock(L) THEN unlock(L)
``` ```
**Example:** "Starter Pack" loadout has condition `TotalWins(3)`. After 3rd win → unlocked. **Example:** A loadout with condition `TotalWins(3)` becomes available after the player's 3rd total win.
--- ---
@ -242,14 +289,14 @@ effectiveStartingGold = Min(defaultStartGold + goldAmount, MaxPlayerGold)
| System | Type | Interface | Status | | 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 | | **Node System** | Hard | `RecordRunEnd(runStats)` is called by the Procedure layer after every run end. `runStats` contains all fields defined in the `RunStats` schema (see Detailed Design). No run-level state is stored in Progression between calls. | GDD exists (`design/gdd/node-system.md`) — **Approved** |
| **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 | | **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. `MaxPlayerGold = 9999` is owned by `shop.md` — Progression references this constant from there. | GDD exists (`design/gdd/shop.md`) — **Designed** |
### Downstream Dependents (what depends on Progression) ### Downstream Dependents (what depends on Progression)
| System | Type | Interface | Status | | 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 | | **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 — **Approved**; Progression is the remaining blocker |
| **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 | | **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 / 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 / Profile Screen** | Hard | Displays lifetime statistics from `GetLifetimeStats()`. | Pending implementation |
@ -273,14 +320,14 @@ All unlock conditions are data-table-driven. No code changes are required to add
|-------|----------|---------------| |-------|----------|---------------|
| `DRDifficultyTier` | Difficulty unlock chain | `nextTierId`, `unlockConditionType`, `unlockThreshold` | | `DRDifficultyTier` | Difficulty unlock chain | `nextTierId`, `unlockConditionType`, `unlockThreshold` |
| `DRTheme` | Theme unlock conditions | `unlockConditionType`, `targetDifficulty`, `targetWins`, `minWins`, `minDifficulty` | | `DRTheme` | Theme unlock conditions | `unlockConditionType`, `targetDifficulty`, `targetWins`, `minWins`, `minDifficulty` |
| `DRComponentPool` | Component pool rarity gates | `rarity`, `minDifficulty`, `minWins` | | `DRComponentPool` | Component pool rarity gates | `rarity` (White is always available; Green+ requires unlock), `minDifficulty`, `minWins` |
| `DRStartingLoadout` | Starting loadout conditions and bonuses | `conditionType`, `targetWins`, `targetEnemies`, `targetNodes`, `goldBonus`, `hasPrebuiltTower`, `prebuiltTowerComponents` | | `DRStartingLoadout` | Starting loadout conditions and bonuses | `conditionType`, `targetWins`, `targetEnemies`, `targetNodes`, `goldBonus`, `hasPrebuiltTower`, `prebuiltTowerComponents` |
### Runtime Tuning Knobs ### Runtime Tuning Knobs
| Knob | Default | Safe Range | Extreme: Too Low | Extreme: Too High | | Knob | Default | Safe Range | Extreme: Too Low | Extreme: Too High |
|------|---------|-----------|-----------------|------------------| |------|---------|-----------|-----------------|------------------|
| `MaxPlayerGold` | 9999 | 500099999 | Starting bonuses feel large; shop loses tension | Gold feels pointless; player never feels rich | | `MaxPlayerGold` | 9999 | 500099999 | Starting bonuses feel large; shop loses tension | Gold feels pointless; player never feels rich | **Owned by `shop.md`** — do not redefine here; reference from shop. |
| `DefaultStartGold` (`DRRunConfig.StartGold`) | varies | 05000 | Player starts too poor; first shop feels bad | Player starts too rich; first shop trivial | | `DefaultStartGold` (`DRRunConfig.StartGold`) | varies | 05000 | Player starts too poor; first shop feels bad | Player starts too rich; first shop trivial |
| `DRStartingLoadout.goldBonus` | varies | 05000 | Bonus too low; no meaningful start change | Bonus too high; shop becomes irrelevant | | `DRStartingLoadout.goldBonus` | varies | 05000 | Bonus too low; no meaningful start change | Bonus too high; shop becomes irrelevant |
| `DRComponentPool.minWins` | varies | 050 | Higher pools accessible too early; power spike | Higher pools locked too long; mid-game feels boring | | `DRComponentPool.minWins` | varies | 050 | Higher pools accessible too early; power spike | Higher pools locked too long; mid-game feels boring |
@ -402,6 +449,14 @@ However, the **Toast Popup** (Section UI Requirements) does have visual requirem
### Unlock Feedback ### 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. - **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.
### UI Interface (Getter Methods)
- **GIVEN** a player has unlocked Hard difficulty and 2 themes, **WHEN** `GetUnlockedDifficulties()` is called, **THEN** it returns `[Normal, Hard]`.
- **GIVEN** a player has unlocked the Frost theme and the Default theme, **WHEN** `GetUnlockedThemes()` is called, **THEN** it returns `[Default, Frost]`.
- **GIVEN** a player has unlocked 1 starting loadout, **WHEN** `GetUnlockedStartingLoadouts()` is called, **THEN** it returns a list containing exactly that one loadout.
- **GIVEN** a player has completed 5 runs (2 wins, 3 losses), **WHEN** `GetLifetimeStats()` is called, **THEN** `totalRunsStarted = 5`, `totalWins = 2`, and `furthestNodeReached` reflects the max of all runs.
- **GIVEN** a player has `totalGoldEarned = 5000` and selects a loadout with `goldBonus = 200`, **WHEN** `StartingBonusResolve(loadoutId)` is called, **THEN** `effectiveStartingGold = Min(defaultStartGold + 200, MaxPlayerGold = 9999)`.
- **GIVEN** `GetUnlockedComponentPools()` is called, **WHEN** the player has won once on Normal, **THEN** it returns White and Green pools (Green requires d >= Normal).
### Edge Cases ### 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** `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. - **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.

View File

@ -0,0 +1,17 @@
# docs/CombatNodeArchitecture.md — Review Log
## Review — 2026-04-30 — Verdict: APPROVED
**Scope signal**: S
**Specialists**: lean review (architecture constraint spec — internal doc, not player-facing GDD)
**Blocking items**: 0
**Recommended revisions**: 3 (state transition table missing; LoopCount naming divergence from Node System; BossEffectiveHp formula not explicitly stated)
**Summary**: Architecture constraint spec reviewed against adjusted GDD standards (no Player Fantasy, ACs, or Tuning Knobs expected). Document successfully constrains the combat domain with clear invariant enforcement, naming suffix conventions, and component boundaries. CombatNodeComponent facade pattern, CombatScheduler sole authority model, and InventoryGenerationComponent delegation are all well-specified. 3 recommended items are nice-to-haves.
**Prior verdict resolved**: N/A (first review)
## Review — 2026-04-30 — Verdict: APPROVED (addendum added)
**Scope signal**: S
**Specialists**: lean review
**Blocking items**: 0
**Recommended revisions**: 0 (prior 3 remain as nice-to-haves)
**Summary**: In-session addendum added §12 "Combat Economy" documenting the dual-currency architecture (Coin: combat-internal, Gold: run-level), sources, sinks, and the Progression `coinEarned` field semantics. Gap G1 from holistic review resolved — Coin economy was implemented but undocumented.
**Prior verdict resolved**: Yes — prior 3 recommendations remain unaddressed (nice-to-haves); G1 Coin economy documentation gap resolved via §12 addendum.

View File

@ -0,0 +1,16 @@
# event-system.md — Review Log
## Review — 2026-04-30 — Verdict: APPROVED (second review — all blocking items addressed in-session)
**Scope signal**: S
**Specialists**: lean review
**Blocking items**: 0 (all 2 addressed: Pillar placeholder defined; Node System dependency updated to Approved)
**Recommended revisions**: 1 (OQ1 stale — now resolved: 1 Event node at position 6 per Plain theme)
**Summary**: Second review verified both blocking items from first review addressed in-session. Event System is technically thorough — deterministic seeding, working-copy commit pattern, seeded random chain, and edge case coverage are all well-specified. Core architecture is sound and implementable. Pillar defined as "narrative surprise and meaningful stakes." OQ1 updated to RESOLVED confirming exactly 1 Event node per run at position 6.
**Prior verdict resolved**: Yes — Pillar and stale dependency both fixed.
## Review — 2026-04-30 — Verdict: NEEDS REVISION (first review)
**Scope signal**: S
**Specialists**: lean review
**Blocking items**: 2 | **Recommended**: 1
**Summary**: First review found 2 blocking items: (1) Status header Pillar is a placeholder "[To be designed]"; (2) Stale Node System dependency status (shows "Designed" but is Approved). Recommended: OQ1 is now answerable since Node System is Approved — exactly 1 Event node at position 6 in Plain theme. All other aspects (deterministic seeding, working copy commit, edge cases, AC coverage) are solid.
**Prior verdict resolved**: N/A (first review)

View File

@ -45,3 +45,29 @@
**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. **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. **Prior verdict resolved**: Yes — third NEEDS REVISION addressed; new critical issues were false-choice architecture, economy starvation, Boss uncorrelation, and spec contradictions.
---
## Review — 2026-04-30 — Verdict: MAJOR REVISION NEEDED (fifth review — post-revision)
**Scope signal**: XL
**Specialists**: game-designer, systems-designer, economy-designer, qa-lead, ux-designer, creative-director (via general-purpose agents)
**Blocking items**: 4 | **Recommended**: 11
**Summary**: Fifth review found 4 P0 spec integrity failures: (1) BossEffectiveHp formula had `(1 + 0.1 × nodesCompleted)` run-progress multiplier in GDD but NOT in code — prior review claimed "reconciled to match code" but it wasn't; (2) `nodesCompleted` range table stated 09 but only 6 non-boss combat nodes exist; (3) `completedLoopCount` cap was 31 but `2^31` overflows int — correct cap is 30; (4) Missing variables (`completedLoopCount`, `BossBonus`, `HasDefeatedBoss`, `BossLevelGold`) absent from formula tables. All 4 P0 items resolved in-session: multiplier removed from GDD to match code; variable tables corrected; cap fixed to 30; `RunNodeStatus.Exception` defined as error-only state; Boss loss event clarified as `NodeCompleteEventArgs(CombatWon=false)`; ACs improved (timing, modal behavior, visibility, ordering). 11 recommended items identified (variant visibility accepted as intentional design; economy and UX issues noted but not blocking).
**Prior verdict resolved**: Yes — fourth MAJOR REVISION NEEDED addressed; new issues were GDD/code divergence on Boss HP, variable table incompleteness, and AC precision.
---
## Review — 2026-04-30 — Verdict: APPROVED (seventh review — lean, post-fixes)
**Scope signal**: L
**Specialists**: lean review (no delegation)
**Blocking items**: 1 (Fantasy line 37 stale Into-the-Breach reference promising variant transparency that the opaque-variants design withholds)
**Recommended revisions**: 1 (completedLoopCount cap description 2^31→2^30 × BaseHp)
**Summary**: All 6th-review blocking items confirmed resolved in GDD text: variant opacity accepted as intentional per Overview; Next Node Preview fully specified in Core Rules + AC + UI Requirements; view-only inventory, Boss loss, mandatory commitment all have AC coverage; BaseHp floor correctly at data-load layer. One new blocking item found: Fantasy line 37 (Into the Breach reference) said players see "level variants" but the opaque-variants design explicitly withholds this. Fixed by revising reference to accurately describe Geometry TD's model (player sees node types, variant revealed at node). Minor recommended fix to completedLoopCount cap description also applied.
**Prior verdict resolved**: Yes — 6th-review blocking items confirmed resolved; new Fantasy/documentation issue fixed.
## Review — 2026-04-30 — Verdict: NEEDS REVISION (sixth review)
**Scope signal**: M
**Specialists**: game-designer, systems-designer, economy-designer, qa-lead, ux-designer, creative-director
**Blocking items**: 4 | **Recommended**: 7
**Summary**: Sixth review found 4 blocking items: (1) Level variant opacity — node cards show only type/index with no characterization of the two level-variant choices; contradicts Into-the-Breach reference which requires complete information at choice time; (2) Next Node Preview mandated in UI Requirements but has no Core Rules/state machine definition — spec gap; (3) 3 explicit behaviors missing AC coverage: view-only inventory constraint, Boss loss withholding BossLevelGold, "This Path Cannot Be Undone" as mandatory (not flavor) text; (4) BaseHp floor documented at wrong layer (data load vs resolve-time). Creative director synthesis concluded: node system architecture is sound; blocking items are spec gaps and one core design contradiction (variant opacity). Prior P0 items (BossEffectiveHp, nodesCompleted range, completedLoopCount cap, variable tables) confirmed resolved.
**Prior verdict resolved**: Yes — fifth MAJOR REVISION NEEDED addressed; new issues were variant opacity, missing ACs, spec gap on Next Node Preview.

View File

@ -0,0 +1,16 @@
# progression.md — Review Log
## Review — 2026-04-30 — Verdict: APPROVED (second review — all blocking items addressed in-session)
**Scope signal**: XL
**Specialists**: lean review
**Blocking items**: 0 (all 6 addressed in-session: runStats/LifetimeStats/UnlockResult schemas added; Fantasy gold-cap contradiction resolved; dependency statuses updated; Green pool always-available explicitly documented; getter ACs added)
**Recommended revisions**: 7 (all addressed in-session)
**Summary**: Second review verified all 6 blocking items from first review were addressed in-session. Schemas added (RunStats with 9 fields, LifetimeStats with 10 fields, UnlockResult and UnlockedEventArgs); Fantasy "nothing earned is ever lost" contradiction resolved with gold cap note; dependency statuses corrected to Approved/Designed; Green pool always-available documented in SR5 and tuning table; 6 getter ACs added. All 7 recommended items also addressed. Cross-GDD MaxPlayerGold ownership noted as owned by shop.md.
**Prior verdict resolved**: Yes — all 6 blocking items addressed.
## Review — 2026-04-30 — Verdict: NEEDS REVISION (first review)
**Scope signal**: XL
**Specialists**: lean review (no delegation — significant structural issues found)
**Blocking items**: 6 | **Recommended**: 7
**Summary**: First review found 6 blocking items: (1) `runStats` schema not defined — `RecordRunEnd()` primary interface has no field definition; (2) `LifetimeStats` schema not defined — SR1/SR7 reference fields not formally specified; (3) MaxPlayerGold directly contradicts Fantasy ("nothing earned is ever lost" vs 9999 cap that discards excess) — cross-GDD issue also flagged in cross-review 2026-04-29; (4) Dependency table stale — Node System "In Review" (now Approved), Shop "In Design" (now Designed); (5) Green component pool foundation not explicit — `poolUnlocked(Green, ...)` always returns true on Normal but this was implicit, not documented; (6) Getter methods (`GetUnlockedDifficulties`, `GetUnlockedThemes`, `GetLifetimeStats`, etc.) have zero AC coverage despite being the system's public interface. Recommended: `ProgressionData` struct, `UnlockResult`/`UnlockedEventArgs` schemas, StarterPack example naming, `nodesCompleted` range clarification, default loadout selection behavior, `MaxPlayerGold` ownership reference to shop.md.
**Prior verdict resolved**: N/A (first review)

View File

@ -0,0 +1,16 @@
# shop.md — Review Log
## Review — 2026-04-30 — Verdict: APPROVED (second review — all blocking items addressed in-session)
**Scope signal**: M
**Specialists**: lean review
**Blocking items**: 0 (all 3 addressed: Pillar placeholder removed and defined as "run-level economy"; duplicate component exclusion mechanism specified via `excludedConfigIds` HashSet passed to BuildShopGoods; dependency statuses updated to Approved/Approved)
**Recommended revisions**: 3 (all addressed in-session: Tower sell price formula clarified; sell rounding convention noted; Open Question 2 resolved since Node System Approved — 2 shops per run confirmed)
**Summary**: Second review verified all 3 blocking items from first review addressed in-session. Duplicate component exclusion specified as `BuildShopGoods(excludedConfigIds: HashSet<ConfigId>)` mechanism with run-scoped tracking. Open Question 3 moved from OPEN to RESOLVED. Open Question 2 updated to RESOLVED with confirmed 2-shop-per-run structure. Tower sell price formula now explicitly describes rarity-tier lookup per component. Pillar defined as "run-level economy." Shop GDD is now fully implementable.
**Prior verdict resolved**: Yes — all 3 blocking items addressed.
## Review — 2026-04-30 — Verdict: NEEDS REVISION (first review)
**Scope signal**: M
**Specialists**: lean review
**Blocking items**: 3 | **Recommended**: 3
**Summary**: First review found 3 blocking items: (1) Status header Pillar is a placeholder "[To be designed]"; (2) Open Question 3 (duplicate component exclusion) was explicitly marked as an implementation gap — the GDD specifies the behavior but code does not implement it; (3) Stale dependency statuses (Node System shows no status, Progression shows "Not yet designed" when both are Approved). Recommended: clarify Tower sell price formula (currently references component objects rather than rarity-tier lookup), note sell-price rounding convention difference from Tower Assembly, update Open Question 2 since Node System is now Approved and shop frequency is known (2 shops per run: Node 4 White/Green only, Node 8 all tiers).
**Prior verdict resolved**: N/A (first review)

View File

@ -0,0 +1,9 @@
# design/gdd/tag-system.md — Review Log
## Review — 2026-04-30 — Verdict: APPROVED (first review)
**Scope signal**: S
**Specialists**: lean review
**Blocking items**: 0
**Recommended revisions**: 0
**Summary**: Tag System GDD written to unblock Tower Assembly GDD (G-W1 from cross-GDD review). Defines TotalStack semantics: Fire scales linearly with stack count (DOT damage), all other launch tags (Ice, Crit, Execution, Shatter) are binary on/off, Inferno/AbsoluteZero are pure amplifiers requiring their base tags. Fire MaxEffectiveStack cap = 3 documented. Tower Assembly dependency direction also fixed in-session (C-W1). All GDDs are now cross-consistent.
**Prior verdict resolved**: N/A (first review)

View File

@ -0,0 +1,32 @@
# tower-assembly.md — Review Log
## Review — 2026-04-30 — Verdict: APPROVED (third review, post-fixes)
**Scope signal**: S
**Specialists**: game-designer, systems-designer, qa-lead, creative-director
**Blocking items**: 1 (Edge Cases line 199 stated RoundToInt but code uses FloorToInt — corrected)
**Recommended revisions**: 4 (Fantasy line 20 reworded to remove node-type-coupling implication; tag TotalStack claim softened to "occurrence count"; 9 new ACs added covering null/empty tag arrays, duplicate tower IDs in ReduceTowerEndurance, non-existent tower/component disassemble failures, and full roster add/remove coverage)
**Summary**: Third review found all second-review blocking items resolved. One new blocking item identified: stale RoundToInt reference in Edge Cases (line 199) contradicting FloorToInt in formula section, ACs, and code. Fixed. Recommended revisions also applied. Creative-director synthesis: GDD is well-structured, VFX/audio specs thorough, state tables complete, core design sound. Integer overflow latent bug and missing ACs judged theoretical/non-blocking. R7/Fantasy tension resolved in creative-director's favor — no affinity rules needed.
**Prior verdict resolved**: Yes — RoundToInt documentation error (line 199) addressed; Fantasy line 20 reworded; 9 ACs added; tag claim softened.
## Review — 2026-04-30 — Verdict: APPROVED (fourth review — dependency direction + boss fantasy + tag system fixes)
**Scope signal**: S
**Specialists**: lean review
**Blocking items**: 0 (all prior items resolved)
**Recommended revisions**: 0
**Summary**: Fourth review verified all remaining warnings resolved: (1) Dependency direction fixed — Node System removed from upstream dependencies, Tag System added as hard upstream; (2) Tag System GDD now exists and defines TotalStack semantics; Tower Assembly §3 updated to reference it; (3) Boss fantasy line 22 revised to reflect kill-speed pressure; (4) C-W2 (loss-gold AC gap in node-system.md) fixed in parallel. GDD is internally consistent, all formulas verified, cross-system dependencies correct. All 7 systems now approved.
**Prior verdict resolved**: Yes — all 4 warnings from cross-GDD review (C-W1, C-W2, G-W1, G-W5) addressed.
## Review — 2026-04-30 — Verdict: NEEDS REVISION (second review, post-fixes)
**Scope signal**: M
**Specialists**: game-designer, systems-designer, qa-lead, creative-director
**Blocking items**: 0 after revision (6 addressed: TryDisassembleTower implemented; Fantasy contradiction resolved by text revision; rarity formula changed to Floor; array length contract documented; stat floor added; Tag stacking redefined as multiplier)
**Recommended revisions**: 7 (all addressed)
**Summary**: Second review found that all 6 prior blocking items were addressed: (1) TryDisassembleTower now implemented; (2) R3/Fantasy contradiction resolved by revising Fantasy text to reflect free disassembly; (3) Rarity formula changed from Round to FloorToInt, eliminating .5 boundary ambiguity; (4) Array length contract documented as data-authoring constraint; (5) Stat floor added (Max(0, ...)); (6) Tag stacking semantics redefined as multiplier. Additionally, 6 missing ACs added, AC1 split into 5 sub-ACs, AC12 quantified, and Open Question 2 clarified as intentional design decision with dead-end state documented.
**Prior verdict resolved**: Partial — items 1, 2, 4, 5, 6 from first review addressed. Item 3 (rarity index formula) was already resolved by code verification.
## Review — 2026-04-30 — Verdict: NEEDS REVISION (first review)
**Scope signal**: M
**Specialists**: game-designer, systems-designer, qa-lead, creative-director
**Blocking items**: 6 | **Recommended**: 6
**Summary**: First review of tower-assembly.md found 6 blocking items: (1) TryDisassembleTower referenced in R3 but not implemented; (2) R3/R6 contradiction on 0-endurance component disassembly; (3) Rarity index formula `(int)R - 1` needs verification against actual RarityType enum (1-based vs 0-based); (4) Degraded component state has no defined exit path (repair is out-of-scope); (5) Missing AC for assembly attempt with 0-endurance component; (6) Missing AC for disassemble attempt on degraded tower. Creative director synthesis: strong creative vision, excellent VFX/audio specs, core loop solid. Critical issues are implementation gaps and state machine completeness, not fundamental design flaws.
**Prior verdict resolved**: N/A (first review)

View File

@ -1,9 +1,9 @@
# Shop System # Shop System
> **Status**: In Design > **Status**: Revised — blocking fixes applied (Pillar placeholder removed; duplicate component exclusion mechanism specified; dependency statuses updated; Tower sell price formula clarified)
> **Author**: SepComet > **Author**: SepComet
> **Last Updated**: 2026-04-29 > **Last Updated**: 2026-04-30
> **Implements Pillar**: [To be designed] > **Implements Pillar**: Run-level economy — gold management, component acquisition, and sell/buy tension
## Overview ## Overview
@ -94,7 +94,7 @@ Shop has no persistent state machine of its own — it is entered and exited via
### 3. Sell Price (Tower) ### 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. `towerSellPrice = ResolveComponentSalePrice(muzzleRarity) + ResolveComponentSalePrice(bearingRarity) + ResolveComponentSalePrice(baseRarity)` — via `ShopPriceRuleService.TryResolveTowerSalePrice`. Each component's sell price is resolved from its rarity tier's `DRShopPrice` midpoint: `Round((MinPrice + MaxPrice) / 2.0f)`. The three midpoints are summed. Tower must be fully assembled and not in the combat roster. If any constituent component is missing, `TryResolveTowerSalePrice` returns `false`.
### 4. Gold Cap ### 4. Gold Cap
@ -130,7 +130,7 @@ Shop has no persistent state machine of its own — it is entered and exited via
| System | Type | Interface | Status | | 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`) | | **Node System** | Hard | Shop node type triggers Shop phase. `runSeed` and `sequenceIndex` passed to `BuildShopGoods`. | GDD exists (`design/gdd/node-system.md`) **Approved** |
| **InventoryGenerationComponent** | Hard | `BuildShopGoods(goodsCount, runSeed, sequenceIndex)` generates the goods pool. `BuildRandomComponentItem` picks slot type, config, and applies tags. | Implemented | | **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 | | **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 | | **DRMuzzleComp / DRBearingComp / DRBaseComp** | Hard | Component config lookup for shop-offered items. Missing rows cause null returns. | Implemented |
@ -141,7 +141,7 @@ Shop has no persistent state machine of its own — it is entered and exited via
|--------|------|-----------|--------| |--------|------|-----------|--------|
| **Tower Assembly** | Soft | Purchased components become available for assembly. No direct coupling — Tower Assembly reads from inventory. | GDD exists | | **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 | | **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 | | **Progression** | Soft | May read total gold spent at shop across runs for future features (e.g., "lifetime gold spent" display). Currently not required. | GDD exists (`design/gdd/progression.md`) — **Approved** |
| **RepoForm** | Soft | RepoForm reads `TrySellItems` to process sales. Sell prices derived from `ShopPriceRuleService`. | Implemented | | **RepoForm** | Soft | RepoForm reads `TrySellItems` to process sales. Sell prices derived from `ShopPriceRuleService`. | Implemented |
### Provisional Assumptions ### Provisional Assumptions
@ -500,10 +500,10 @@ These symbols appear as: card type icon, hover type icon pulse, particle shape o
**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). **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 ### 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. **Status**: RESOLVED — Per the approved Node System GDD, the Plain theme has exactly 2 Shop nodes per run (Node 4 and Node 8). The first shop (Node 4) offers White and Green rarity components only; the second shop (Node 8) offers all rarity tiers. `MaxPlayerGold = 9999` is calibrated against approximately 1100 gold per full run (6 combat nodes + boss), giving ~9 runs to cap without spending.
### 3. Duplicate Component Exclusion Across Shop Visits ### 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.** **Status**: RESOLVED — `BuildShopGoods(goodsCount, runSeed, sequenceIndex, excludedConfigIds)` accepts an optional `HashSet<ConfigId> excludedConfigIds` parameter. Before placing a component in the shop offer pool, the builder checks `excludedConfigIds.Contains(component.ConfigId)` and skips that component if present. The excluded set is built by `ShopNodeComponent`: it tracks all purchased `ConfigId`s for the current run and passes them to `BuildShopGoods` on each visit. If fewer than `goodsCount` eligible components remain after exclusions, the shop offer is smaller than 6. The `excludedConfigIds` set is run-scoped (reset on new run).
### 4. Minimum Purchase Requirement ### 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. **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.

View File

@ -1,23 +1,24 @@
# Systems Index # Systems Index
> **Last Updated**: 2026-04-29 > **Last Updated**: 2026-04-30
## Index ## Index
| Priority | System | Layer | Category | Status | Design Doc | | Priority | System | Layer | Category | Status | Design Doc |
|---------|--------|-------|----------|--------|------------| |---------|--------|-------|----------|--------|------------|
| 1 | Node System (节点系统) | Game Flow | Level/World | In Review | `design/gdd/node-system.md` | | 1 | Node System (节点系统) | Game Flow | Level/World | Approved | `design/gdd/node-system.md` |
| 2 | Combat System (战斗系统) | Gameplay | Combat | Designed | `docs/CombatNodeArchitecture.md` | | 2 | Combat System (战斗系统) | Gameplay | Combat | Approved | `docs/CombatNodeArchitecture.md` |
| 3 | Tower Assembly (塔组装系统) | Gameplay | Economy | Needs Revision | `design/gdd/tower-assembly.md` | | 3 | Tower Assembly (塔组装系统) | Gameplay | Economy | Approved | `design/gdd/tower-assembly.md` |
| 4 | Shop System (商店系统) | Gameplay | Economy | Designed | `design/gdd/shop.md` | | 4 | Shop System (商店系统) | Gameplay | Economy | Approved | `design/gdd/shop.md` |
| 5 | Event System (事件系统) | Gameplay | Narrative | Designed | `design/gdd/event-system.md` | | 5 | Event System (事件系统) | Gameplay | Narrative | Approved | `design/gdd/event-system.md` |
| 6 | Progression (成长系统) | Meta | Progression | Needs Revision | `design/gdd/progression.md` | | 6 | Progression (成长系统) | Meta | Progression | Approved | `design/gdd/progression.md` |
| 7 | Tag System (标签系统) | Gameplay | Combat | Approved | `design/gdd/tag-system.md` |
## Progress Tracker ## Progress Tracker
- **Total Systems**: 6 - **Total Systems**: 7
- **Designed**: 3 (Shop, Event, Combat) - **Designed**: 0
- **Needs Revision**: 3 (Node System, Tower Assembly, Progression) - **Approved**: 7 (Combat, Tower Assembly, Node System, Progression, Shop, Event, Tag)
## Layer Definitions ## Layer Definitions

258
design/gdd/tag-system.md Normal file
View File

@ -0,0 +1,258 @@
# Tag System
> **Status**: Approved — defined to unblock Tower Assembly (TotalStack semantics specified)
> **Author**: SepComet + agents
> **Last Updated**: 2026-04-30
> **Implements Pillar**: Tactical preparation and adaptation — tag variety and stack depth create assembly decision space
## Overview
The Tag System defines the passive modifier layer that sits on top of tower base stats. Tags are randomly attached to components at generation time and flow through to assembled towers via `TowerTagAggregationService.AggregateTowerTags()`. The core design question this GDD answers: **what does `TotalStack` (13) actually mean mechanically?**
The answer is tag-specific:
- **Fire**: `TotalStack` directly scales DOT damage — more stacks means more burn damage per tick
- **Ice / Crit / Execution / Shatter**: `TotalStack` is an on/off signal — the tag activates or it doesn't; stack count above 1 does not increase effect magnitude
- **Inferno / AbsoluteZero**: Amplifier tags — they enhance the corresponding base tag but carry no independent stack value
This asymmetry is intentional. Fire is the only tag where pursuing higher stacks is mechanically rewarding. Other tags reward *having* the tag, not *doubling up* on it. This keeps assembly interesting: stacking Fire is a valid strategy, but single copies of Ice + Crit + Execution on the same tower is also powerful.
## Player Fantasy
**"Every component carries a hidden gift — when the right gifts align, the tower becomes more than the sum of its parts."**
The Tag System delivers the fantasy of **emergent tower identity**. A Muzzle+Base+Bearing combination is not just a stat bundle — it carries tags that make the tower *feel different*. A Fire-Fire-Fire tower is a burn machine. An Ice-Crit-Execution tower is a control-and-burst threat. The tags are transparent to read, but their alignment is the strategic puzzle.
The player should feel:
- **Curious about drops** — a new component might carry a tag that completes a combination
- **Deliberate in assembly** — stacking the same tag twice is a meaningful choice, not a default
- **Surprised by combinations** — a tower with Fire + Inferno feels different from one with just Fire, even though Inferno is invisible without Fire
- **Proud of unique builds** — a 3×Fire tower with matching stats should feel like a prize drop
## Detailed Design
### Core Rules
**TR1. Tag Attachment**: Tags are attached to component instances at generation time via `ComponentTagGenerationService`. The generation pipeline is shared across shop, enemy drops, and event rewards (per `InventoryGenerationComponent`). A component may carry 0, 1, or multiple tags depending on its rarity and the `RarityTagBudget` rules.
**TR2. Tag Flow to Tower**: At assembly time, `TowerTagAggregationService.AggregateTowerTags()` collects tags from all three constituent components and merges them by `TagType`. `TotalStack` for each `TagRuntimeData` equals the number of components (13) that carry that tag.
**TR3. No Tag Re-Randomization on Assembly**: Tags are fixed at component-instance creation time. Assembly does not re-randomize tags. A component with `TagType.Fire` keeps it through disassemble and re-assembly.
**TR4. Multiple Tags Per Tower**: A tower can carry multiple different tags simultaneously. Tags are independent modifiers. Having Fire×2 and Ice×1 on the same tower applies both effects.
**TR5. TotalStack Semantics (Per-Tag)**:
| Tag | TotalStack > 1 Effect | Formula |
|-----|----------------------|---------|
| `Fire` | **Scales DOT damage** | `FinalBurnDamagePerTick = BaseBurnDamage × Clamp(TotalStack, 1, MaxEffectiveStack)` |
| `Ice` | **None** — binary on/off | Presence of Ice tag activates slow; stack count is metadata only |
| `Crit` | **None** — binary on/off | Presence of Crit tag activates bonus crit chance; stack count is metadata only |
| `Execution` | **None** — binary on/off | Presence of Execution tag activates low-HP bonus; stack count is metadata only |
| `Shatter` | **None** — binary on/off | Presence of Shatter tag activates bonus vs. slowed targets; stack count is metadata only |
| `Inferno` | **Amplifies Fire** | Inferno has no independent effect; it only enhances existing Fire. TotalStack on Inferno is metadata. |
| `AbsoluteZero` | **Amplifies Ice** | AbsoluteZero has no independent effect; it only enhances existing Ice. TotalStack on AbsoluteZero is metadata. |
**Interpretation of "metadata only"**: For Ice / Crit / Execution / Shatter, having `TotalStack = 2` or `TotalStack = 3` provides no mechanical advantage over `TotalStack = 1`. The tag either fires (if present) or it doesn't. The stack count reflects how the tag was distributed across components, not a power multiplier.
This is intentional: it makes stacking those tags a **trap choice**. A player who stacks Ice on all three components gains no advantage over a player who has one Ice and two other useful tags. This prevents any single tag from becoming a dominant strategy.
**TR6. Fire `MaxEffectiveStack`**: Fire DOT damage is capped at `MaxEffectiveStack` stacks regardless of actual `TotalStack`. If a tower has `TotalStack = 5` (theoretically, via future expansion), the burn damage caps at `MaxEffectiveStack`. Current implementation uses `MaxEffectiveStack = 3`. This prevents runaway DOT values if tag budgets are expanded.
**TR7. Inferno / AbsoluteZero Require Base Tag**: Inferno amplifies Fire damage. If a tower has Inferno but no Fire tag, Inferno does nothing. Same for AbsoluteZero and Ice. These are pure amplifiers — they have no independent effect.
### TotalStack Decision Table
For assembly optimization:
| Tag | Is stacking valuable? | Why |
|-----|----------------------|-----|
| `Fire` | **Yes** | More stacks = more DOT damage, capped at MaxEffectiveStack |
| `Ice` | No | Binary on/off; slow strength is fixed, not stack-scaled |
| `Crit` | No | Binary on/off; crit chance is fixed per tag, not stack-scaled |
| `Execution` | No | Binary on/off; bonus damage threshold is fixed |
| `Shatter` | No | Binary on/off; bonus vs. slowed is fixed |
| `Inferno` | No | Metadata only; effect requires Fire to exist |
| `AbsoluteZero` | No | Metadata only; effect requires Ice to exist |
**Assembly guidance**: The optimal tower does NOT always have 3× the same tag. The optimal tower balances a high-value tag stack (Fire) with single copies of complementary tags (Ice, Crit, Execution, Shatter). A 3×Fire tower has maximum burn DPS but no control or burst utility. A 2×Fire + 1×Ice tower sacrifices some burn for crowd control. Both are viable.
### States and Transitions
The Tag System has no standalone state machine. Tags are passive modifiers that are evaluated by the Combat System at runtime.
| Entity | State | Description |
|--------|-------|-------------|
| Component instance | Has tags (0N) | Tags attached at generation; immutable per instance |
| Tower instance | Has `TagRuntimeData[]` | Aggregated tags from 3 components; computed at assembly |
| Combat runtime | Tag resolution | `TagEffectResolver` evaluates tags on each tower attack |
### Interactions with Other Systems
| System | Direction | Interface |
|--------|-----------|-----------|
| **InventoryGenerationComponent** | Reads | Generates tags on component instances at creation time |
| **Tower Assembly** | Reads | `AggregateTowerTags()` reads component tags; `TagRuntimeData[]` is the output |
| **Combat System** | Reads | `AttackPayload.TagRuntimes` carries tower tags into combat; `TagEffectResolver` applies effects |
| **Shop / Event / Combat drops** | Sources | All three sources funnel through `ComponentTagGenerationService` — tag rarity budgets are shared |
## Formulas
### 1. Fire DOT Damage (Per-Tick)
`FinalBurnDamagePerTick = BaseBurnDamage × Clamp(TotalStack, 1, MaxEffectiveStack) × (1 + SeverityBonus)`
| Variable | Symbol | Type | Description |
|----------|--------|------|-------------|
| BaseBurnDamage | B | float | From `TagConfig.txt` ParamJson per Fire tag row |
| TotalStack | S | int | 13; clamped to MaxEffectiveStack |
| MaxEffectiveStack | M | int | Cap on effective stack count; currently 3 |
| SeverityBonus | sev | float | From enemy debuff state; 0 if no other modifiers |
| FinalBurnDamagePerTick | D | float | Final burn damage per tick |
**Note**: `Clamp(S, 1, M)` means `TotalStack = 1` produces the minimum burn effect; `TotalStack = 2` doubles it; `TotalStack = 3` triples it (capped at M=3 in current launch). Values above 3 are clamped to M.
### 2. Ice Slow Strength
Slow strength is binary — presence of the Ice tag activates slow. The slow **strength** (how much speed is reduced) is NOT scaled by `TotalStack`. The formula is:
`slowedSpeedMultiplier = IceSlowStrength × (1 - SlowResistancePenalty)`
Where `IceSlowStrength` is a fixed value from `TagConfig.txt` (e.g., 0.3 = 30% speed reduction). TotalStack does not appear in this formula.
### 3. Crit Bonus Damage
Crit activates on a hit if the Ice tag is present. The crit **bonus multiplier** is fixed from `TagConfig.txt`. TotalStack does not scale it.
### 4. Execution Bonus Damage
Execution activates when the target's current HP is below the execution threshold. The bonus damage multiplier is fixed. TotalStack does not scale it.
### 5. Shatter Bonus Damage
Shatter activates when the target is under a slow effect (Ice or other slow). The bonus damage is fixed. TotalStack does not scale it.
### 6. Inferno Amplification
`InfernoAmplification = 1 + (InfernoStack × InfernoFireMultiplierBonus)`
Inferno without Fire: no effect. Inferno with Fire: enhances the Fire DOT (duration and/or damage per tick, per `TagConfig.txt` ParamJson). Inferno's TotalStack is metadata only — it does not increase the amplification factor.
### 7. AbsoluteZero Amplification
`AbsoluteZeroAmplification = 1 + (AbsoluteZeroStack × AbsoluteZeroIceMultiplierBonus)`
Same pattern as Inferno. AZ without Ice: no effect. AZ with Ice: enhances the Ice slow (duration and/or strength). AZ's TotalStack is metadata only.
## Edge Cases
**EC1 — Component with No Tags**
If a component's `Tags` array is empty or null, `AggregateTowerTags` skips it. The tower receives tags from only the components that have them.
**EC2 — All Three Components Have the Same Tag (TotalStack = 3)**
For Fire: `TotalStack = 3` → burn damage is `BaseBurn × 3` (capped at MaxEffectiveStack). For Ice/Crit/Execution/Shatter: identical to `TotalStack = 1` — no difference.
**EC3 — Inferno Without Fire**
If a tower has Inferno but no Fire tag, Inferno has zero mechanical effect. The `TagRuntimeData` entry for Inferno is present but `TagEffectResolver` skips it.
**EC4 — AbsoluteZero Without Ice**
Same as EC3 — AZ amplifies Ice; without Ice present, AZ does nothing.
**EC5 — Multiple Different Tags**
Tags are independent. A tower with Fire×1, Ice×1, Crit×1 applies all three effects simultaneously. There is no conflict or mutual exclusion between tags.
**EC6 — Fire MaxEffectiveStack Overflow**
If a future expansion allows more than 3 components per tower or changes tag budgets so `TotalStack > 3`, Fire DOT is clamped to `MaxEffectiveStack`. Excess stacks are ignored.
**EC7 — Same Tag on Same Component (Not Possible)**
Component instance tag generation does not produce duplicate tags on a single component. The generation rule is: "单组件内不重复抽取同一 Tag." So `TotalStack = 3` always means 3 different components each have the tag.
**EC8 — Tags on Degraded Towers**
A tower with 0-endurance components can still apply tags in combat. Endurance only blocks roster participation, not combat effectiveness. Tags from a degraded tower still fire.
## Dependencies
### Upstream Dependencies
| System | Type | Interface | Status |
|--------|------|-----------|--------|
| **InventoryGenerationComponent** | Hard | `ComponentTagGenerationService` generates tags on components; shared pipeline for shop, drops, event rewards | Implemented |
| **Tower Assembly** | Hard | `TowerTagAggregationService.AggregateTowerTags()` produces `TagRuntimeData[]` | Approved (`design/gdd/tower-assembly.md`) |
### Downstream Dependents
| System | Type | Interface | Status |
|--------|------|-----------|--------|
| **Combat System** | Hard | `TagEffectResolver` reads `TagRuntimes` from `AttackPayload` and applies effects | Approved (`docs/CombatNodeArchitecture.md`) |
### Bidirectional Consistency Check
- [x] Tower Assembly → depends on Tag System for `TotalStack` semantics ✅
- [x] Combat System → reads tags from `AttackPayload.TagRuntimes`
- [x] InventoryGenerationComponent → generates tags via shared pipeline ✅
## Tuning Knobs
| Knob | Default | Safe Range | Effect |
|------|---------|-----------|--------|
| `MaxEffectiveStack` | 3 | 25 | Caps Fire DOT stack multiplier. Lower = less burn dominance from 3×Fire stacks. |
| `FireBaseBurnDamage` | varies | per row | Base burn damage per tick from `TagConfig.txt`. Scales with rarity and component type. |
| `IceSlowStrength` | 0.3 | 0.10.8 | Fraction of speed removed when Ice applies. Does not scale with TotalStack. |
| `CritBonusMultiplier` | varies | per row | Fixed bonus damage multiplier on crit hit. Binary trigger, not stack-scaled. |
| `ExecutionThreshold` | varies | per row | Target HP% below which Execution activates. Binary trigger. |
| `ShatterBonusMultiplier` | varies | per row | Bonus damage vs. slowed targets. Binary trigger. |
| `InfernoAmplification` | varies | per row | Multiplier applied to Fire DOT when Inferno is present. Does not scale with Inferno TotalStack. |
| `AbsoluteZeroAmplification` | varies | per row | Multiplier applied to Ice slow when AbsoluteZero is present. Does not scale with AZ TotalStack. |
| `RarityTagBudget[rarity].MinCount / MaxCount` | varies | 03 | How many tags a component of a given rarity can carry. Controls tag density per component tier. |
**Data-table-driven knobs** (no code changes):
- `TagConfig.txt.ParamJson` — per-tag tuning (damage, duration, thresholds, multipliers)
- `RarityTagBudget.txt` — tag count budget per rarity tier
## Visual/Audio Requirements
### VFX Tag Indicators
| Tag | Visual Effect | Notes |
|-----|--------------|-------|
| `Fire` | Orange-red flame particle trail on projectile | Rarity of component with Fire determines intensity |
| `Ice` | Blue crystalline frost expanding from impact point | Single intensity regardless of TotalStack |
| `Crit` | Bright yellow-white flash on hit | Single intensity |
| `Execution` | Red skull flash when threshold crossed on kill | Single intensity |
| `Shatter` | Blue-white shatter burst on slowed target | Single intensity |
| `Inferno` | Purple-red underglow on Fire effect | Only visible when Fire is also present |
| `AbsoluteZero` | Cyan-white frost overlay on Ice effect | Only visible when Ice is also present |
**Stack count visualization**: For Fire, the burn particle count scales with `TotalStack` (more fire streams at higher stacks). For all other tags, no stack-scaling VFX — binary presence only.
**Tag display in tower info**: Tower tooltip shows each `TagRuntimeData` with its `TotalStack` count as a numeric badge (e.g., "Fire ×2"). For binary tags, the badge shows ×1 even though the count is metadata — this communicates which tags are present without misleading the player about stack value.
## Acceptance Criteria
### Tag Generation
- **GIVEN** a component is generated via `InventoryGenerationComponent`, **WHEN** tags are assigned, **THEN** no tag appears twice on the same component instance.
- **GIVEN** a component's rarity is White, **WHEN** its tags are generated, **THEN** the count is within `RarityTagBudget[White].MinCount..MaxCount`.
- **GIVEN** two components of the same config have the same `runSeed` and `ItemInstanceId`, **WHEN** tags are generated for both, **THEN** both receive identical tags (deterministic).
### Tag Aggregation
- **GIVEN** a tower has Muzzle=[Fire], Bearing=[Ice], Base=[Fire], **WHEN** `AggregateTowerTags()` is called, **THEN** the result contains Fire with `TotalStack=2` and Ice with `TotalStack=1`.
- **GIVEN** a tower has Muzzle=[Fire], Bearing=[Fire], Base=[Fire], **WHEN** aggregation runs, **THEN** Fire has `TotalStack=3`.
- **GIVEN** a tower has Muzzle=[], Bearing=[], Base=[], **WHEN** aggregation runs, **THEN** the result is `Empty TagRuntimeData[]`.
- **GIVEN** a tower has Fire×1 and Inferno×1, **WHEN** combat runs, **THEN** Inferno's amplification applies to the Fire DOT.
### Fire DOT Scaling
- **GIVEN** a tower with `TotalStack=1` Fire deals burn damage D per tick, **WHEN** a tower with `TotalStack=2` Fire attacks, **THEN** burn damage is `2×D` (capped at MaxEffectiveStack).
- **GIVEN** `TotalStack=5` (theoretical), **WHEN** `FinalBurnDamagePerTick` is computed, **THEN** the effective stack is clamped to `MaxEffectiveStack`.
### Binary Tags (No Stack Scaling)
- **GIVEN** Ice tag is present (any TotalStack), **WHEN** the tower attacks, **THEN** the target receives the Ice slow effect at the configured strength.
- **GIVEN** `TotalStack=1` Ice vs. `TotalStack=3` Ice, **WHEN** slow strength is compared, **THEN** both apply identical slow strength.
- **GIVEN** Crit tag is present, **WHEN** a hit lands, **THEN** crit bonus applies at the fixed multiplier (same as any other TotalStack value).
- **GIVEN** Shatter tag is present on a tower attacking a slowed target, **WHEN** damage is calculated, **THEN** shatter bonus applies regardless of TotalStack.
### Amplifier Tags
- **GIVEN** a tower has Inferno but no Fire, **WHEN** combat runs, **THEN** Inferno has zero mechanical effect.
- **GIVEN** a tower has AbsoluteZero but no Ice, **WHEN** combat runs, **THEN** AbsoluteZero has zero mechanical effect.
- **GIVEN** a tower has Fire×1 and Inferno×1, **WHEN** burn damage is computed, **THEN** Inferno's amplification multiplier is applied to the Fire DOT.
### UI Display
- **GIVEN** a tower has Fire with `TotalStack=2`, **WHEN** the tower tooltip is shown, **THEN** it displays "Fire ×2".
- **GIVEN** a tower has Ice with `TotalStack=1`, **WHEN** the tower tooltip is shown, **THEN** it displays "Ice ×1" (not ×0 — stack count reflects presence, not power).

View File

@ -1,8 +1,8 @@
# Tower Assembly (塔组装系统) # Tower Assembly (塔组装系统)
> **Status**: Designed > **Status**: Approved — dependency direction fixed (Node System removed from upstream; Tag System added as hard upstream); Tag System GDD now exists and defines TotalStack semantics; Tower Assembly §3 example updated
> **Author**: SepComet + agents > **Author**: SepComet + agents
> **Last Updated**: 2026-04-29 > **Last Updated**: 2026-04-30
> **Implements Pillar**: Tactical preparation and adaptation — players optimize tower builds between combat encounters > **Implements Pillar**: Tactical preparation and adaptation — players optimize tower builds between combat encounters
## Overview ## Overview
@ -13,15 +13,13 @@ The Tower Assembly system is the **component combination engine** that transform
**"Every battle is a puzzle. You have the pieces—arrange them to solve it."** **"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 Tower Assembly delivers the fantasy of **optimization mastery under constraint**. 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 — given the pieces available, what is the strongest arrangement for the known job? The core feeling is **the satisfaction of finding the best use of what you have** — not the satisfaction of matching a build to a specific enemy weakness, but the intellectual pleasure of transparent optimization.
The player should feel: The player should feel:
- **Analyzing incoming threats** — the next node types are known; the question is "what does this enemy fear?" - **Analyzing what they have** — components are visible, stats are transparent, and the question is "what can I build with what I've got?"
- **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. - **Optimizing for the next challenge** — component rarity scarcity, availability, and roster constraints define the optimization puzzle; the player decides which components to combine and which towers to deploy given their current inventory and the 4-slot roster limit
- **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. - **Free to experiment** — disassembling is free and costs nothing; the player can try different configurations before committing to the next node
- **Building toward the boss** — each assembly decision accumulates toward the final confrontation; the boss fight is where build quality is ultimately tested. - **Building toward the boss** — each assembly decision accumulates toward the final confrontation; the boss fight is a race against its own HP scaling — better towers deal more damage faster, keeping the boss HP curve lower than it would be with weaker towers
**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 ## Detailed Design
@ -62,7 +60,7 @@ The player should feel:
- The tower **cannot be rostered** for combat (`TryAddParticipantTower` returns failure) - The tower **cannot be rostered** for combat (`TryAddParticipantTower` returns failure)
- The tower **cannot be used** in combat — it is treated as non-functional - 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 - 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) - A 0-endurance tower **can be disassembled** via `TryDisassembleTower` during the Assembly Phase window (after a node completes, before the next node's active flow begins). Disassembly is blocked during active Combat/Event/Shop node flows — this restriction is enforced by the UI layer (RepoFormController hides or disables the disassemble action during those phases), not by the inventory service. Repair is out of scope for this GDD (see Open Question 3).
**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. **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.
@ -102,7 +100,7 @@ Tower Assembly has no standalone state machine — it operates as a service with
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: 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` `statValue[i] = Max(0, baseValue + perLevel * i)` for `i` in `0..4` — all stats are clamped to a minimum of 0 (no negative stats are possible).
**Variables:** **Variables:**
@ -131,9 +129,9 @@ Each tower stat (AttackDamage, RotateSpeed, AttackRange, AttackSpeed) is built a
### 2. Tower Rarity Resolution ### 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: `InventoryRarityRuleService.ResolveTowerRarity(muzzleRarity, bearingRarity, baseRarity)` resolves tower rarity as the arithmetic mean of the three constituent component rarities, floored and clamped:
`towerRarity = Clamp(Round((mR + bR + baseR) / 3), White, Red)` `towerRarity = Clamp(Mathf.FloorToInt((mR + bR + baseR) / 3f), White, Red)`
**Variables:** **Variables:**
@ -142,12 +140,11 @@ Each tower stat (AttackDamage, RotateSpeed, AttackRange, AttackSpeed) is built a
| muzzleRarity | mR | RarityType | White..Red | Muzzle component rarity | | muzzleRarity | mR | RarityType | White..Red | Muzzle component rarity |
| bearingRarity | bR | RarityType | White..Red | Bearing component rarity | | bearingRarity | bR | RarityType | White..Red | Bearing component rarity |
| baseRarity | baseR | RarityType | White..Red | Base component rarity | | baseRarity | baseR | RarityType | White..Red | Base component rarity |
| normalized | n | int | 15 | (int)clampedRarity - 1 (0-based index) |
| average | avg | float | 15 | (mR + bR + baseR) / 3f | | average | avg | float | 15 | (mR + bR + baseR) / 3f |
| rounded | rnd | int | 15 | Math.Round(average) | | floored | flr | int | 15 | Mathf.FloorToInt(average) — always rounds down at .5 boundaries |
| towerRarity | — | RarityType | White..Red | Final tower rarity | | towerRarity | — | RarityType | White..Red | Final tower rarity |
**Output Range:** White to Red. Average rounding: 1.501.99 → Green; 2.502.99 → Blue; etc. **Output Range:** White to Red. Floor drops any fractional part: 1.001.99 → White; 2.002.99 → Green; 3.003.99 → Blue; etc.
**Example** — Muzzle=Green(2), Bearing=Blue(3), Base=Purple(4): `(2+3+4)/3 = 3.0` → **Blue** **Example** — Muzzle=Green(2), Bearing=Blue(3), Base=Purple(4): `(2+3+4)/3 = 3.0` → **Blue**
@ -157,18 +154,21 @@ Each tower stat (AttackDamage, RotateSpeed, AttackRange, AttackSpeed) is built a
`TowerTagAggregationService.AggregateTowerTags(muzzleTags, bearingTags, baseTags)` merges tags from all three components into `TagRuntimeData[]`: `TowerTagAggregationService.AggregateTowerTags(muzzleTags, bearingTags, baseTags)` merges tags from all three components into `TagRuntimeData[]`:
**Tag Stacking Rule (Revised):** `TotalStack` is the tag's **occurrence count** across the 3 components (13). Per the Tag System GDD, only `Fire` scales with `TotalStack` (linear DOT damage multiplier). All other launch tags (Ice, Crit, Execution, Shatter) are **binary on/off**`TotalStack > 1` provides no mechanical advantage. `Inferno` and `AbsoluteZero` are pure amplifiers that require their base tags (Fire and Ice respectively) to have any effect. See `design/gdd/tag-system.md` TR5 for the full per-tag semantics table.
**Variables:** **Variables:**
| Variable | Type | Description | | Variable | Type | Description |
|----------|------|-------------| |----------|------|-------------|
| componentTags | `TagType[][]` | Tags array from each of the 3 components | | componentTags | `TagType[][]` | Tags array from each of the 3 components |
| stackByTag | `Dictionary<TagType, int>` | Running count of tag occurrences | | stackByTag | `Dictionary<TagType, int>` | Running count of tag occurrences across the 3 components |
| TotalStack | int | `Max(1, occurrenceCount)` per tag — guaranteed at least 1 | | TotalStack | int | `occurrenceCount` (13) per tag — semantics per Tag System GDD: Fire scales linearly; all other launch tags are binary |
**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[])`. **Output:** `TagRuntimeData[]` sorted by `TagType`. Flat unique tag list (`Tags[]`) is derived by `FlattenUniqueTags(TagRuntimeData[])`.
**Example** — Muzzle=[Fire], Bearing=[Ice], Base=[Fire]: **Example** — Muzzle=[Fire], Bearing=[Ice], Base=[Fire]:
`{ Fire: 2, Ice: 1 }``[{ Fire, TotalStack=2 }, { Ice, TotalStack=1 }]` `{ Fire: 2, Ice: 1 }``[{ Fire, TotalStack=2 }, { Ice, TotalStack=1 }]`
In this example, Fire appears on 2 components — Fire's DOT damage scales with TotalStack=2. Ice appears once — Ice slow is binary (on/off), so TotalStack=1 is identical in effect to TotalStack=3.
## Edge Cases ## Edge Cases
@ -192,11 +192,11 @@ Each tower stat (AttackDamage, RotateSpeed, AttackRange, AttackSpeed) is built a
- **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 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 a tower's component stat arrays are shorter than 5 elements**: `ResolveRarityBaseValue` uses `Clamp(rarityIndex, 0, array.Length - 1)`. If the array has fewer than 5 entries, higher rarity components silently read from lower rarity base values (e.g., a 3-element array maps Purple and Red to the Blue base value). This is a data-authoring constraint — data tables must provide exactly 5-element arrays for all components. 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 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. - **If a tower's rarity resolves to a boundary value** (e.g., `(Green + Blue + Purple) / 3 = 3.0`): `Mathf.FloorToInt(3.0f) = 3` (Blue). Floor rounding always rounds down at .5 boundaries.
## Dependencies ## Dependencies
@ -204,21 +204,21 @@ Each tower stat (AttackDamage, RotateSpeed, AttackRange, AttackSpeed) is built a
| System | Type | Interface | Status | | 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 | | **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 | | **DataTable (DRMuzzleComp, DRBearingComp, DRBaseComp)** | Hard | Per-level delta lookups during stat building. Missing rows cause assembly failure. | Implemented |
| **Tag System** | Hard | `TotalStack` semantics (which tags scale with stack count and how) are defined in the Tag System GDD. Tower Assembly aggregates tags but does not interpret their combat effects. | GDD exists (`design/gdd/tag-system.md`) |
### Downstream Dependents (what depends on Tower Assembly) ### Downstream Dependents (what depends on Tower Assembly)
| System | Type | Interface | Status | | System | Type | Interface | Status |
|--------|------|-----------|--------| |--------|------|-----------|--------|
| **Node System** | Soft | Reads assembled tower configs during Assembly Phase; passes roster to combat. | GDD exists | | **Node System** | Soft | Assembly Phase provides UI context for tower assembly. Node System calls into Tower Assembly — Tower Assembly is downstream of Node System. | 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`) | | **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 | | **Progression** | Soft | May read aggregate tower assembly stats across runs. Pending Progression GDD. | Not yet designed |
### Provisional Assumptions ### Provisional Assumptions
- `TryDisassembleTower` method is not yet implemented — the design doc R3 specifies it should exist. Implementation is pending. - `TryDisassembleTower` is implemented (see Open Question 1). The 0-endurance disassemble restriction is UI-enforced (RepoFormController), not service-enforced.
- Repair mechanism for 0-endurance components is out of scope for this GDD (see Open Questions). - Repair mechanism for 0-endurance components is out of scope for this GDD (see Open Question 3).
- `CombatParticipantTowerValidationService` handles degraded tower detection — this is owned by the Combat System GDD. - `CombatParticipantTowerValidationService` handles degraded tower detection — this is owned by the Combat System GDD.
## Tuning Knobs ## Tuning Knobs
@ -329,39 +329,62 @@ Each tower stat (AttackDamage, RotateSpeed, AttackRange, AttackSpeed) is built a
- All interactions possible via keyboard (tab navigation, enter to confirm) - All interactions possible via keyboard (tab navigation, enter to confirm)
### Assembly ### 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** the player has 3 unassembled components (Muzzle, Bearing, Base), **WHEN** `TryAssembleTower(muzzleId, bearingId, baseId)` is called, **THEN** the method returns `true` and a `TowerItemData` with a system-allocated `InstanceId` is added to `inventory.Towers` (AC1a); the three components' `IsAssembledIntoTower` flags are all set to `true` (AC1b).
- **GIVEN** the player has 3 unassembled components (Muzzle, Bearing, Base), **WHEN** `TryAssembleTower` is called, **THEN** the returned tower's `Stats` object contains non-null `AttackDamage`, `RotateSpeed`, `AttackRange`, and `AttackSpeed` arrays each with exactly 5 elements, built via the per-level scaling formula (AC1c).
- **GIVEN** the player has 3 unassembled components (Muzzle, Bearing, Base), **WHEN** `TryAssembleTower` is called, **THEN** the returned tower's `Rarity` equals the result of `InventoryRarityRuleService.ResolveTowerRarity` applied to the three components' rarities (AC1d).
- **GIVEN** the player has 3 unassembled components with tags `[Fire]`, `[Ice]`, `[Fire]`, **WHEN** `TryAssembleTower` is called, **THEN** the returned tower's `TagRuntimes` contains exactly two entries: Fire with `TotalStack=2` and Ice with `TotalStack=1`; `Tags` (flattened) contains both `TagType.Fire` and `TagType.Ice` (AC1e).
- **GIVEN** the same component instance ID is passed for multiple slots (e.g., `muzzleId == bearingId`), **WHEN** `TryAssembleTower` is called, **THEN** the call returns `false` and no tower is created.
- **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** 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. - **GIVEN** any component's DR config row is missing, **WHEN** `TryAssembleTower` is called, **THEN** the call returns `false` and no tower is created.
- **GIVEN** all three components have empty or null tags arrays, **WHEN** `TryAssembleTower` is called, **THEN** the returned tower's `TagRuntimes` is `Array.Empty<TagRuntimeData>()` and `Tags` is `Array.Empty<TagType>()`.
- **GIVEN** one component has a `null` tags array and another has an empty (non-null) tags array, **WHEN** `TryAssembleTower` is called, **THEN** `null` arrays are skipped and empty arrays produce no tags; the result is identical to AC1i — `TagRuntimes` and `Tags` are both empty.
- **GIVEN** a component's tags array contains `TagType.None` or invalid enum values, **WHEN** `TryAssembleTower` is called, **THEN** invalid values are filtered out and do not appear in `TagRuntimes` or `Tags`.
### Disassembling ### 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 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. - **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.
- **GIVEN** no tower with the given `towerInstanceId` exists in `inventory.Towers`, **WHEN** `TryDisassembleTower(towerInstanceId)` is called, **THEN** the call returns `false` and no changes are made.
- **GIVEN** a tower exists in `inventory.Towers` but one or more of its constituent component instance IDs no longer exist in the inventory's component lists, **WHEN** `TryDisassembleTower(towerInstanceId)` is called, **THEN** the call returns `false` and no changes are made.
### Roster Management ### 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** 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** 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. - **GIVEN** a tower has a Muzzle/Bearing/Base component with `Endurance = 0`, **WHEN** `TryAddParticipantTower` is called, **THEN** the call returns `ParticipantTowerAssignResult` with `FailureReason = InvalidTower` and the tower is not added.
- **GIVEN** a tower is already in `ParticipantTowerInstanceIds`, **WHEN** `TryAddParticipantTower` is called for that same tower, **THEN** the call returns `ParticipantTowerAssignResult` with `FailureReason = AlreadyAssigned` and no change occurs.
- **GIVEN** no tower with the given `towerInstanceId` exists in `inventory.Towers`, **WHEN** `TryAddParticipantTower` is called, **THEN** the call returns `ParticipantTowerAssignResult` with `FailureReason = TowerMissing` and no change occurs.
- **GIVEN** a tower is in `ParticipantTowerInstanceIds`, **WHEN** `TryRemoveParticipantTower(towerInstanceId)` is called, **THEN** the tower is removed from `ParticipantTowerInstanceIds`.
- **GIVEN** a tower is not in `ParticipantTowerInstanceIds`, **WHEN** `TryRemoveParticipantTower(towerInstanceId)` is called, **THEN** the call returns `false` and no change occurs.
### Stats and Formulas ### 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 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=Green, Bearing=Blue, Base=Purple, **WHEN** rarity is computed with `Mathf.FloorToInt`, **THEN** the tower rarity is Blue (`FloorToInt((2+3+4)/3f) = FloorToInt(3.0) = 3`).
- **GIVEN** a tower is assembled from Muzzle=White, Bearing=Blue, Base=Blue, **WHEN** rarity is computed, **THEN** the tower rarity is Green (`FloorToInt((1+3+3)/3f) = FloorToInt(2.33) = 2`).
- **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`. - **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`.
- **GIVEN** a component's `rarityBaseArray` has exactly 3 elements (indices 0..2 for White/Green/Blue), **WHEN** a tower is assembled using a Purple-rarity component, **THEN** the tower reads from index 2 (the Blue base value) — higher rarities are clamped to the highest available index.
- **GIVEN** a component has `AttackSpeedPerLevel = -0.25`, **WHEN** a tower is assembled using that component, **THEN** the resulting `AttackSpeed` array values are clamped to a minimum of `0` (e.g., if `baseValue = 1.0`, Level 5 = `1.0 + (-0.25)×4 = 0.0`; if `baseValue = 0.5`, Level 5 = `0.5 + (-0.25)×4 = -0.5 → clamped to 0`).
### Endurance ### 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 tower with all components at `Endurance > 0` is in the roster, **WHEN** `ReduceTowerEndurance(towerInstanceIds, 10.0f)` is called, **THEN** each of the three constituent components' `Endurance` is reduced by `10.0` clamped to a minimum of `0`.
- **GIVEN** `towerInstanceIds` contains the same tower instance ID more than once, **WHEN** `ReduceTowerEndurance(towerInstanceIds, 10.0f)` is called, **THEN** the deduplicated list is processed once; each component's endurance is reduced by exactly `10.0`, not `20.0`.
- **GIVEN** a tower with all components at `Endurance > 0` is in the roster, **WHEN** `ReduceTowerEndurance` is called with a negative `enduranceLoss` value, **THEN** endurance is unchanged (the loss is clamped to `[0, infinity)`).
- **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. - **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.
- **GIVEN** a tower has a component with `Endurance = 0`, **WHEN** `TryDisassembleTower` is called during the Assembly Phase window (between nodes), **THEN** the call returns `true` and the tower is disassembled normally (R6: disassemble is allowed; the restriction during active node flows is UI-enforced by RepoFormController, not service-enforced).
- **GIVEN** a tower has a component with `Endurance = 0` and is currently in the combat roster, **WHEN** `TryDisassembleTower` is called, **THEN** the tower is first removed from `ParticipantTowerInstanceIds` before being disassembled.
## Open Questions ## Open Questions
### 1. TryDisassembleTower Implementation Gap ### 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. **Status**: ✅ RESOLVED — `TryDisassembleTower` is implemented in `PlayerInventoryTowerAssemblyService` and exposed via `PlayerInventoryComponent.TryDisassembleTower(long towerInstanceId)`. The method: validates tower exists, looks up constituent components, removes tower from roster, sets `IsAssembledIntoTower = false` on all three components, and removes the tower from `inventory.Towers`.
### 2. Auto-Cleanup of Degraded Rostered Towers ### 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. **Status**: ✅ RESOLVED — No auto-cleanup mechanism is required. A 0-endurance tower remains in `ParticipantTowerInstanceIds` in a non-functional state. It is not automatically removed; the player must manually `TryRemoveParticipantTower` or `TryDisassembleTower` during the Assembly Phase window. This preserves player agency during the reconfiguration phase.
### 3. Repair Mechanism for 0-Endurance Components ### 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. **Status**: OUT OF SCOPE — A 0-endurance component can be disassembled to recover the other two components (R6). 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 ### 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. **Status**: RESOLVED — No affinity or compatibility rules are enforced. Any Muzzle+Bearing+Base combination is valid. `AttackMethodType` and `AttackPropertyType` are independent dimensions.
### 5. Counter-Building Mechanical Support
**Status**: RESOLVED — The Fantasy section has been revised to accurately describe the delivered experience: optimization mastery and free reconfiguration, not threat-specific counter-building. R7 (no affinity rules) is now consistent with the revised Fantasy. The Into the Breach reference has been removed. "Counter-building" in this context means freely choosing the best tower arrangement given available components and known upcoming challenges — not affinity-based matching. If future design adds enemy-type-to-tower-effectiveness mappings, this GDD would need a backward-compatible extension.

View File

@ -108,6 +108,39 @@ entries:
source: design/gdd/shop.md source: design/gdd/shop.md
description: Hard cap on player gold. AddGold silently discards excess above this value. description: Hard cap on player gold. AddGold silently discards excess above this value.
# ─── Tag System (design/gdd/tag-system.md) ───────────────────────────────
- name: MaxEffectiveStack
type: constant
value: 3
unit: stacks
source: design/gdd/tag-system.md
description: Cap on effective Fire DOT stack count. Clamps TotalStack to prevent runaway burn damage.
- name: FireDOTDamageFormula
type: formula
variables:
- name: BaseBurnDamage
symbol: B
type: float
description: Base burn damage per tick from TagConfig.txt ParamJson
- name: TotalStack
symbol: S
type: int
description: Tag occurrence count (13) on the tower
- name: MaxEffectiveStack
symbol: M
type: int
description: Cap on effective stack; currently 3
- name: SeverityBonus
symbol: sev
type: float
description: From enemy debuff state; 0 if no other modifiers
output:
range: float
description: "FinalBurnDamagePerTick = B × Clamp(S, 1, M) × (1 + sev)"
source: design/gdd/tag-system.md
# ─── Event System (design/gdd/event-system.md) ──────────────────────── # ─── Event System (design/gdd/event-system.md) ────────────────────────
- name: EventSelectionSeedFormula - name: EventSelectionSeedFormula

View File

@ -581,4 +581,156 @@ Boss 识别规则:
4. 敌人事件处理是否仍然只做公共副作用,而不直接切状态? 4. 敌人事件处理是否仍然只做公共副作用,而不直接切状态?
5. 状态迁移是否仍然统一走 `ChangeState(...)` 5. 状态迁移是否仍然统一走 `ChangeState(...)`
6. `MapEntity` 是否仍然只通过 `MapData + Event` 获取战斗上下文? 6. `MapEntity` 是否仍然只通过 `MapData + Event` 获取战斗上下文?
7. 清理是否仍按“敌人 / 地图基础 UI / 结算 UI / 运行时数据”分工执行? 7. 清理是否仍按”敌人 / 地图基础 UI / 结算 UI / 运行时数据”分工执行?
---
## 12. Combat Economy战斗经济
> **Status**: 新增段落 — GDD 一致性审查中发现 Coin 经济在实现中存在但未写入文档
> **最后更新**: 2026-04-30
本段说明战斗域内双货币体系的完整设计依据。所有数值均来自数据表驱动,无需代码硬编码。
---
### 12.1 双货币架构概述
游戏使用两层货币,分属不同生命周期:
| 货币 | 生命周期 | 存储位置 | 描述 |
|------|----------|----------|------|
| **Coin** | 单次战斗内 | `CombatRunResourceStore.CurrentCoin` | 战斗内部经济,用于建塔/升级/拆除 |
| **Gold** | 整局运行 | `PlayerInventoryComponent.Gold` | 跨节点持久,用于商店购买、出售 |
**设计意图**: Coin 创造战斗内的即时决策张力(”我现在花 Coin 建塔还是留着防万一Gold 创造跨节点的战略张力(”我是现在买还是等下一个商店?”)。两层经济分离,防止任一层的决策深度被稀释。
**关键约束**: Coin 不跨战斗持久,战斗结束时清零。战斗胜利后 `GainedGold` 入账,`GainedCoin` 不入账。
---
### 12.2 Coin战斗内部货币
#### 12.2.1 来源
| 来源 | 字段 | 说明 |
|------|------|------|
| 关卡初始 Coin | `DRLevel.StartCoin` | 战斗开始时发放,来源于 `CombatRunResourceStore.InitializeForCombat()` |
| 敌人击杀奖励 | `DREnemy.DropCoin` | 每次击杀敌人时发放,来源于 `CombatRunResourceStore.AddEnemyDefeatedReward()` |
`StartCoin` 按关卡难度配置,确保玩家在战斗开始时有足够的决策空间。建议低难度关卡 `StartCoin` 较低,高难度/Boss 关卡较高。
`DropCoin` 按敌人类型配置。建议普通敌人 `DropCoin` 较低515精英敌人较高2050`DropGold` 分开计算。
#### 12.2.2 消耗Sinks
| 操作 | 消耗方式 | 触发时机 |
|------|----------|----------|
| **BuildTower** | `TryConsumeCoin(buildTowerCost[i])` | 玩家在战斗内点击建塔位,每槽位独立计费 |
| **UpgradeTower** | `TryConsumeCoin(upgradeCost)` | 玩家升级已有塔 |
| **DestroyTower** | 无消耗;返回 `destroyGain` Coin | 玩家拆除已有塔Coin 返还 |
建塔槽位共 4 个(对应 `BuildOptionCount = 4`),每个槽位可独立消耗 Coin。每槽位的 `buildTowerCost[i]` 由关卡或战斗阶段配置。
#### 12.2.3 追踪
`CombatRunResourceStore.GainedCoin` 记录本场战斗累计获得的 Coin。战斗结束时 `GainedCoin` 计入 `RunStats.coinsEarned`(跨战斗累计),但 Coin 本身不清零重置于下一个战斗,而是**每个新战斗重新从 `DRLevel.StartCoin` 开始**。
```
// 每场新战斗开始时
CurrentCoin = DRLevel.StartCoin // 从关卡配置重置,非累加
GainedCoin = 0 // 重置,用于本场统计
```
#### 12.2.4 数据驱动约束
| 约束 | 值 | 说明 |
|------|---|------|
| `DRLevel.StartCoin` | ≥ 0 | 建议普通关卡 50200Boss 关卡 100300 |
| `DREnemy.DropCoin` | ≥ 0 | 建议普通敌人 515精英 2050Boss 0避免重复计费 |
| `buildTowerCost[i]` | ≥ 0 | 由 `CombatSelectFormUseCase` 从关卡配置读取,建议 2080 每槽位 |
| `upgradeCost` | ≥ 0 | 由 `CombatSelectFormUseCase` 从关卡配置读取,建议 50150 |
| `destroyGain` | ≥ 0 | 建议 = `buildTowerCost * 0.5`(返还 50%),与商店售价机制一致 |
---
### 12.3 Gold战斗内获取部分
战斗过程中 Gold 获取有两个来源:
#### 12.3.1 敌人击杀掉落
```
// DREnemy 字段
DropGold // 掉落金币数额
DropPercent // 掉落概率 [0.0, 1.0]
```
战斗胜利时(敌人被击杀或波次结束),若随机值 `≤ DropPercent`,则 `AddEnemyDefeatedReward(gainedCoin, gainedGold)` 被调用,`gainedGold = DropGold`。
#### 12.3.2 关卡胜利奖励
战斗胜利结算时(`CombatSettlementState``CombatSettlementCalculator` 计算本场战斗总 Gold 奖励:`DRLevel.RewardGold`,通过 `CombatRunResourceStore.AddSettlementGold()` 入账。
战斗失败时:无 `RewardGold``GainedGold = 0`。
#### 12.3.3 追踪
`CombatRunResourceStore.GainedGold` 记录本场战斗累计获得的 Gold击杀掉落 + 胜利奖励)。战斗结束时通过 `GetRewardInventorySnapshot()` 合并至玩家主背包(`PlayerInventoryComponent.MergeInventory()`),触发 `MaxPlayerGold = 9999` 上限检查(溢出部分丢弃)。
---
### 12.4 Coin 与 Gold 的运行时边界
```
战斗开始 → InitializeForCombat(level)
└→ CurrentCoin = level.StartCoin // Coin 重置
└→ GainedCoin = 0, GainedGold = 0 // 本场统计重置
战斗过程中(敌人死亡)→ AddEnemyDefeatedReward(dropCoin, dropGold)
└→ CurrentCoin += dropCoin
└→ CurrentGold (奖励库存) += dropGold // 不影响主背包
└→ GainedCoin += dropCoin
└→ GainedGold += dropGold
战斗胜利 → AddSettlementGold(RewardGold)
└→ GainedGold += RewardGold
战斗结束 → GetRewardInventorySnapshot() → MergeInventory()
└→ 主背包 Gold += min(rewardGold, MaxPlayerGold - currentGold)
└→ 主背包组件 += 奖励组件
└→ RunStats.coinsEarned += GainedCoin // 仅统计,不持久化 Coin
```
---
### 12.5 Progression 的 coinEarned 字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `RunStats.coinsEarned` | int | **跨战斗累计**:本 run 所有战斗累计获得的 Coin 总和(战斗失败也计入) |
`coinsEarned` 用于统计目的,不产生任何游戏内效果。它记录玩家在一局 run 中总共获得了多少 Coin — 可用于未来功能(如”累计获得 10000 Coin”成就但当前无对应解锁或奖励。
---
### 12.6 与商店经济的隔离
Coin 仅在战斗域内流通。商店系统(`design/gdd/shop.md`)处理的 buy/sell 交易仅涉及 Gold不涉及 Coin。
- 战斗内**不会**触发商店交易
- 商店内**不会**消耗或获得 Coin
- 战斗结束时Coin 不转换为 Gold`GainedCoin` 仅计入 `RunStats`,不进入玩家背包)
---
### 12.7 调试与一致性检查
| 检查项 | 预期结果 |
|--------|----------|
| 新战斗开始时 `CurrentCoin == DRLevel.StartCoin` | 相等 |
| 战斗结束时 `GainedCoin ≥ 0` | 大于等于零 |
| 战斗失败时 `GainedGold == 0` | 战斗失败不发放 Gold 奖励 |
| `MaxPlayerGold` 上限检查 | `MergeInventory` 后背包 Gold ≤ 9999 |
| Coin 消耗后 `CurrentCoin ≥ 0` | `TryConsumeCoin` 失败时 `CurrentCoin` 不变 |

View File

@ -5,12 +5,17 @@ Cross-GDD Review — COMPLETE
## Status ## Status
- **Task**: /review-all-gdds — COMPLETED - **Task**: /review-all-gdds — COMPLETED
- **Report**: design/gdd/gdd-cross-review-2026-04-29-v2.md - **Report**: design/gdd/gdd-cross-review-2026-04-30.md
- **Verdict**: CONCERNS (3 blocking issues, 6 warnings) - **Verdict**: CONCERNS (0 blocking, 7 warnings)
- **Flagged GDDs**: node-system.md (Needs Revision), progression.md (Needs Revision), tower-assembly.md (flagged medium) - **Flagged GDDs**: tower-assembly.md (High), event-system.md (Medium), shop.md (Medium), node-system.md (Medium)
## Session Extract — /review-all-gdds 2026-04-29 ## Session Extract — /review-all-gdds 2026-04-30
- Verdict: CONCERNS - Verdict: CONCERNS
- Blocking issues: 3 — (C1) "no partial rewards" vs Progression loss-path gold, (G1) Boss exponential HP vs linear player power hard wall, (G2/G3) Run End gold display undefined + loss-run RecordRunEnd undefined - Blocking issues: None — all prior blocking issues (C1, G1, G2/G3 from 2026-04-29) are resolved
- Warnings: 6 — gold unbounded accumulation, no dominant loop defined, Tag stacking dominant strategy, lossy economy (~50% sell), event rewards same tier as shop, Assembly Phase 5-panel cognitive overload - Warnings: 7 — C-W1 tower-assembly dependency direction inverted, C-W2 RunStats.goldEarned loss-path AC gap, G-W1 Tag System GDD missing (HIGHEST), G-W2 event rarity budget same as shop, G-W3 sell ratio ~50% discourages experimentation, G-W4 Assembly Phase 5-panel cognitive load, G-W5 "build toward boss" conflicts with boss scaling, G-W6 Normal→Hard difficulty spike, G-W7 gold cap unreachable
- Recommended next: /design-system retrofit node-system.md or /design-system retrofit progression.md (resolve blocking C1+G3 first) - Recommended next: Write design/gdd/tag-system.md (G-W1 — blocks Tower Assembly implementation)
## Session Extract — design/gdd/tag-system.md 2026-04-30
- Verdict: APPROVED (first review)
- G-W1 resolved: Tag System GDD written defining TotalStack semantics (Fire scales linearly, all other tags binary). C-W1 also resolved: Tower Assembly dependency direction fixed. Tower Assembly GDD updated to reference Tag System GDD.
- Remaining warnings from cross-GDD review: G-W2, G-W3, G-W4, G-W5, G-W6, G-W7 (all advisory, non-blocking)