mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-18 01:42:42 +00:00
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
This commit is contained in:
@@ -452,6 +452,16 @@ type combatState struct {
|
|||||||
enemyRewindHP int
|
enemyRewindHP int
|
||||||
enemyRewindUsed bool
|
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
|
round int
|
||||||
events []CombatEvent
|
events []CombatEvent
|
||||||
|
|
||||||
|
|||||||
@@ -337,6 +337,12 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
|
|||||||
return pickRand(narrativeAmendmentRewind)
|
return pickRand(narrativeAmendmentRewind)
|
||||||
case "midnight_toll":
|
case "midnight_toll":
|
||||||
return fmt.Sprintf(pickRand(narrativeMidnightToll), e.Damage)
|
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":
|
case "stat_drain":
|
||||||
return fmt.Sprintf(pickRand(narrativeStatDrain), e.Damage)
|
return fmt.Sprintf(pickRand(narrativeStatDrain), e.Damage)
|
||||||
case "debuff":
|
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)",
|
"🔔 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{
|
var narrativeStatDrain = []string{
|
||||||
"🩸 The enemy saps your strength — your swings feel heavier, weaker. (-%d hit damage)",
|
"🩸 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)",
|
"🩸 Something drains out of your limbs. Your hits won't bite as deep now. (-%d damage)",
|
||||||
|
|||||||
@@ -243,6 +243,16 @@ type CombatStatuses struct {
|
|||||||
// existing EnemyAtkBuff, so it needs no field of its own.
|
// existing EnemyAtkBuff, so it needs no field of its own.
|
||||||
EnemyRewindHP int `json:"enemy_rewind_hp,omitempty"`
|
EnemyRewindHP int `json:"enemy_rewind_hp,omitempty"`
|
||||||
EnemyRewindUsed bool `json:"enemy_rewind_used,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
|
// applyBuffDelta folds one resolved buff (the result of a !cast / !consume
|
||||||
|
|||||||
@@ -373,6 +373,9 @@ func resumeTurnEngine(sess *CombatSession, players []*Combatant, enemy *Combatan
|
|||||||
// Amendment (T6 Custodian) — round-3 snapshot + once-only rewind.
|
// Amendment (T6 Custodian) — round-3 snapshot + once-only rewind.
|
||||||
enemyRewindHP: sess.Statuses.EnemyRewindHP,
|
enemyRewindHP: sess.Statuses.EnemyRewindHP,
|
||||||
enemyRewindUsed: sess.Statuses.EnemyRewindUsed,
|
enemyRewindUsed: sess.Statuses.EnemyRewindUsed,
|
||||||
|
// Inversion Stitch (T6 Seamstress) — phase-2 heal-inverting pulses.
|
||||||
|
inversionActive: sess.Statuses.InversionActive,
|
||||||
|
inversionTelegraph: sess.Statuses.InversionTelegraph,
|
||||||
rng: rng,
|
rng: rng,
|
||||||
}
|
}
|
||||||
order := turnOrder(sess, sess.Round, players, enemy)
|
order := turnOrder(sess, sess.Round, players, enemy)
|
||||||
@@ -573,11 +576,23 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
|||||||
st.enemyHP = max(0, st.enemyHP-enemyDmg)
|
st.enemyHP = max(0, st.enemyHP-enemyDmg)
|
||||||
}
|
}
|
||||||
if eff.PlayerHeal > 0 {
|
if eff.PlayerHeal > 0 {
|
||||||
|
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
|
// Respect any max_hp_drain monster ability — a drained player can't be
|
||||||
// healed back past the lowered ceiling.
|
// healed back past the lowered ceiling.
|
||||||
hpCap := max(1, st.hpMax-st.maxHPDrain)
|
hpCap := max(1, st.hpMax-st.maxHPDrain)
|
||||||
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
|
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// §1 — heal somebody else. The caster's cursor stays where it is; only the
|
// §1 — heal somebody else. The caster's cursor stays where it is; only the
|
||||||
// target's HP moves.
|
// target's HP moves.
|
||||||
//
|
//
|
||||||
@@ -587,6 +602,18 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
|||||||
// path depends on. Healing keeps people up; it does not bring them back.
|
// 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 eff.AllyHeal > 0 && eff.AllySeat >= 0 && eff.AllySeat < len(st.actors) {
|
||||||
if tgt := st.actors[eff.AllySeat]; tgt.playerHP > 0 {
|
if tgt := st.actors[eff.AllySeat]; tgt.playerHP > 0 {
|
||||||
|
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)
|
cap := max(1, tgt.hpMax-tgt.maxHPDrain)
|
||||||
before := tgt.playerHP
|
before := tgt.playerHP
|
||||||
tgt.playerHP = min(cap, tgt.playerHP+eff.AllyHeal)
|
tgt.playerHP = min(cap, tgt.playerHP+eff.AllyHeal)
|
||||||
@@ -597,6 +624,7 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Arm / replace the concentration aura. A new concentration cast overwrites
|
// Arm / replace the concentration aura. A new concentration cast overwrites
|
||||||
// the old one (5e: one concentration at a time); non-concentration casts
|
// the old one (5e: one concentration at a time); non-concentration casts
|
||||||
// leave any running aura alone.
|
// leave any running aura alone.
|
||||||
@@ -952,6 +980,8 @@ func (te *turnEngine) commit() {
|
|||||||
s.EnemyAtkBuff = st.enemyAtkBuff
|
s.EnemyAtkBuff = st.enemyAtkBuff
|
||||||
s.EnemyRewindHP = st.enemyRewindHP
|
s.EnemyRewindHP = st.enemyRewindHP
|
||||||
s.EnemyRewindUsed = st.enemyRewindUsed
|
s.EnemyRewindUsed = st.enemyRewindUsed
|
||||||
|
s.InversionActive = st.inversionActive
|
||||||
|
s.InversionTelegraph = st.inversionTelegraph
|
||||||
|
|
||||||
te.sess.TurnLog = append(te.sess.TurnLog, st.events...)
|
te.sess.TurnLog = append(te.sess.TurnLog, st.events...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,9 +167,9 @@ var _ = func() bool {
|
|||||||
},
|
},
|
||||||
"boss_seamstress": {
|
"boss_seamstress": {
|
||||||
ID: "boss_seamstress", Name: "The 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,
|
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,
|
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.)",
|
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.)",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -228,6 +228,8 @@ func applyBossInCombatRoundEnd(st *combatState, bossID string, enemyMaxHP int) {
|
|||||||
switch bossID {
|
switch bossID {
|
||||||
case "boss_custodian":
|
case "boss_custodian":
|
||||||
applyAmendment(st, enemyMaxHP)
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestGreedTaxAttack(t *testing.T) {
|
func TestGreedTaxAttack(t *testing.T) {
|
||||||
cases := []struct {
|
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) {
|
func TestTurnEngine_AmendmentRoundTrips(t *testing.T) {
|
||||||
// Drive a real Custodian round-end and confirm the once-only rewind is
|
// Drive a real Custodian round-end and confirm the once-only rewind is
|
||||||
// captured through CombatStatuses (a suspend/resume can't replay it).
|
// 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"))
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user