# 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` (1–3) 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 (1–3) 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 (0–N) | 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 | 1–3; 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 | 2–5 | 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.1–0.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 | 0–3 | 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).