From 479f77b9c52d849bbe794ce4139d67dc17872ab9 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:14:41 -0700 Subject: [PATCH] adventure: T6 Inversion Stitch (Seamstress) + boss re-strengthen (P8) Second in-combat Layer-2 mechanic, reusing Amendment's round-end seam. In the Seamstress's phase 2 (<=35% HP) the room sews inside-out in a repeating warn(1)->sting(2) cadence, telegraphed one round ahead: during a pulse player heals (self + ally) invert to damage, floored at 1 HP. State inversionActive/inversionTelegraph round-trips through CombatStatuses so a suspend/resume can't drop or double a pulse. Sim surfaced that unplace is reach-bound (only ~37% reach the boss, then cleared her ~98%), so the mechanic alone is sub-noise. Re-strengthened the Seamstress, the weakest T6 boss (over-softened by P7 for a zone lift that never came): HP 385->460, Atk 39->45, Needle Rain proc 0.40->0.45. Sim-validated at fighter zone 37.5% (in range), and a real boss fight now instead of a victory lap. Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s --- internal/plugin/combat_engine.go | 10 ++ internal/plugin/combat_narrative.go | 27 ++++ internal/plugin/combat_session.go | 10 ++ internal/plugin/combat_turn_engine.go | 56 +++++-- internal/plugin/postgame_bestiary.go | 4 +- internal/plugin/postgame_boss_hooks.go | 71 ++++++++ internal/plugin/postgame_boss_hooks_test.go | 170 +++++++++++++++++++- 7 files changed, 332 insertions(+), 16 deletions(-) diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index 78cec7b..6eb93c9 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -452,6 +452,16 @@ type combatState struct { enemyRewindHP int enemyRewindUsed bool + // Inversion Stitch (T6 Seamstress) โ€” an in-combat Layer-2 hook resolved at + // round end by applyBossInCombatRoundEnd, live only in the boss's phase 2. + // inversionActive is the number of rounds the room stays sewn inside-out + // (heals sting instead of mend, gated in stepPlayerActionEffect); it counts + // down one per round end. inversionTelegraph warns the round before a pulse + // activates, so a player who watches the tell can hold their heals. Both + // round-trip through CombatStatuses; zero/false for every non-Seamstress fight. + inversionActive int + inversionTelegraph bool + round int events []CombatEvent diff --git a/internal/plugin/combat_narrative.go b/internal/plugin/combat_narrative.go index ddb0fc6..7bc466c 100644 --- a/internal/plugin/combat_narrative.go +++ b/internal/plugin/combat_narrative.go @@ -337,6 +337,12 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul return pickRand(narrativeAmendmentRewind) case "midnight_toll": return fmt.Sprintf(pickRand(narrativeMidnightToll), e.Damage) + case "inversion_telegraph": + return pickRand(narrativeInversionTelegraph) + case "inversion_stitch": + return pickRand(narrativeInversionStitch) + case "heal_inverted": + return fmt.Sprintf(pickRand(narrativeHealInverted), e.Damage) case "stat_drain": return fmt.Sprintf(pickRand(narrativeStatDrain), e.Damage) case "debuff": @@ -792,6 +798,27 @@ var narrativeMidnightToll = []string{ "๐Ÿ”” The hour is nearly spent, and so is its patience; each blow lands with more weight now. (+%d attack)", } +// narrativeInversionTelegraph fires one round before an Inversion Stitch pulse โ€” +// the Seamstress's tell. It reads as a warning so a player learns to hold heals. +var narrativeInversionTelegraph = []string{ + "๐Ÿงต The Seamstress draws a thread taut and the room *shivers* โ€” walls flexing toward inside-out. Whatever you were about to mend, hold it. (next round, healing turns against you)", + "๐Ÿงต A seam in the air puckers. The geometry is about to flip; a cure cast into it will run backward. (inversion incoming next round)", +} + +// narrativeInversionStitch fires when a pulse activates โ€” the room is sewn +// inside-out and healing now wounds for the pulse's duration. +var narrativeInversionStitch = []string{ + "๐Ÿงต The stitch pulls through. The room is inside-out now โ€” for a moment, to heal is to hurt.", + "๐Ÿงต Everything turns wrong-way-round. Mending and wounding have swapped ends of the needle.", +} + +// narrativeHealInverted fires each time a heal lands during an active pulse: the +// cure runs backward and stings instead. Teaches the mechanic on the spot. +var narrativeHealInverted = []string{ + "๐Ÿงต The heal runs backward through the inverted room โ€” the cure opens the wound it meant to close. (%d damage)", + "๐Ÿงต Healing turns against its target in the sewn-inside-out air; the mend lands as a sting. (%d damage)", +} + var narrativeStatDrain = []string{ "๐Ÿฉธ The enemy saps your strength โ€” your swings feel heavier, weaker. (-%d hit damage)", "๐Ÿฉธ Something drains out of your limbs. Your hits won't bite as deep now. (-%d damage)", diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index a565bc3..b883c8d 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -243,6 +243,16 @@ type CombatStatuses struct { // existing EnemyAtkBuff, so it needs no field of its own. EnemyRewindHP int `json:"enemy_rewind_hp,omitempty"` EnemyRewindUsed bool `json:"enemy_rewind_used,omitempty"` + + // Inversion Stitch (Tier-6 postgame, The Seamstress). An in-combat Layer-2 + // hook resolved at round end (applyBossInCombatRoundEnd), live only in the + // boss's phase 2. InversionActive is the rounds-remaining of the inside-out + // pulse during which player heals sting instead of mend (gated in + // stepPlayerActionEffect); InversionTelegraph is the one-round warning before + // a pulse activates. Both round-trip through combatState so a suspend/resume + // can't lose or replay a pulse mid-fight. Zero/false for every other enemy. + InversionActive int `json:"inversion_active,omitempty"` + InversionTelegraph bool `json:"inversion_telegraph,omitempty"` } // applyBuffDelta folds one resolved buff (the result of a !cast / !consume diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go index 5f8d537..ecdfc85 100644 --- a/internal/plugin/combat_turn_engine.go +++ b/internal/plugin/combat_turn_engine.go @@ -373,7 +373,10 @@ func resumeTurnEngine(sess *CombatSession, players []*Combatant, enemy *Combatan // Amendment (T6 Custodian) โ€” round-3 snapshot + once-only rewind. enemyRewindHP: sess.Statuses.EnemyRewindHP, enemyRewindUsed: sess.Statuses.EnemyRewindUsed, - rng: rng, + // Inversion Stitch (T6 Seamstress) โ€” phase-2 heal-inverting pulses. + inversionActive: sess.Statuses.InversionActive, + inversionTelegraph: sess.Statuses.InversionTelegraph, + rng: rng, } order := turnOrder(sess, sess.Round, players, enemy) sess.Statuses.TurnIdx = turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase) @@ -573,10 +576,22 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) { st.enemyHP = max(0, st.enemyHP-enemyDmg) } if eff.PlayerHeal > 0 { - // Respect any max_hp_drain monster ability โ€” a drained player can't be - // healed back past the lowered ceiling. - hpCap := max(1, st.hpMax-st.maxHPDrain) - st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal) + if st.inversionActive > 0 { + // Inversion Stitch (T6 Seamstress phase 2): the room is sewn inside-out, + // so the cure lands as a wound. Floored at 1 so a player is never killed + // by their own heal โ€” the Seamstress's own blows do the finishing; the + // sting just denies the sustain and softens the seat for them. + st.playerHP = max(1, st.playerHP-eff.PlayerHeal) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "heal_inverted", + Damage: eff.PlayerHeal, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + } else { + // Respect any max_hp_drain monster ability โ€” a drained player can't be + // healed back past the lowered ceiling. + hpCap := max(1, st.hpMax-st.maxHPDrain) + st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal) + } } // ยง1 โ€” heal somebody else. The caster's cursor stays where it is; only the // target's HP moves. @@ -587,14 +602,27 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) { // path depends on. Healing keeps people up; it does not bring them back. if eff.AllyHeal > 0 && eff.AllySeat >= 0 && eff.AllySeat < len(st.actors) { if tgt := st.actors[eff.AllySeat]; tgt.playerHP > 0 { - cap := max(1, tgt.hpMax-tgt.maxHPDrain) - before := tgt.playerHP - tgt.playerHP = min(cap, tgt.playerHP+eff.AllyHeal) - st.events = append(st.events, CombatEvent{ - Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: "ally_heal", - Damage: tgt.playerHP - before, PlayerHP: tgt.playerHP, EnemyHP: st.enemyHP, - Seat: eff.AllySeat, Desc: eff.Label, - }) + if st.inversionActive > 0 { + // Inversion Stitch: the ally-heal wounds the friend it was meant to + // mend. Floored at 1 like the self-heal sting above โ€” the sting denies + // the sustain, it does not kill. + before := tgt.playerHP + tgt.playerHP = max(1, tgt.playerHP-eff.AllyHeal) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "heal_inverted", + Damage: before - tgt.playerHP, PlayerHP: tgt.playerHP, EnemyHP: st.enemyHP, + Seat: eff.AllySeat, Desc: eff.Label, + }) + } else { + cap := max(1, tgt.hpMax-tgt.maxHPDrain) + before := tgt.playerHP + tgt.playerHP = min(cap, tgt.playerHP+eff.AllyHeal) + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: "ally_heal", + Damage: tgt.playerHP - before, PlayerHP: tgt.playerHP, EnemyHP: st.enemyHP, + Seat: eff.AllySeat, Desc: eff.Label, + }) + } } } // Arm / replace the concentration aura. A new concentration cast overwrites @@ -952,6 +980,8 @@ func (te *turnEngine) commit() { s.EnemyAtkBuff = st.enemyAtkBuff s.EnemyRewindHP = st.enemyRewindHP s.EnemyRewindUsed = st.enemyRewindUsed + s.InversionActive = st.inversionActive + s.InversionTelegraph = st.inversionTelegraph te.sess.TurnLog = append(te.sess.TurnLog, st.events...) } diff --git a/internal/plugin/postgame_bestiary.go b/internal/plugin/postgame_bestiary.go index 85db0d9..cae1ffc 100644 --- a/internal/plugin/postgame_bestiary.go +++ b/internal/plugin/postgame_bestiary.go @@ -167,9 +167,9 @@ var _ = func() bool { }, "boss_seamstress": { ID: "boss_seamstress", Name: "The Seamstress", - CR: 27, HP: 385, AC: 21, Attack: 39, AttackBonus: 12, Speed: 14, + CR: 27, HP: 460, AC: 21, Attack: 45, AttackBonus: 12, Speed: 14, BlockRate: 0.15, - Ability: &MonsterAbility{Name: "Needle Rain", Phase: "decisive", ProcChance: 0.40, Effect: "aoe"}, + Ability: &MonsterAbility{Name: "Needle Rain", Phase: "decisive", ProcChance: 0.45, Effect: "aoe"}, XPValue: 100000, Notes: "Unplace boss. A corrupted celestial sewing herself into the tear; half of her is on the other side. Phase 2 below 35% HP. (Layer 2: Inversion Stitch โ€” healing and damage swap direction on her in telegraphed pulses.)", }, diff --git a/internal/plugin/postgame_boss_hooks.go b/internal/plugin/postgame_boss_hooks.go index f1cc9ff..b928315 100644 --- a/internal/plugin/postgame_boss_hooks.go +++ b/internal/plugin/postgame_boss_hooks.go @@ -228,6 +228,8 @@ func applyBossInCombatRoundEnd(st *combatState, bossID string, enemyMaxHP int) { switch bossID { case "boss_custodian": applyAmendment(st, enemyMaxHP) + case "boss_seamstress": + applyInversionStitch(st, enemyMaxHP) } } @@ -261,3 +263,72 @@ func applyAmendment(st *combatState, enemyMaxHP int) { }) } } + +// โ”€โ”€ Inversion Stitch โ€” The Seamstress (The Unplace) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// +// The Seamstress is sewing the tear shut from the inside, and once she's far +// enough into the work (phase 2) she starts turning the room inside-out around +// the party: for a two-round pulse, healing runs the wrong direction โ€” every +// cure lands as a wound (gated in stepPlayerActionEffect, floored at 1 HP so a +// player is never killed by their own healer, only softened for the Seamstress's +// own blows to finish). The catch is that she can't invert the room without a +// visible tell: each pulse is telegraphed one full round ahead, so a player who +// reads it holds their heals and waits it out. The autopilot that drives the sim +// does not read tells โ€” so the headless sweep sees the mechanic at its harshest, +// the way an undisciplined party would, and a real player who respects the +// warning fares better. That gap IS the difficulty lever, the same way the Greed +// Tax and the Phylactery Verses are prod-only player-agency levers. +// +// It is the second in-combat Layer-2 mechanic, so it rides the same round-end +// seam Amendment opened. Its state (inversionActive countdown + inversionTelegraph +// warning) round-trips through CombatStatuses so a suspend/resume can't drop or +// double a pulse. A no-op outside the Seamstress's phase 2. +const ( + // seamstressPhaseTwoFrac must track The Seamstress's PhaseTwoAt in + // postgame_zone_defs.go (0.35): the inversion only starts once she is sewn + // deep enough into the tear. Kept local for the same reason as + // custodianPhaseTwoFrac โ€” the round-end hook resolves off the session, not the + // ZoneDefinition, so the threshold isn't otherwise in reach here. + seamstressPhaseTwoFrac = 0.35 + + // inversionPulseRounds is how many rounds each inside-out pulse lasts once it + // activates (heals sting for this many player-rounds). + inversionPulseRounds = 2 +) + +// applyInversionStitch drives the Seamstress's phase-2 inversion cadence at round +// end: telegraph a pulse one round ahead, activate it for inversionPulseRounds, +// then re-telegraph the next โ€” giving a repeating warn(1) โ†’ sting(2) rhythm the +// player can play around. No-op until the boss is in phase 2. +func applyInversionStitch(st *combatState, enemyMaxHP int) { + // Layer-1 fight until the Seamstress is sewn into her own phase 2. + phaseTwo := int(seamstressPhaseTwoFrac * float64(enemyMaxHP)) + if st.enemyHP > phaseTwo { + return + } + // An active pulse counts down one round per round end. While it stays above + // zero the next round's heals still sting; when it lapses this round, fall + // through so a fresh telegraph is scheduled immediately. + if st.inversionActive > 0 { + st.inversionActive-- + if st.inversionActive > 0 { + return + } + } + // A telegraphed pulse activates: the room turns inside-out for the pulse. + if st.inversionTelegraph { + st.inversionActive = inversionPulseRounds + st.inversionTelegraph = false + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "inversion_stitch", + PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + return + } + // Otherwise warn: the next round's pulse is one round out. + st.inversionTelegraph = true + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "inversion_telegraph", + PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) +} diff --git a/internal/plugin/postgame_boss_hooks_test.go b/internal/plugin/postgame_boss_hooks_test.go index 24e0e6f..ce02602 100644 --- a/internal/plugin/postgame_boss_hooks_test.go +++ b/internal/plugin/postgame_boss_hooks_test.go @@ -1,6 +1,9 @@ package plugin -import "testing" +import ( + "strings" + "testing" +) func TestGreedTaxAttack(t *testing.T) { cases := []struct { @@ -337,6 +340,148 @@ func TestApplyBossInCombatRoundEnd_DispatchesOnlyCustodian(t *testing.T) { } } +// โ”€โ”€ Inversion Stitch (Seamstress) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +// seamstressState builds a fully-formed combatState (embedded actor + roster) so +// the event helpers that read st.playerHP resolve โ€” a bare struct-literal state +// has a nil actor cursor. +func seamstressState(t *testing.T, enemyHP, enemyMaxHP int) *combatState { + t.Helper() + sess := turnSession(CombatPhaseRoundEnd, 10000, enemyHP) + sess.EnemyID = "boss_seamstress" + p := basePlayer() + e := baseEnemy() + e.Stats.MaxHP = enemyMaxHP + te := resumeTurnEngine(sess, []*Combatant{&p}, &e, combatSessionStepRNG(sess, enemySeat)) + te.st.enemyHP = enemyHP + return te.st +} + +func TestApplyInversionStitch_NoOpAbovePhaseTwo(t *testing.T) { + // Above the 0.35*500=175 phase-two line the room stays right-side-out. + st := seamstressState(t, 300, 500) + applyInversionStitch(st, 500) + if st.inversionTelegraph || st.inversionActive != 0 || len(st.events) != 0 { + t.Errorf("above phase two: telegraph=%v active=%d events=%d, want all zero", + st.inversionTelegraph, st.inversionActive, len(st.events)) + } +} + +func TestApplyInversionStitch_Cadence(t *testing.T) { + // Below the phase-two line the room cycles warn(1) โ†’ sting(pulse) โ†’ warn(1)โ€ฆ + st := seamstressState(t, 150, 500) // 150 < 0.35*500 = 175 + + // Round end 1: no pulse yet, so telegraph the first one. + applyInversionStitch(st, 500) + if !st.inversionTelegraph || st.inversionActive != 0 { + t.Fatalf("after warn: telegraph=%v active=%d, want telegraph=true active=0", + st.inversionTelegraph, st.inversionActive) + } + if countEvents(st.events, "inversion_telegraph") != 1 { + t.Fatalf("telegraph events = %d, want 1", countEvents(st.events, "inversion_telegraph")) + } + + // Round end 2: the telegraphed pulse activates for its full duration. + applyInversionStitch(st, 500) + if st.inversionActive != inversionPulseRounds || st.inversionTelegraph { + t.Fatalf("after activate: active=%d telegraph=%v, want active=%d telegraph=false", + st.inversionActive, st.inversionTelegraph, inversionPulseRounds) + } + if countEvents(st.events, "inversion_stitch") != 1 { + t.Fatalf("stitch events = %d, want 1", countEvents(st.events, "inversion_stitch")) + } + + // The pulse counts down one round per round end, staying active until it lapses. + for r := inversionPulseRounds - 1; r >= 1; r-- { + applyInversionStitch(st, 500) + if st.inversionActive != r { + t.Fatalf("mid-pulse active = %d, want %d", st.inversionActive, r) + } + } + + // The round the pulse lapses (active 1โ†’0) a fresh warn is scheduled in the + // same round end โ€” the repeating rhythm, never two silent rounds in a row. + applyInversionStitch(st, 500) + if st.inversionActive != 0 || !st.inversionTelegraph { + t.Fatalf("after pulse: active=%d telegraph=%v, want active=0 telegraph=true", + st.inversionActive, st.inversionTelegraph) + } + if countEvents(st.events, "inversion_telegraph") != 2 { + t.Fatalf("telegraph events = %d, want 2 (the next pulse warned)", + countEvents(st.events, "inversion_telegraph")) + } +} + +func TestApplyBossInCombatRoundEnd_DispatchesSeamstress(t *testing.T) { + // A non-Seamstress boss at a phase-two HP is untouched. + st := seamstressState(t, 150, 500) + applyBossInCombatRoundEnd(st, "boss_aurvandryx", 500) + if st.inversionTelegraph || len(st.events) != 0 { + t.Errorf("non-Seamstress boss triggered inversion (telegraph=%v events=%d)", + st.inversionTelegraph, len(st.events)) + } + // The Seamstress id resolves the hook. + applyBossInCombatRoundEnd(st, "boss_seamstress", 500) + if !st.inversionTelegraph { + t.Error("boss_seamstress did not schedule the inversion telegraph") + } +} + +// TestInversionStitch_HealsSting drives a real round with an active pulse seeded +// and confirms both the self-heal and the ally-heal land as wounds, floored at 1. +func TestInversionStitch_HealsSting(t *testing.T) { + setupEmptyTestDB(t) + p := &AdventurePlugin{} + + t.Run("ally heal stings the friend", func(t *testing.T) { + sess := startAllyHealFight(t, p, 50) // friend hurt to 50/100 + sess.Statuses.InversionActive = 1 // room is inside-out this round + healer, friend := basePlayer(), basePlayer() + ct := &combatTurn{sess: sess, players: []*Combatant{&healer, &friend}, + enemy: &Combatant{Name: "dummy", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}}, + seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))} + before := sess.seatHP(1) + + events, err := p.driveCombatRound(ct, PlayerAction{Kind: ActionCast, + Effect: &turnActionEffect{Label: "Cure", Action: "spell_cast", AllyHeal: 30, AllySeat: 1}}) + if err != nil { + t.Fatal(err) + } + if got := sess.seatHP(1); got >= before { + t.Errorf("friend HP = %d (was %d) โ€” an inverted heal must wound, not mend", got, before) + } + if countEvents(events, "heal_inverted") != 1 { + t.Errorf("heal_inverted events = %d, want 1", countEvents(events, "heal_inverted")) + } + }) + + t.Run("self heal stings the acting seat, floored at 1", func(t *testing.T) { + // A self-heal (PlayerHeal, no AllySeat) lands on whichever seat is acting; + // assert the inversion fired (heal_inverted) and no seat was driven to 0 โ€” + // the sting denies sustain, it never kills. + sess := startAllyHealFight(t, p, 100) + sess.Statuses.InversionActive = 1 + healer, friend := basePlayer(), basePlayer() + ct := &combatTurn{sess: sess, players: []*Combatant{&healer, &friend}, + enemy: &Combatant{Name: "dummy", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}}, + seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))} + + events, err := p.driveCombatRound(ct, PlayerAction{Kind: ActionCast, + Effect: &turnActionEffect{Label: "Cure Self", Action: "spell_cast", PlayerHeal: 30}}) + if err != nil { + t.Fatal(err) + } + if countEvents(events, "heal_inverted") != 1 { + t.Errorf("heal_inverted events = %d, want 1 โ€” the self-heal did not invert", countEvents(events, "heal_inverted")) + } + for seat := 0; seat < 2; seat++ { + if got := sess.seatHP(seat); got < 1 { + t.Errorf("seat %d HP = %d โ€” the sting must floor at 1, never kill", seat, got) + } + } + }) +} + func TestTurnEngine_AmendmentRoundTrips(t *testing.T) { // Drive a real Custodian round-end and confirm the once-only rewind is // captured through CombatStatuses (a suspend/resume can't replay it). @@ -364,3 +509,26 @@ func TestTurnEngine_AmendmentRoundTrips(t *testing.T) { t.Errorf("amendment_rewind events = %d, want 1", countEvents(sess.TurnLog, "amendment_rewind")) } } + +func TestTurnEngine_InversionRoundTrips(t *testing.T) { + // Drive a real Seamstress round-end in phase 2 and confirm the scheduled + // telegraph persists through CombatStatuses (a suspend/resume keeps the cadence). + sess := turnSession(CombatPhaseRoundEnd, 10000, 150) // 150 < 0.35*500 = 175 + sess.EnemyID = "boss_seamstress" + sess.EnemyHPMax = 500 + p := basePlayer() + e := baseEnemy() + e.Stats.MaxHP = 500 + te := resumeTurnEngine(sess, []*Combatant{&p}, &e, combatSessionStepRNG(sess, enemySeat)) + if _, err := te.step(PlayerAction{}); err != nil { + t.Fatal(err) + } + te.commit() + + if !sess.Statuses.InversionTelegraph { + t.Error("InversionTelegraph did not persist through commit") + } + if countEvents(sess.TurnLog, "inversion_telegraph") != 1 { + t.Errorf("inversion_telegraph events = %d, want 1", countEvents(sess.TurnLog, "inversion_telegraph")) + } +}