geometry-tower-defense/design/gdd/tower-assembly.md

391 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Tower Assembly (塔组装系统)
> **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
> **Last Updated**: 2026-04-30
> **Implements Pillar**: Tactical preparation and adaptation — players optimize tower builds between combat encounters
## Overview
The Tower Assembly system is the **component combination engine** that transforms individual Muzzle, Bearing, and Base components into functional Tower instances. During Assembly Phase (between combat nodes), the `PlayerInventoryTowerAssemblyService.TryAssembleTower()` method accepts three component instance IDs and produces a `TowerItemData` containing aggregated stats across 5 level tiers. The system resolves tower rarity from component rarities, computes per-level stat arrays from rarity-scaled base values plus data-table-defined per-level deltas, and merges Tags from all constituent components. Assembled towers can be rostered for combat (max 4 active) via the `PlayerInventoryTowerRosterService`. The system operates purely on in-memory inventory state; all data is ephemeral per run.
## Player Fantasy
**"Every battle is a puzzle. You have the pieces—arrange them to solve it."**
The Tower Assembly delivers the fantasy of **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:
- **Analyzing what they have** — components are visible, stats are transparent, and the question is "what can I build with what I've got?"
- **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
- **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 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
## Detailed Design
### Core Rules
**Tower Assembly** combines exactly one Muzzle, one Bearing, and one Base component into a Tower instance.
**R1. Assembly Eligibility**: A component is eligible for assembly if and only if:
- It exists in the player's inventory (identified by `InstanceId`)
- Its `IsAssembledIntoTower` flag is `false`
**R2. Assembly Process** (`PlayerInventoryTowerAssemblyService.TryAssembleTower`):
1. Player selects one Muzzle, one Bearing, and one Base component from inventory
2. System validates all three components are eligible (R1)
3. System looks up per-level delta values from data tables (`DRMuzzleComp.AttackDamagePerLevel`, `DRBearingComp.RotateSpeedPerLevel`/`AttackRangePerLevel`, `DRBaseComp.AttackSpeedPerLevel`)
4. Tower rarity computed via `InventoryRarityRuleService.ResolveTowerRarity(muzzleRarity, bearingRarity, baseRarity)` — arithmetic mean of three rarities, rounded and clamped to `RarityType` enum range
5. Stat arrays built at `TowerLevelCount = 5` granularity using rarity-scaled base values plus per-level deltas
6. Tags aggregated via `TowerTagAggregationService.AggregateTowerTags()` — stack counts merged across all three components
7. New `TowerItemData` created with a system-allocated `InstanceId`, display name, and the computed stats
8. All three components' `IsAssembledIntoTower` flags are set to `true`
9. The new tower is added to `inventory.Towers`
**R3. Disassembling**: Any assembled tower may be disassembled at no cost. Disassembly:
- Removes the tower from `inventory.Towers`
- Sets all three constituent components' `IsAssembledIntoTower` flags to `false`
- Components' `Endurance` values are preserved (not reset)
- If the tower is currently in the combat roster (`ParticipantTowerInstanceIds`), it is automatically removed from the roster before disassembling
**R4. Tower Roster** (`PlayerInventoryTowerRosterService`):
- Maximum 4 towers may be rostered for combat (`MaxParticipantTowerCount`)
- Roster is set during Assembly Phase via `TryAddParticipantTower(towerInstanceId)` / `TryRemoveParticipantTower(towerInstanceId)`
- Only rostered towers participate in combat
- Roster state persists in `inventory.ParticipantTowerInstanceIds` and is reset on each new run
**R5. Tower Endurance**: Each component has an `Endurance` value (0100). When a tower participates in combat, all three components' endurance is reduced proportionally via `InventoryTowerEnduranceUtility.ReduceTowerEndurance()`.
**R6. Endurance at Zero**: If any constituent component's `Endurance` reaches 0:
- The tower **cannot be rostered** for combat (`TryAddParticipantTower` returns failure)
- The tower **cannot be used** in combat — it is treated as non-functional
- The components retain their 0 endurance state until repaired or the run ends
- A 0-endurance 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.
### States and Transitions
Tower Assembly has no standalone state machine — it operates as a service within the `PlayerInventoryComponent`. State transitions are driven by the Node System's Assembly Phase.
**Component States** (per component instance):
| State | Description | Exits |
|-------|-------------|-------|
| `InInventory` | Component resides in inventory, not assembled | → `Assembled` on successful `TryAssembleTower` |
| `Assembled` | Component is part of a tower, `IsAssembledIntoTower = true` | → `InInventory` on successful `TryDisassembleTower`; → `InRoster` when tower is added to roster |
| `InRoster` | Tower containing this component is in the combat roster | → `Assembled` when tower removed from roster |
| `Degraded` | Any constituent component has `Endurance = 0`; tower cannot be rostered | → `Assembled` if endurance is repaired (out of scope) |
**Tower States** (per tower instance):
| State | Description | Exits |
|-------|-------------|-------|
| `AssembledIdle` | Tower exists in `inventory.Towers`, not in combat roster | → `AssembledRostered` on `TryAddParticipantTower` |
| `AssembledRostered` | Tower is in `ParticipantTowerInstanceIds`, eligible for combat | → `AssembledIdle` on `TryRemoveParticipantTower`; → `Degraded` if any component reaches 0 endurance |
| `Degraded` | Tower cannot be rostered due to 0-endurance component | → `AssembledRostered` if endurance is repaired (out of scope) |
### Interactions with Other Systems
| System | Direction | Interface |
|--------|-----------|------------|
| **Node System** | Driven by | Assembly Phase is triggered by `NodeSystem` after each node resolves. During Assembly Phase, the player may call `TryAssembleTower`, `TryDisassembleTower`, `TryAddParticipantTower`, and `TryRemoveParticipantTower`. |
| **PlayerInventoryComponent** | Reads/writes | Tower Assembly operates entirely through `PlayerInventoryComponent`'s inventory state. Components and towers are stored in `BackpackInventoryData`. |
| **Combat System** | Delegates to | `PlayerInventoryComponent.GetParticipantTowerSnapshot()` returns the current combat roster (up to 4 towers). Combat system reads tower stats from this snapshot. |
| **Progression** | Writes | On `RunEnd`, final tower configurations are not persisted (run resets). Progression may read aggregate stats (e.g., total towers assembled across all runs) — pending Progression GDD. |
## Formulas
### 1. Tower Stat Per-Level Scaling
Each tower stat (AttackDamage, RotateSpeed, AttackRange, AttackSpeed) is built as a 5-element array via `BuildLevelIntArray` or `BuildLevelFloatArray`. All stats follow the same structural formula:
`statValue[i] = 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:**
| Variable | Symbol | Type | Range | Description |
|----------|--------|------|-------|-------------|
| rarityBaseArray | B | int[5] or float[5] | varies | Per-rarity base values indexed by rarity (White=0..Red=4) |
| rarity | R | RarityType | White..Red | Component's rarity, converted to 0-based index |
| rarityIndex | ri | int | 04 | `Clamp((int)R - 1, 0, 4)` |
| baseValue | B[ri] | int or float | varies | Starting value at level 0, selected by rarity |
| perLevel | P | int or float | varies | Per-level increment from data table |
| statValue[i] | V_i | int or float | varies | Stat value at level i (level = i + 1) |
**Output Range:** Level 1 value = `B[ri]`; Level 5 value = `B[ri] + P * 4`. Actual range depends on component data tables.
**Example — AttackDamage** (Muzzle, Green rarity, `perLevel=3`, `AttackDamage = [10, 20, 30, 40, 50]`):
| Level | Index | Value |
|-------|-------|-------|
| 1 | 0 | 20 + 3×0 = **20** |
| 2 | 1 | 20 + 3×1 = **23** |
| 3 | 2 | 20 + 3×2 = **26** |
| 4 | 3 | 20 + 3×3 = **29** |
| 5 | 4 | 20 + 3×4 = **32** |
---
### 2. Tower Rarity Resolution
`InventoryRarityRuleService.ResolveTowerRarity(muzzleRarity, bearingRarity, baseRarity)` resolves tower rarity as the arithmetic mean of the three constituent component rarities, floored and clamped:
`towerRarity = Clamp(Mathf.FloorToInt((mR + bR + baseR) / 3f), White, Red)`
**Variables:**
| Variable | Symbol | Type | Range | Description |
|----------|--------|------|-------|-------------|
| muzzleRarity | mR | RarityType | White..Red | Muzzle component rarity |
| bearingRarity | bR | RarityType | White..Red | Bearing component rarity |
| baseRarity | baseR | RarityType | White..Red | Base component rarity |
| average | avg | float | 15 | (mR + bR + baseR) / 3f |
| floored | flr | int | 15 | Mathf.FloorToInt(average) — always rounds down at .5 boundaries |
| towerRarity | — | RarityType | White..Red | Final tower rarity |
**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**
---
### 3. Tag Aggregation
`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:**
| Variable | Type | Description |
|----------|------|-------------|
| componentTags | `TagType[][]` | Tags array from each of the 3 components |
| stackByTag | `Dictionary<TagType, int>` | Running count of tag occurrences across the 3 components |
| 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`. Flat unique tag list (`Tags[]`) is derived by `FlattenUniqueTags(TagRuntimeData[])`.
**Example** — Muzzle=[Fire], Bearing=[Ice], Base=[Fire]:
`{ Fire: 2, Ice: 1 }``[{ Fire, TotalStack=2 }, { Ice, TotalStack=1 }]`
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
- **If the same component instance ID is passed for multiple slots**: Assembly fails. `TryGetComponentById` searches type-specific lists, so the second slot lookup fails and returns `false`.
- **If any component is already assembled into a tower** (`IsAssembledIntoTower = true`): Assembly fails immediately. Components may only belong to one tower at a time.
- **If any component's DR config row is missing** (`DRMuzzleComp`, `DRBearingComp`, or `DRBaseComp` returns `null` for the component's `ConfigId`): Assembly fails. Components without valid data table entries cannot be assembled.
- **If all three components have duplicate tags** (e.g., all have `TagType.Fire`): `TagRuntimeData` is produced with `TotalStack = 3`. Stack count equals the number of components carrying that tag (max 3).
- **If one or more components have empty or null tags arrays**: `AggregateTowerTags` skips null/empty arrays. The resulting tower only has tags from components with non-empty arrays.
- **If all three components have empty/null tags**: Tower has no tags. `AggregateTowerTags` returns `Array.Empty<TagRuntimeData>()`.
- **If tags contain `TagType.None` or invalid enum values**: These are filtered out by `AggregateTowerTags` via `if (tagType == TagType.None || !Enum.IsDefined(typeof(TagType), tagType)) continue;`. Invalid tags do not appear in output.
- **If the combat roster is full (4 towers) and user attempts to add another**: `TryAddParticipantTower` returns `ParticipantTowerAssignFailureReason.ParticipantAreaFull`. The tower is not added.
- **If a tower with 0-endurance component is attempted to be rostered**: `CombatParticipantTowerValidationService.ValidateTower` returns a validation failure (`BrokenMuzzleComponent`/`BrokenBearingComponent`/`BrokenBaseComponent`). `TryAddParticipantTower` returns `ParticipantTowerAssignResult` with `FailureReason = InvalidTower`. The tower cannot participate.
- **If a tower in the combat roster has a component reach 0 endurance mid-combat**: The tower remains in `ParticipantTowerInstanceIds` but becomes degraded. `CombatParticipantTowerValidationService.ValidateParticipantTowers` marks it invalid on the next validation. No automatic removal from roster occurs.
- **If a tower's component stat arrays are shorter than 5 elements**: `ResolveRarityBaseValue` uses `Clamp(rarityIndex, 0, array.Length - 1)`. If the array 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 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
### Upstream Dependencies (what Tower Assembly depends on)
| System | Type | Interface | Status |
|--------|------|-----------|--------|
| **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 |
| **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)
| System | Type | Interface | Status |
|--------|------|-----------|--------|
| **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`) |
| **Progression** | Soft | May read aggregate tower assembly stats across runs. Pending Progression GDD. | Not yet designed |
### Provisional Assumptions
- `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 Question 3).
- `CombatParticipantTowerValidationService` handles degraded tower detection — this is owned by the Combat System GDD.
## Tuning Knobs
| Knob | Default | Safe Range | Extreme: Too Low | Extreme: Too High |
|------|---------|-----------|-----------------|------------------|
| `TowerLevelCount` | 5 | 310 | Fewer upgrade tiers — less meaningful progression within a tower's lifetime | More tiers — stat arrays grow; UI scalability issues; balance complexity |
| `MaxParticipantTowerCount` | 4 | 26 | Too few towers — limited tactical variety in combat roster | Too many — combat UI cluttered; player decision paralysis |
| Per-level delta (`DRMuzzleComp.AttackDamagePerLevel`, etc.) | varies by row | varies | Too high → towers scale exponentially; late-game dominance | Too low → leveling feels pointless; stats converge |
| Rarity base arrays | varies by component row | varies | Too high → rarity gaps become massive | Too low → rarity feels meaningless |
**Data-table-driven knobs** (not code constants):
- `DRMuzzleComp.AttackDamagePerLevel` — flat AttackDamage increase per tower level
- `DRBearingComp.RotateSpeedPerLevel` — rotation speed increase per level
- `DRBearingComp.AttackRangePerLevel` — range increase per level
- `DRBaseComp.AttackSpeedPerLevel` — attack speed change per level (can be negative)
## Visual/Audio Requirements
### VFX Event Specifications
| Event | Visual Effect | Audio Cue | Duration |
|-------|--------------|-----------|----------|
| **Tower Assembled** | 610 geometric particles (triangles/diamonds) burst from assembly point, rarity-colored. Component icons collapse inward, tower card materializes with scale pulse (1.0x→1.15x→1.0x). Purple/Red rarity adds golden shimmer particles. | Ascending C5-E5-G5 arpeggio (200ms). Blue+ adds C6 for premium signal. | ~300ms |
| **Tower Added to Roster** | Roster slot glows rarity color (200ms). Tower icon animates to roster slot (200ms ease-out). Roster full: ring pulse from panel border. | Two-tone lock-in (G5→E5→G5, 80ms). Roster full adds C6. | ~250ms |
| **Tower Removed from Roster** | Slot fades from rarity color to empty (150ms). Tower icon animates back to inventory. Roster-full indicator: "-1" pulse above panel. | Descending G5→D5 (100ms) — "slot opened". | ~200ms |
| **Tower Degraded (0 Endurance)** | Red-orange geometric crack propagates across tower icon (300ms). Tower dims to 40% opacity, desaturated. Broken shard overlay icon. Roster slot flashes red-orange if affected. | Dissonant minor-2nd (C5→C5♭, 150ms) + descending power-down sweep (400Hz→200Hz, 200ms). | ~400ms |
| **Tower Disassembled** | Tower icon explodes into 3 component icons flying to inventory positions. Particle burst at tower's former position. Components pulse on arrival. | Reverse arpeggio G5→E5→C5 (200ms). 3x short clicks (30ms each) as components snap back. | ~300ms |
### Rarity Color Palette
| Rarity | Hex | VFX Color | Audio Signal |
|--------|-----|-----------|--------------|
| White | `#E8E8E8` | White particles | 1-note chime |
| Green | `#4ADE80` | Green particles | 2-note chime |
| Blue | `#60A5FA` | Blue particles | 3-note chime |
| Purple | `#C084FC` | Purple + shimmer | 4-note + shimmer VFX |
| Red | `#F87171` | Red + shimmer | 5-note + shimmer VFX |
### Animation & Style Constraints
- **Particle shapes**: Triangles, diamonds, hexagons ONLY — no circles, no organic curves
- **Waveforms**: Clean sine or triangle waves — digital-mathematical character
- **Easing**: All UI animations use ease-out entry. No bounce, no elastic overshoot
- **Duration budget**: Assembly/disassemble 200300ms; roster add/remove 200250ms; degraded 300400ms. Max 400ms per transition
- **Accessibility**: All audio cues have visual alternatives (color flash, icon change, screen pulse). Degraded state uses geometric crack overlay, not color alone
- **No simultaneous full-screen effects**: VFX is localized to the relevant card/icon; full-screen flashes reserved only for degraded warning at ≤30% opacity
### DataTable Extension
`DRTowerAssemblySound` (or extension of `DRSound`):
| SoundId | AssetName | Volume | Notes |
|---------|-----------|--------|-------|
| TowerAssemble | Tower_Assemble | 0.8 | C5-E5-G5 arpeggio, 200ms |
| TowerAssemblePremium | Tower_Assemble_Premium | 0.8 | C5-E5-G5-C6, 250ms (Blue+) |
| TowerRosterAdd | Tower_Roster_Add | 0.6 | G5-E5-G5, 80ms |
| TowerRosterRemove | Tower_Roster_Remove | 0.5 | G5-D5, 100ms |
| TowerDegrade | Tower_Degrade | 0.7 | Minor-2nd + power-down sweep, 350ms |
| TowerDisassemble | Tower_Disassemble | 0.7 | G5-E5-C5 reverse arpeggio, 200ms |
## UI Requirements
### Assembly Phase Screen
**Trigger**: Auto-displayed after any node resolves (per Node System).
**Content**:
- **Inventory Grid**: All owned unassembled components, grouped by type (Muzzle, Bearing, Base)
- **Tower Slots**: 3 assembly slots (Muzzle, Bearing, Base) — drop targets for components
- **Assembled Towers Panel**: All towers built so far in this run
- **Combat Roster**: 4 slots showing currently rostered towers (ready for next combat)
- **Next Node Preview**: 2 outgoing edge destinations visible during Assembly Phase (from Node System)
- **Ready Button**: Confirms Assembly Phase is complete; triggers Node Choice
**Interactions**:
- Drag components from inventory into assembly slots
- Click "Assemble" button when 3 slots are filled → creates tower
- Click tower → shows tower stats, rarity, tags; options to "Add to Roster" or "Disassemble"
- Drag tower from Assembled Towers to Roster slots
- Click "Disassemble" on tower → free disassemble, components return to inventory
- Click "Ready" → proceeds to Node Choice
**Empty State**:
- No unassembled components: inventory grid shows "No components — combat drops will appear here"
- No assembled towers: Assembled Towers panel shows "Assemble towers from components above"
- Roster empty: slots show dotted outline placeholder
**Tower Info Tooltip/Panel** (on tower click):
- Tower name and rarity (color-coded)
- Stats: AttackDamage (5 levels), RotateSpeed, AttackRange, AttackSpeed
- Tags with stack counts
- Component sources (Muzzle/Bearing/Base names)
- Endurance bars for each component (0100%)
### Roster Management UI
**Roster Slots** (4 slots):
- Each slot shows: tower icon, rarity color border, tower name
- Drag tower to roster slot to add
- Click "X" on rostered tower to remove from roster
- Degraded tower (0 endurance): slot shows crack overlay, cannot be deployed
- Roster full (4/4): slots show "FULL" indicator; drag-and-drop returns tower to Assembled Towers
### Accessibility
- All rarity colors are paired with distinct iconography
- Degraded state uses geometric crack shape, not color alone
- Tag stack counts shown numerically, not just visually
- Component endurance shown as percentage + bar
- All interactions possible via keyboard (tab navigation, enter to confirm)
### Assembly
- **GIVEN** the player has 3 unassembled components (Muzzle, Bearing, Base), **WHEN** `TryAssembleTower(muzzleId, bearingId, baseId)` is called, **THEN** 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** 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
- **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** 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
- **GIVEN** fewer than 4 towers are in the roster, **WHEN** `TryAddParticipantTower(towerInstanceId)` is called with a valid non-degraded tower, **THEN** the tower is added to `ParticipantTowerInstanceIds`.
- **GIVEN** 4 towers are already in the roster, **WHEN** `TryAddParticipantTower` is called with a valid tower, **THEN** the call returns `ParticipantTowerAssignFailureReason.ParticipantAreaFull` and no change occurs.
- **GIVEN** a tower has a 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
- **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 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 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
- **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 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
### 1. TryDisassembleTower Implementation Gap
**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
**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
**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
**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.