You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Parent epic: #77 (FTK2 ports, capability-first: passive traits, status effects, party procs, summons). This is the first child spec and feeds the status-effect and proc specs.
Executive Summary
This spec adds the framework's first class-innate passive capability: a mod author binds a passive trait (trigger + negation effect) to a custom class with one line of code, and the framework wires the combat triggers. The capability is proven by one new showcase class, the Innkeeper, carrying two FTK2-inspired ports: Stonewall (negate the first damaging incoming attack, once per combat; ported from FTK2 Blacksmith's Steadfast and renamed to avoid the vanilla SteadFast skill collision) and Iron Belly (always-on veto of drink/consumable debuff statuses; ported from FTK2 Friar). Passives carry no FTK_*DB row and no IdAllocator id: they are a framework-side registry keyed by the class's already-deterministic id, which makes co-op determinism inherited rather than engineered.
Problem Statement
The framework can add classes, items, weapons, combat actions, and enemies, but it has no class-innate passive path: proficiencies attach only to weapons (Weapon.m_ProficiencyEffects) or enemies, and both dispatch on OUTGOING actions. Passives fire on INCOMING events (being attacked, receiving a debuff), which the game never routes through the proficiency table.
The vanilla game models class passives as CharacterSkills, a closed set of ~26 boolean flags on FTK_playerGameStart.m_CharacterSkills (verified; see kb_676160e5). Mods cannot add flags to that game type, so a framework-side registry keyed by class id is the moddable analogue. Epic #77's capability-first plan requires this passive plumbing before the status-effect and proc specs can build on it.
Goals and Success Metrics
Goal
Metric
Target
Passive authoring is one line per trait
Content.AddPassive calls needed per showcase trait
1
Both trigger surfaces proven in-game
Showcase traits observed firing in a solo session
2 of 2
Vanilla parity when unused
Observable behavior change with zero registered passives
None
Determinism inherited, not engineered
New networked identities introduced by passives
0
Done bar
SELF-TEST PASS lines + solo in-game verification of both traits
Present in BepInEx/LogOutput.log + observed
Functional Requirements
FR-1: Passive registry and data model
Description: A Core-internal registry maps a class id to its passive trait definitions. A trait definition (PassiveTraitDef) is a public immutable plain class with readonly fields (NOT a C# record; net35): registry key (modGuid + ":" + passiveId), bound class id, trigger kind, display name. There is no chance field in this spec (see Simplicity Constraints). The registry is populated at content-registration time and read by the trigger patches. No FTK_*DB row is created or mutated; no IdAllocator id is allocated. Acceptance Criteria:
After TableManager initialization, a registered trait is resolvable by (classId, trigger)
Re-registering the same (modGuid, passiveId) is idempotent: returns the existing record and logs a warning, no duplicate entry
Registering against a null or unregistered class row logs an error, mutates nothing, and returns null Priority: P0 Layer: Core
FR-2: Content.AddPassive public API
Description: One public method and one public enum, exactly as in the API surface table below. OncePerCombat is not a parameter: it is the defined semantic of the IncomingAttack negation trigger (user decision on the epic). A chance knob is deliberately excluded: neither showcase uses one, and an untested probabilistic path is exactly the extension point this framework refuses to ship; the future proc spec (epic child 3) reintroduces it with a showcase that exercises it. The method registers the display name via Localization and returns the trait record for self-test use. Acceptance Criteria:
Signature matches the API surface table exactly
The call performs no IdAllocator allocation and no FTK_*DB write
The display name resolves through the existing Localization path
A showcase trait is bindable in one call against the row returned by Content.AddClass Priority: P0 Layer: Content (public API surface; physical placement per File Placement and Ownership below)
FR-3: IncomingAttack trigger (Stonewall plumbing)
Description: A master-side Harmony patch on the damage-calculation step (DamageCalculator._calcDamage / DummyDamageInfo construction, verified in kb_676160e5) negates the first qualifying incoming attack against a character whose class holds an IncomingAttack passive. Negation follows the vanilla Petrified/Protected/Shielded precedent: damage and crit damage set to 0, new health set to victim health at start, proficiency application suppressed. A qualifying attack is a MAIN-TARGET hit (the DummyDamageInfo constructed with _mainTarget == true) whose computed damage at the negate site (the constructed m_Damage plus m_CritDamage) is greater than 0; splash/secondary sub-hits neither qualify nor consume the charge. The once-per-combat charge is consumed only by a qualifying attack. The negate applies regardless of damage type and lethality. The decision runs only on the master client; the Photon-serialized DummyDamageInfo carries the negated outcome to all clients. Passives are class-innate only: the lookup keys strictly on CharacterStats.m_CharacterClass (the base class id); gear skills and FTK_characterModifier rows can never grant a framework passive (deliberate scoping decision, in contrast to vanilla CharacterSkills.AddSkills OR-merging). Acceptance Criteria:
The first main-target incoming attack that would deal more than 0 damage to the Innkeeper deals 0, with feedback per FR-6
The second qualifying attack in the same combat deals normal damage
An attack computing to 0 damage does not consume the charge and produces no passive feedback
A splash/secondary sub-hit (constructed with _mainTarget == false) is never negated and never consumes the charge
A lethal hit is negated the same as any qualifying hit
Characters of classes without an IncomingAttack passive take damage byte-identically to vanilla
The vanilla CharacterSkills.m_SteadFast flag is never read or written by the framework Priority: P0 Layer: Core
FR-4: Once-per-combat gate and combat lifecycle reset
Description: A master-only, transient, never-serialized gate records which (character, passive) pairs have fired this combat. It resets on combat start and clears on combat end, using the verified lifecycle hooks (CharacterDummy.ResetForCombat postfix for start, CharacterDummy.CombatFinished for end; kb_676160e5). Acceptance Criteria:
A charge spent in one combat is available again at the start of the next combat
The gate state is never written to any save or network payload
Gate reads and writes happen only on the master client Priority: P0 Layer: Core
Description (AMENDED 2026-07-02 after the Phase 3 decompile gate, ninum kb_7e48c334, and a game-designer decision): a master-side veto of HARMFUL consumable-path status application to a character whose class holds a ConsumableDebuff passive. The gate found that enIntoxicate is applied by NO consumable (its only source is enemy tavern-brawl attack data, exactly the source Iron Belly must not block), and that all consumable/orb statuses flow through one dedicated path, EncounterSession.CombatPartyBuff, which enemy attacks never use. Amended scope (designer Option A): on the CombatPartyBuff path only, strip the harmful statuses (the vanilla drink downsides: enPoison1 from poisoned mead and poisoned mushroom, enConfuse from rum) targeted at the passive holder; beneficial statuses on the same path (protect, resistances, the buff halves of the same drinks) still apply. Enemy-path statuses, including the same enPoison1 and enConfuse ids and enIntoxicate, are untouched. The veto is always on (no once-per-combat gate). Never a whole ProficiencyBase.Category immunity. Acceptance Criteria:
Consuming a drink whose downside is enPoison1 or enConfuse as the Innkeeper applies its buffs but NOT the harmful status, with feedback per FR-6
The same status id applied by an enemy attack still lands on the Innkeeper
Characters of other classes receive consumable statuses byte-identically to vanilla
The veto is implemented at the consumable choke point (EncounterSession.CombatPartyBuff), never at the shared AddProfToDummy Priority: P0 Layer: Core
FR-6: Player-facing feedback (never silent)
Description: Every passive proc emits two feedback surfaces: a combat-log entry (EncounterSession.AddCombatEventToActiveLogEntry) and a floating HUD text on the character (CharacterDummy.SpawnHudTextRPC), both verified reachable from the trigger paths (kb_676160e5). Localization mechanism (required, since passives have no DB row or vanilla getter to postfix): the showcase registration registers the trait name and the two feedback templates in the existing Localization string map under synthetic keys derived from the passive key (for example passiveKey + ":hud" and passiveKey + ":log"), and the Core patch reads them back via the existing TryGetName path. The English strings therefore live in the showcase content registration, never in the Core patch bodies. Emission site: feedback is emitted from the master-only decision site; SpawnHudTextRPC carries the floating text to all clients by RPC, and combat-log parity on non-master clients is the deferred item recorded in the risk table (solo is the done bar). Feedback derives from the networked outcome (the mutated DummyDamageInfo / suppressed status), so what the player sees always matches what mechanically happened. Acceptance Criteria:
A Stonewall proc shows a floating text and a combat-log line naming the trait, observed solo in-game
An Iron Belly proc shows a floating text and a combat-log line naming the trait, observed solo in-game
Both strings are registered by the showcase via the synthetic-key Localization mechanism and read back by the patches via TryGetName (no hard-coded English in the patch bodies)
No passive ever fires without visible feedback Priority: P0 Layer: Core
FR-7: Innkeeper showcase class
Description: One new class, the Innkeeper, created via the existing Content.AddClass path (clone a tanky vanilla template such as Blacksmith for skill set and skinset scaffolding), with a Toughness/Vitality-forward, low-offense, low-Speed stat identity per the design brief, and both showcase passives bound via Content.AddPassive. Localized class name, trait names, and flavor text (design brief provides draft copy). All vanilla classes are untouched. Acceptance Criteria:
The Innkeeper is selectable at character create and playable in a solo run
Both traits fire in-game per FR-3 and FR-5
The Innkeeper's offense stats are below and survival stats at-or-above the Blacksmith template's row values (verified against the live DB rows in the self-test)
No vanilla FTK_playerGameStart row is mutated Priority: P0 Layer: Content
FR-8: Self-test coverage
Description: The framework's SELF-TEST suite gains assertions for this capability, following the existing self-test pattern. Acceptance Criteria:
SELF-TEST asserts both showcase traits are resolvable in the registry, bound to the Innkeeper's class id, with the expected triggers
SELF-TEST asserts the Innkeeper class row is registered with id == array index
SELF-TEST PASS lines appear in BepInEx/LogOutput.log with no diagnostic flags set Priority: P0 Layer: Core
Non-Functional Requirements
NFR-1: Runtime and language compatibility
Category: Compatibility Requirement: All code targets net35 (Mono, Unity 2017.2.2p2). No APIs or language features that postdate .NET 3.5. Rationale: The game's Mono runtime hard-fails on newer surface; this is the framework's standing constraint.
NFR-2: Co-op determinism
Category: Reliability Requirement: All passive decisions (negation, chance roll, gate) execute only on the master client. Outcomes reach other clients exclusively through existing networked game state (the Photon-serialized DummyDamageInfo, the status application result). Passives introduce zero new networked identities and zero client-local RNG. Rationale: Epic #77 invariant; mirrors the proven master-grants-broadcasts pattern of ThiefStealProficiency.
NFR-3: Vanilla parity when unused
Category: Reliability Requirement: With no registered passives, every patched code path produces behavior observably identical to vanilla. Registration remains one-shot behind the existing _done-guarded TableManager.Initialize postfix. The two trigger patches carry NO one-shot guard (they must run on every damage calculation / status application); their invariant is statelessness: no static mutable state except the master-only transient gate, and an early return to the vanilla outcome when the victim's class has no matching passive. Rationale: The framework must be a no-op for content that is not installed; regressions in vanilla combat are unacceptable. Idempotency means the right thing per patch kind: one-shot for registration, parity-preserving statelessness for per-event triggers.
NFR-4: Closed extension surface
Category: Maintainability Requirement: PassiveTrigger is a closed two-value set in this spec. New trigger kinds, non-negation effects, and a BehaviorKind.Passive dispatch kind are future specs (the seam is named here but not built). Rationale: Two showcases whose effect is site-intrinsic negation do not justify an effect abstraction; premature extension points are the framework's documented anti-pattern.
NFR-5: No per-frame cost
Category: Performance Requirement: Passive lookups occur only on damage-calculation and status-application events (dictionary lookup by class id). No per-frame work, no polling. Rationale: Combat responsiveness must be unaffected on 2017-era hardware targets.
NFR-6: Type accessibility and immutability
Category: Maintainability Requirement: PassiveTraitDef is a PUBLIC plain class with readonly fields (it is returned by the public AddPassive; not a C# 9 record, net35). PassiveRegistry and PassiveCombatGate are internal. No type in this feature exposes mutable static state to modders. Rationale: The framework has prior drift between "internal" framing and public declarations (kb_771834da); making accessibility explicit prevents repeating it.
Technical Architecture
Component Diagram
flowchart TD
subgraph PublicAPI["Public API (modder-facing)"]
A["Content.AddPassive"]
E["enum PassiveTrigger"]
end
subgraph Showcase["Showcase content (Content/)"]
SC["InnkeeperClass: Content.AddClass + 2x Content.AddPassive"]
end
subgraph Core["Core internals"]
R["PassiveRegistry (classId to traits)"]
G["PassiveCombatGate (once-per-combat, master-only, transient)"]
P1["IncomingAttack patch (DamageCalculator damage-calc step)"]
P2["ConsumableDebuff patch (status-application step)"]
L["Localization (trait names + feedback strings)"]
end
subgraph Game["Game types (verified, kb_676160e5)"]
D1["DummyDamageInfo (Photon-serialized outcome)"]
D2["CharacterDummy.AddProfToDummy / proficiency application"]
CS["CharacterStats.m_CharacterClass"]
RS["CharacterDummy.ResetForCombat / CombatFinished"]
FB["SpawnHudTextRPC + AddCombatEventToActiveLogEntry"]
end
SC --> A
SC --> E
A --> R
A --> L
P1 --> R
P2 --> R
P1 --> G
P1 --- D1
P2 --- D2
P1 --- CS
P2 --- CS
G --- RS
P1 --- FB
P2 --- FB
Loading
Key Types
Type / Field
C# type
Description
Constraints
PassiveTrigger
public enum
IncomingAttack, ConsumableDebuff
Closed set in this spec
PassiveTraitDef
public class, readonly fields
The trait record returned by AddPassive
NOT a C# record (net35); immutable after construction
PassiveTraitDef.Key
string
modGuid + ":" + passiveId, registry key
Local only; never crosses Photon
PassiveTraitDef.ClassId
int
Bound class's deterministic id (from AddClass, id == array index)
Must match a registered FTK_playerGameStart
PassiveTraitDef.Trigger
PassiveTrigger
Which hook dispatches this trait
One of the closed set
PassiveTraitDef.DisplayName
string
Trait name for feedback strings
Registered via Localization
PassiveRegistry (internal)
static class
classId to List of PassiveTraitDef
Core-internal; not modder-visible
PassiveCombatGate (internal)
static class
Set of (characterId, passiveKey) fired this combat
Master-only, transient, never serialized
FTK_*DB Rows Touched
DB row type
Read / Write / New
Purpose
FTK_playerGameStart
New cloned row (via existing Content.AddClass)
The Innkeeper showcase class
FTK_proficiencyTable
Read
Identify enIntoxicate for the Iron Belly veto scope
(none)
Passive traits themselves: no DB row, no IdAllocator id
Binds a negation passive to classRow's deterministic id in the Core registry; registers displayName via Localization; returns the record for self-test. No DB write, no IdAllocator id. No chance parameter in this spec.
PassiveTrigger (public enum)
values: IncomingAttack, ConsumableDebuff
n/a
Closed set of trigger kinds; extended only by a future spec
File Placement and Ownership
The public Content facade physically lives in Core/Content.cs (namespace FTKModFramework.Core); "Layer: Content" in the FRs is the logical API label, not a folder.
No new FTK_*DB row and no IdAllocator id for passives. Nothing round-trips over Photon, so an id would be pure ceremony (and would pay the CheckAndMakeIndex rebuild cost for nothing).
No BehaviorKind.Passive, no behavior-class dispatch, no PassiveEffect abstraction, no options struct. The trigger kind fully determines the effect (site-intrinsic negation) for both showcases. The BehaviorKind seam is named for future specs and left unbuilt (Core/BehaviorKind.cs documents the closed-set rule).
No chance knob. Neither showcase is probabilistic; an untested probabilistic code path ships with the future proc spec that showcases it, not here.
PassiveRegistry, PassiveCombatGate, and both patches are Core-internal. The modder surface is exactly Content.AddPassive plus PassiveTrigger.
Trigger patches attach to the smallest verified master-side methods. Prefer mutating the computed outcome (the vanilla negate precedent fields) over return-false prefixes that skip whole methods.
Showcase content (Content/ folder) calls only the public Content.* surface; it never touches the registry, the gate, or the patches.
The vanilla CharacterSkills type is read-only precedent; the framework never writes any of its flags.
Edge Cases and Error Handling
Scenario
Expected Behaviour
No passive registered for the acting character's class
Patched paths behave observably identical to vanilla
Incoming attack computes to 0 damage
Charge not consumed; no feedback; vanilla flow untouched
Lethal incoming hit
Negated the same as any qualifying hit (differs from vanilla SteadFast, which is non-lethal-only)
Two IncomingAttack passives on one class
Deterministic registration-order evaluation; the first negation resolves the hit; each trait tracks its own charge
Splash/secondary sub-hit of an AoE strikes the Innkeeper
Constructed with _mainTarget == false: never negated, never consumes the charge
Gate is transient: the charge is restored on reload; accepted and documented limitation
Master-client migration mid-combat (co-op)
Gate state is lost on the new master; accepted and documented limitation (solo is the done bar)
Iron Belly vs an enemy-applied poison
Not vetoed; scope is consumable-sourced status ids only
A character with a shield whose class also has vanilla m_SteadFast semantics via gear skills
Vanilla SteadFast and framework Stonewall are independent systems; neither reads the other's state
Dependencies and Risks
Item
Impact
Likelihood
Mitigation
enIntoxicate's exact Category and per-item application sources unconfirmed
Iron Belly veto scoped wrong or rarely fires
Medium
Decompile follow-up before FR-5 implementation (kb_676160e5 maps the systems; the per-item mapping remains)
Damage-calc patch site is a private static method chain
Patch fragility across game updates
Medium
Patch the smallest confirmed site; mirror the vanilla negate precedent fields exactly; assert via self-test
Feedback text parity on non-master clients
Cosmetic divergence in co-op
Low
Feedback derives from the Photon-serialized outcome; SpawnHudTextRPC is RPC-based; solo is the done bar, co-op text parity re-checked in a later epic slice
Overworld drinks may apply FTK_characterModifier rows instead of the Intoxicated proficiency
Iron Belly silent in overworld contexts
Medium
Showcase scope is the combat Intoxicated status; the overworld modifier veto is explicitly deferred (open question)
Vanilla SteadFast name collision
Player confusion; accidental reuse of the vanilla flag
Goal: Ship Content.AddPassive, PassiveTrigger, PassiveRegistry, and the Innkeeper class with both traits registered but dormant (no combat behavior yet). Independently shippable: SELF-TEST proves the bindings; zero combat-state risk. Merge/self-test milestone only: the class description and flavor text must not advertise a trait until its trigger phase lands (a selectable class advertising passives that do nothing violates the never-silent brief). Tasks:
Innkeeper class via Content.AddClass with stats per design brief + two AddPassive calls (Content)
SELF-TEST assertions (FR-8) + build green + in-game SELF-TEST PASS
Phase 2: Stonewall (IncomingAttack trigger live)
Goal: The once-per-combat negate works and is visible in-game. Independently shippable: one complete trigger kind. Tasks:
Master-side damage-calc patch following the vanilla negate precedent (FR-3)
PassiveCombatGate + lifecycle resets (FR-4)
Feedback strings + surfaces (FR-6, Stonewall)
Solo in-game verification: first qualifying hit negated with feedback, second hit lands, next combat resets
Phase 3: Iron Belly (ConsumableDebuff trigger live)
Goal: The scoped consumable-debuff veto works and is visible in-game. Completes the spec. Tasks:
Decompile follow-up (pre-implementation gate): DONE, kb_7e48c334; outcome folded into the amended FR-5
Master-side scoped status veto (FR-5)
Feedback strings + surfaces (FR-6, Iron Belly)
Solo in-game verification: Intoxicated suppressed with feedback; enemy poison still lands
Open Questions
RESOLVED (kb_7e48c334): enIntoxicate is applied by no consumable; the consumable statuses flow through EncounterSession.CombatPartyBuff. FR-5 was amended accordingly (path-scoped veto of enPoison1/enConfuse).
Does any character-sheet UI surface exist to display class passives and a Stonewall available/spent marker? The design brief wants pre-combat legibility; if no slot exists, the marker is out of scope for this spec and the traits are documented in the class description text instead. [UNVERIFIED, needs game-decompile-analyst]
Should Stonewall gain reserve nerf levers (physical-only, or a damage cap) if playtest shows boss-opener trivialization? Design brief holds these in reserve; not built now.
Which existing FTK_hitEffect visual, if any, suits a Stonewall brace/deflect flash as a P2 polish item? (The P0 feedback surfaces are the log line and floating text.)
Parent Epic
Parent epic: #77 (FTK2 ports, capability-first: passive traits, status effects, party procs, summons). This is the first child spec and feeds the status-effect and proc specs.
Executive Summary
This spec adds the framework's first class-innate passive capability: a mod author binds a passive trait (trigger + negation effect) to a custom class with one line of code, and the framework wires the combat triggers. The capability is proven by one new showcase class, the Innkeeper, carrying two FTK2-inspired ports: Stonewall (negate the first damaging incoming attack, once per combat; ported from FTK2 Blacksmith's Steadfast and renamed to avoid the vanilla SteadFast skill collision) and Iron Belly (always-on veto of drink/consumable debuff statuses; ported from FTK2 Friar). Passives carry no FTK_*DB row and no IdAllocator id: they are a framework-side registry keyed by the class's already-deterministic id, which makes co-op determinism inherited rather than engineered.
Problem Statement
The framework can add classes, items, weapons, combat actions, and enemies, but it has no class-innate passive path: proficiencies attach only to weapons (Weapon.m_ProficiencyEffects) or enemies, and both dispatch on OUTGOING actions. Passives fire on INCOMING events (being attacked, receiving a debuff), which the game never routes through the proficiency table.
The vanilla game models class passives as CharacterSkills, a closed set of ~26 boolean flags on FTK_playerGameStart.m_CharacterSkills (verified; see kb_676160e5). Mods cannot add flags to that game type, so a framework-side registry keyed by class id is the moddable analogue. Epic #77's capability-first plan requires this passive plumbing before the status-effect and proc specs can build on it.
Goals and Success Metrics
Functional Requirements
FR-1: Passive registry and data model
Description: A Core-internal registry maps a class id to its passive trait definitions. A trait definition (PassiveTraitDef) is a public immutable plain class with readonly fields (NOT a C# record; net35): registry key (modGuid + ":" + passiveId), bound class id, trigger kind, display name. There is no chance field in this spec (see Simplicity Constraints). The registry is populated at content-registration time and read by the trigger patches. No FTK_*DB row is created or mutated; no IdAllocator id is allocated.
Acceptance Criteria:
Priority: P0
Layer: Core
FR-2: Content.AddPassive public API
Description: One public method and one public enum, exactly as in the API surface table below. OncePerCombat is not a parameter: it is the defined semantic of the IncomingAttack negation trigger (user decision on the epic). A chance knob is deliberately excluded: neither showcase uses one, and an untested probabilistic path is exactly the extension point this framework refuses to ship; the future proc spec (epic child 3) reintroduces it with a showcase that exercises it. The method registers the display name via Localization and returns the trait record for self-test use.
Acceptance Criteria:
Priority: P0
Layer: Content (public API surface; physical placement per File Placement and Ownership below)
FR-3: IncomingAttack trigger (Stonewall plumbing)
Description: A master-side Harmony patch on the damage-calculation step (DamageCalculator._calcDamage / DummyDamageInfo construction, verified in kb_676160e5) negates the first qualifying incoming attack against a character whose class holds an IncomingAttack passive. Negation follows the vanilla Petrified/Protected/Shielded precedent: damage and crit damage set to 0, new health set to victim health at start, proficiency application suppressed. A qualifying attack is a MAIN-TARGET hit (the DummyDamageInfo constructed with _mainTarget == true) whose computed damage at the negate site (the constructed m_Damage plus m_CritDamage) is greater than 0; splash/secondary sub-hits neither qualify nor consume the charge. The once-per-combat charge is consumed only by a qualifying attack. The negate applies regardless of damage type and lethality. The decision runs only on the master client; the Photon-serialized DummyDamageInfo carries the negated outcome to all clients. Passives are class-innate only: the lookup keys strictly on CharacterStats.m_CharacterClass (the base class id); gear skills and FTK_characterModifier rows can never grant a framework passive (deliberate scoping decision, in contrast to vanilla CharacterSkills.AddSkills OR-merging).
Acceptance Criteria:
Priority: P0
Layer: Core
FR-4: Once-per-combat gate and combat lifecycle reset
Description: A master-only, transient, never-serialized gate records which (character, passive) pairs have fired this combat. It resets on combat start and clears on combat end, using the verified lifecycle hooks (CharacterDummy.ResetForCombat postfix for start, CharacterDummy.CombatFinished for end; kb_676160e5).
Acceptance Criteria:
Priority: P0
Layer: Core
FR-5: ConsumableDebuff trigger (Iron Belly plumbing)
Description (AMENDED 2026-07-02 after the Phase 3 decompile gate, ninum kb_7e48c334, and a game-designer decision): a master-side veto of HARMFUL consumable-path status application to a character whose class holds a ConsumableDebuff passive. The gate found that enIntoxicate is applied by NO consumable (its only source is enemy tavern-brawl attack data, exactly the source Iron Belly must not block), and that all consumable/orb statuses flow through one dedicated path, EncounterSession.CombatPartyBuff, which enemy attacks never use. Amended scope (designer Option A): on the CombatPartyBuff path only, strip the harmful statuses (the vanilla drink downsides: enPoison1 from poisoned mead and poisoned mushroom, enConfuse from rum) targeted at the passive holder; beneficial statuses on the same path (protect, resistances, the buff halves of the same drinks) still apply. Enemy-path statuses, including the same enPoison1 and enConfuse ids and enIntoxicate, are untouched. The veto is always on (no once-per-combat gate). Never a whole ProficiencyBase.Category immunity.
Acceptance Criteria:
Priority: P0
Layer: Core
FR-6: Player-facing feedback (never silent)
Description: Every passive proc emits two feedback surfaces: a combat-log entry (EncounterSession.AddCombatEventToActiveLogEntry) and a floating HUD text on the character (CharacterDummy.SpawnHudTextRPC), both verified reachable from the trigger paths (kb_676160e5). Localization mechanism (required, since passives have no DB row or vanilla getter to postfix): the showcase registration registers the trait name and the two feedback templates in the existing Localization string map under synthetic keys derived from the passive key (for example passiveKey + ":hud" and passiveKey + ":log"), and the Core patch reads them back via the existing TryGetName path. The English strings therefore live in the showcase content registration, never in the Core patch bodies. Emission site: feedback is emitted from the master-only decision site; SpawnHudTextRPC carries the floating text to all clients by RPC, and combat-log parity on non-master clients is the deferred item recorded in the risk table (solo is the done bar). Feedback derives from the networked outcome (the mutated DummyDamageInfo / suppressed status), so what the player sees always matches what mechanically happened.
Acceptance Criteria:
Priority: P0
Layer: Core
FR-7: Innkeeper showcase class
Description: One new class, the Innkeeper, created via the existing Content.AddClass path (clone a tanky vanilla template such as Blacksmith for skill set and skinset scaffolding), with a Toughness/Vitality-forward, low-offense, low-Speed stat identity per the design brief, and both showcase passives bound via Content.AddPassive. Localized class name, trait names, and flavor text (design brief provides draft copy). All vanilla classes are untouched.
Acceptance Criteria:
Priority: P0
Layer: Content
FR-8: Self-test coverage
Description: The framework's SELF-TEST suite gains assertions for this capability, following the existing self-test pattern.
Acceptance Criteria:
Priority: P0
Layer: Core
Non-Functional Requirements
NFR-1: Runtime and language compatibility
Category: Compatibility
Requirement: All code targets net35 (Mono, Unity 2017.2.2p2). No APIs or language features that postdate .NET 3.5.
Rationale: The game's Mono runtime hard-fails on newer surface; this is the framework's standing constraint.
NFR-2: Co-op determinism
Category: Reliability
Requirement: All passive decisions (negation, chance roll, gate) execute only on the master client. Outcomes reach other clients exclusively through existing networked game state (the Photon-serialized DummyDamageInfo, the status application result). Passives introduce zero new networked identities and zero client-local RNG.
Rationale: Epic #77 invariant; mirrors the proven master-grants-broadcasts pattern of ThiefStealProficiency.
NFR-3: Vanilla parity when unused
Category: Reliability
Requirement: With no registered passives, every patched code path produces behavior observably identical to vanilla. Registration remains one-shot behind the existing _done-guarded TableManager.Initialize postfix. The two trigger patches carry NO one-shot guard (they must run on every damage calculation / status application); their invariant is statelessness: no static mutable state except the master-only transient gate, and an early return to the vanilla outcome when the victim's class has no matching passive.
Rationale: The framework must be a no-op for content that is not installed; regressions in vanilla combat are unacceptable. Idempotency means the right thing per patch kind: one-shot for registration, parity-preserving statelessness for per-event triggers.
NFR-4: Closed extension surface
Category: Maintainability
Requirement: PassiveTrigger is a closed two-value set in this spec. New trigger kinds, non-negation effects, and a BehaviorKind.Passive dispatch kind are future specs (the seam is named here but not built).
Rationale: Two showcases whose effect is site-intrinsic negation do not justify an effect abstraction; premature extension points are the framework's documented anti-pattern.
NFR-5: No per-frame cost
Category: Performance
Requirement: Passive lookups occur only on damage-calculation and status-application events (dictionary lookup by class id). No per-frame work, no polling.
Rationale: Combat responsiveness must be unaffected on 2017-era hardware targets.
NFR-6: Type accessibility and immutability
Category: Maintainability
Requirement: PassiveTraitDef is a PUBLIC plain class with readonly fields (it is returned by the public AddPassive; not a C# 9 record, net35). PassiveRegistry and PassiveCombatGate are internal. No type in this feature exposes mutable static state to modders.
Rationale: The framework has prior drift between "internal" framing and public declarations (kb_771834da); making accessibility explicit prevents repeating it.
Technical Architecture
Component Diagram
flowchart TD subgraph PublicAPI["Public API (modder-facing)"] A["Content.AddPassive"] E["enum PassiveTrigger"] end subgraph Showcase["Showcase content (Content/)"] SC["InnkeeperClass: Content.AddClass + 2x Content.AddPassive"] end subgraph Core["Core internals"] R["PassiveRegistry (classId to traits)"] G["PassiveCombatGate (once-per-combat, master-only, transient)"] P1["IncomingAttack patch (DamageCalculator damage-calc step)"] P2["ConsumableDebuff patch (status-application step)"] L["Localization (trait names + feedback strings)"] end subgraph Game["Game types (verified, kb_676160e5)"] D1["DummyDamageInfo (Photon-serialized outcome)"] D2["CharacterDummy.AddProfToDummy / proficiency application"] CS["CharacterStats.m_CharacterClass"] RS["CharacterDummy.ResetForCombat / CombatFinished"] FB["SpawnHudTextRPC + AddCombatEventToActiveLogEntry"] end SC --> A SC --> E A --> R A --> L P1 --> R P2 --> R P1 --> G P1 --- D1 P2 --- D2 P1 --- CS P2 --- CS G --- RS P1 --- FB P2 --- FBKey Types
FTK_*DB Rows Touched
Content.* API Surface
File Placement and Ownership
The public Content facade physically lives in Core/Content.cs (namespace FTKModFramework.Core); "Layer: Content" in the FRs is the logical API label, not a folder.
Simplicity Constraints
Edge Cases and Error Handling
Dependencies and Risks
Implementation Phases
Phase 1: Authoring surface and registry (dormant)
Goal: Ship Content.AddPassive, PassiveTrigger, PassiveRegistry, and the Innkeeper class with both traits registered but dormant (no combat behavior yet). Independently shippable: SELF-TEST proves the bindings; zero combat-state risk. Merge/self-test milestone only: the class description and flavor text must not advertise a trait until its trigger phase lands (a selectable class advertising passives that do nothing violates the never-silent brief).
Tasks:
Phase 2: Stonewall (IncomingAttack trigger live)
Goal: The once-per-combat negate works and is visible in-game. Independently shippable: one complete trigger kind.
Tasks:
Phase 3: Iron Belly (ConsumableDebuff trigger live)
Goal: The scoped consumable-debuff veto works and is visible in-game. Completes the spec.
Tasks:
Open Questions