adventure: T6 Phylactery Verses + stackable-rebirth engine primitive (P8)

The second Layer-2 postgame boss mechanic, and the first stateful in-combat one.
Valdris ("boss_valdris_ascendant", the Ossuary Ascendant) is bound to three
Verses hidden on the zone's secret nodes: every Verse the player finds and walks
before the fight unbinds one rebirth, every Verse they skip leaves it armed. A
full-clear explorer strips all three and fights a mortal lich; a speedrunner who
blows past the secrets fights a god who will not stay down.

New engine primitive: stackable rebirth. The stock survive_at_1 is a one-shot
1-HP stay; Valdris needs several rebirths that each restore a real pool.
combatState/CombatStatuses gain EnemyReviveCharges/EnemyReviveHP (round-tripped
through the turn engine so a suspend/resume keeps the live count), and enemyDown
consumes a charge after the survive_at_1 check, reviving to 25% of the
party-scaled max and emitting a phylactery_rebirth event. Zero for every
non-Valdris fight.

Unlike Greed Tax (a pure per-round recompute in applyBossRunModifiers), rebirths
are spent mid-fight, so the charge count is seeded ONCE at session creation
(seedBossRunStatuses, from unvisited secret Verses) and never re-derived on the
per-round enemy rebuild. Seeded from handleFightCmd after startPartyCombatSession.

Sim A/B (millenia, n=120 L20 party+Pete+pets, same binary, control neuters the
dispatch): mortal end (verses found) fighter 42.5% -- reproduces the deployed P7
ossuary baseline, confirming the mechanic doesn't touch the validated full-clear
path -- and the god end (0 verses) fighter 12.5%, a deadly-but-beatable flex arm.
The sim walks 0 verses (autopilot takes the first unlocked fork; Verses are
behind Perception locks), so the A/B brackets the whole player-agency gradient.

Claude-Session: https://claude.ai/code/session_0156WqjgsbmSY2U8eQ3Kkb1s
This commit is contained in:
prosolis
2026-07-16 08:04:19 -07:00
parent fc9e055083
commit 686434f8e3
7 changed files with 239 additions and 0 deletions

View File

@@ -119,6 +119,17 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
return p.replyDM(ctx, "Couldn't start the fight: "+err.Error()) return p.replyDM(ctx, "Couldn't start the fight: "+err.Error())
} }
// Layer-2 boss state that is spent mid-fight (Valdris's Phylactery Verses)
// is seeded onto the fresh session ONCE, from the route the player walked to
// get here — not on the per-round rebuild, which would resurrect spent
// rebirths. enemyHP is the party-scaled pool persisted above. No-op for every
// non-hooked enemy.
if seedBossRunStatuses(sess, monster.ID, enemyHP, run) {
if err := saveCombatSession(sess); err != nil {
return p.replyDM(ctx, "Couldn't start the fight: "+err.Error())
}
}
var b strings.Builder var b strings.Builder
if isBoss { if isBoss {
if line := composeBossEntry(zone.ID, run.RunID, run.CurrentRoom); line != "" { if line := composeBossEntry(zone.ID, run.RunID, run.CurrentRoom); line != "" {

View File

@@ -428,6 +428,14 @@ type combatState struct {
enemyRegen int // regenerate: enemy heals this much each round end enemyRegen int // regenerate: enemy heals this much each round end
enemySurviveArmed bool // survive_at_1: enemy cheats death once, dropping to 1 HP enemySurviveArmed bool // survive_at_1: enemy cheats death once, dropping to 1 HP
// Phylactery Verses (T6 Valdris) — stackable rebirth. enemyReviveCharges is
// how many times the boss still cheats death; each revives it to
// enemyReviveHP. Seeded once at fight start from unfound Verses, round-tripped
// through CombatStatuses. Distinct from enemySurviveArmed (a one-shot 1-HP
// proc): a rebirth restores a meaningful pool, and there can be several.
enemyReviveCharges int
enemyReviveHP int
// Phase 13 bestiary slice 4 — the former flavor-only placeholders, now // Phase 13 bestiary slice 4 — the former flavor-only placeholders, now
// backed by real state. // backed by real state.
enemySpellResist bool // spell_resist: player spell damage against this enemy is halved enemySpellResist bool // spell_resist: player spell damage against this enemy is halved
@@ -1308,6 +1316,19 @@ func enemyDown(st *combatState, phaseName string) bool {
}) })
return false return false
} }
// Phylactery Verses (T6 Valdris): a stackable rebirth. Each unfound Verse
// left one of these charges, and each restores a real pool rather than the
// 1-HP stay above — a full-clear explorer stripped them all and fights a
// mortal, a skip-route fights a god who keeps getting back up.
if st.enemyReviveCharges > 0 {
st.enemyReviveCharges--
st.enemyHP = max(1, st.enemyReviveHP)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "phylactery_rebirth",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
return false
}
return true return true
} }

View File

@@ -331,6 +331,8 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
return pickRand(narrativeSurviveArmed) return pickRand(narrativeSurviveArmed)
case "survive_at_1": case "survive_at_1":
return pickRand(narrativeSurvive) return pickRand(narrativeSurvive)
case "phylactery_rebirth":
return pickRand(narrativePhylacteryRebirth)
case "stat_drain": case "stat_drain":
return fmt.Sprintf(pickRand(narrativeStatDrain), e.Damage) return fmt.Sprintf(pickRand(narrativeStatDrain), e.Damage)
case "debuff": case "debuff":
@@ -761,6 +763,15 @@ var narrativeSurvive = []string{
"🕯️ The enemy by all rights should be down. It is, instead, very barely up.", "🕯️ The enemy by all rights should be down. It is, instead, very barely up.",
} }
// narrativePhylacteryRebirth fires when Valdris burns a Verse the player left
// un-found: a bound rebirth spends and the lich reassembles. Each line reads as
// "you skipped one of these" so the mechanic teaches itself over a wipe.
var narrativePhylacteryRebirth = []string{
"💀 The lich comes apart — and a Verse you never found sings him back together. He rises, unhurried.",
"💀 Bone-dust swirls up off the floor and re-seats itself. A rebirth you didn't unbind just spent itself. He stands.",
"💀 That should have been the end of him. A Verse still hums somewhere in the cathedral, and Valdris simply *begins again*.",
}
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)",

View File

@@ -215,6 +215,17 @@ type CombatStatuses struct {
EnemyRegen int `json:"enemy_regen,omitempty"` EnemyRegen int `json:"enemy_regen,omitempty"`
EnemySurviveArmed bool `json:"enemy_survive_armed,omitempty"` EnemySurviveArmed bool `json:"enemy_survive_armed,omitempty"`
// Phylactery Verses (Tier-6 postgame, Valdris Ascendant). Unlike the
// proc-armed EnemySurviveArmed one-shot, this is a *count* of rebirths seeded
// once at fight start (seedBossRunStatuses) from how many of the zone's secret
// Verses the player left un-found. Each consumed rebirth (enemyDown) revives
// the boss to EnemyReviveHP. Both fields are frozen at seed time except
// EnemyReviveCharges, which decrements as rebirths are spent — so they must
// round-trip through combatState to survive a suspend/resume. Zero for every
// other enemy, so omitempty keeps them off every non-Valdris row.
EnemyReviveCharges int `json:"enemy_revive_charges,omitempty"`
EnemyReviveHP int `json:"enemy_revive_hp,omitempty"`
// Slice-4 monster-ability effects — the former flavor-only placeholders. // Slice-4 monster-ability effects — the former flavor-only placeholders.
// EnemyRevealNext is a one-shot; the other three persist for the fight. // EnemyRevealNext is a one-shot; the other three persist for the fight.
EnemySpellResist bool `json:"enemy_spell_resist,omitempty"` EnemySpellResist bool `json:"enemy_spell_resist,omitempty"`

View File

@@ -363,6 +363,8 @@ func resumeTurnEngine(sess *CombatSession, players []*Combatant, enemy *Combatan
enemyRetaliateFrac: sess.Statuses.EnemyRetaliateFrac, enemyRetaliateFrac: sess.Statuses.EnemyRetaliateFrac,
enemyRegen: sess.Statuses.EnemyRegen, enemyRegen: sess.Statuses.EnemyRegen,
enemySurviveArmed: sess.Statuses.EnemySurviveArmed, enemySurviveArmed: sess.Statuses.EnemySurviveArmed,
enemyReviveCharges: sess.Statuses.EnemyReviveCharges,
enemyReviveHP: sess.Statuses.EnemyReviveHP,
// Slice-4 monster-ability effects — the former flavor-only placeholders. // Slice-4 monster-ability effects — the former flavor-only placeholders.
enemySpellResist: sess.Statuses.EnemySpellResist, enemySpellResist: sess.Statuses.EnemySpellResist,
enemyRevealNext: sess.Statuses.EnemyRevealNext, enemyRevealNext: sess.Statuses.EnemyRevealNext,
@@ -926,6 +928,8 @@ func (te *turnEngine) commit() {
s.EnemyRetaliateFrac = st.enemyRetaliateFrac s.EnemyRetaliateFrac = st.enemyRetaliateFrac
s.EnemyRegen = st.enemyRegen s.EnemyRegen = st.enemyRegen
s.EnemySurviveArmed = st.enemySurviveArmed s.EnemySurviveArmed = st.enemySurviveArmed
s.EnemyReviveCharges = st.enemyReviveCharges
s.EnemyReviveHP = st.enemyReviveHP
s.EnemySpellResist = st.enemySpellResist s.EnemySpellResist = st.enemySpellResist
s.EnemyRevealNext = st.enemyRevealNext s.EnemyRevealNext = st.enemyRevealNext
s.EnemyFearImmune = st.enemyFearImmune s.EnemyFearImmune = st.enemyFearImmune

View File

@@ -96,3 +96,75 @@ func greedTaxAttack(richness float64) int {
func applyGreedTax(enemy *Combatant, run *DungeonRun) { func applyGreedTax(enemy *Combatant, run *DungeonRun) {
enemy.Stats.Attack += greedTaxAttack(greedRouteRichness(run)) enemy.Stats.Attack += greedTaxAttack(greedRouteRichness(run))
} }
// ── Phylactery Verses — Valdris, At Last (The Ossuary Ascendant) ────────────
//
// The plan Valdris has been running since he "died" in the T1 Crypt: the
// phylactery shard players looted for years was bait, and he has rebuilt as a
// true lich. His rebirths are bound into three Verses hidden in the cathedral,
// each a NodeKindSecret behind a Perception gate. Every Verse a player finds and
// walks before the fight UNBINDS one rebirth; every Verse they skip leaves it
// armed. Full-clear explorers strip all three and fight a mortal lich;
// speedrunners who blow past the secrets fight a god who will not stay down.
//
// This is the mirror-image of the Greed Tax's design axis: the Tax punishes
// greedy exploration, the Verses reward thorough exploration. Both are prod
// player-agency levers the headless sim only samples at whatever route its
// autopilot happens to walk.
//
// Unlike the Greed Tax (a pure per-round Attack recompute), a rebirth is spent
// mid-fight, so it is stateful: seedBossRunStatuses freezes the charge count
// onto the session ONCE at fight start, and the turn engine round-trips the
// live count through CombatStatuses. It must NOT be re-derived on the per-round
// enemy rebuild, or spent rebirths would come back every round.
const (
// phylacteryReviveFrac is the fraction of the boss's (party-scaled) max HP
// each unbound rebirth restores him to — a real second wind, not the 1-HP
// stay of survive_at_1.
phylacteryReviveFrac = 4 // 1/4 == 25%
)
// phylacteryReviveCharges is the number of rebirths still armed on Valdris: one
// per zone Verse (NodeKindSecret) the run has NOT visited. The Ossuary's only
// secret nodes are the three Verses (the shared builder stamps none by default),
// so counting unvisited secrets is exactly "unbound rebirths". Zero for any
// non-Valdris boss or a nil run. A pure function of frozen run state.
func phylacteryReviveCharges(bossID string, run *DungeonRun) int {
if bossID != "boss_valdris_ascendant" || run == nil {
return 0
}
g, ok := loadZoneGraph(run.ZoneID)
if !ok {
return 0
}
visited := make(map[string]bool, len(run.VisitedNodes))
for _, id := range run.VisitedNodes {
visited[id] = true
}
charges := 0
for id, n := range g.Nodes {
if n.Kind == NodeKindSecret && !visited[id] {
charges++
}
}
return charges
}
// seedBossRunStatuses folds any ONCE-AT-FIGHT-START Layer-2 boss state into the
// freshly-created session, reading the run the player took to get here. It is
// the stateful counterpart to applyBossRunModifiers (which is a per-round pure
// recompute): whatever it seeds here is mutated by the fight and round-tripped,
// never re-derived. enemyMaxHP is the party-scaled pool already persisted onto
// the session. Returns whether it changed anything (so the caller can skip a
// redundant save). No-op — false — for every non-hooked boss.
func seedBossRunStatuses(sess *CombatSession, bossID string, enemyMaxHP int, run *DungeonRun) bool {
if sess == nil {
return false
}
if charges := phylacteryReviveCharges(bossID, run); charges > 0 {
sess.Statuses.EnemyReviveCharges = charges
sess.Statuses.EnemyReviveHP = max(1, enemyMaxHP/phylacteryReviveFrac)
return true
}
return false
}

View File

@@ -86,6 +86,115 @@ func TestApplyBossRunModifiers_GreedTax(t *testing.T) {
} }
} }
func TestPhylacteryReviveCharges(t *testing.T) {
// The Ossuary's three Verses are its only secret nodes: f1b2, f2b2, cap32
// (see zone_graph_ossuary_ascendant.go). Node ids are zone-prefixed.
const pre = "ossuary_ascendant."
// Skip every Verse → all three rebirths stay armed (fight a god).
skip := &DungeonRun{ZoneID: ZoneOssuaryAscendant, VisitedNodes: []string{pre + "entry", pre + "p1", pre + "boss"}}
if got := phylacteryReviveCharges("boss_valdris_ascendant", skip); got != 3 {
t.Errorf("skip-route charges = %d, want 3", got)
}
// Find one Verse → one rebirth unbound, two remain.
one := &DungeonRun{ZoneID: ZoneOssuaryAscendant, VisitedNodes: []string{pre + "entry", pre + "f1b2", pre + "boss"}}
if got := phylacteryReviveCharges("boss_valdris_ascendant", one); got != 2 {
t.Errorf("one-Verse charges = %d, want 2", got)
}
// Full-clear explorer finds all three → 0 rebirths, a mortal lich.
full := &DungeonRun{ZoneID: ZoneOssuaryAscendant, VisitedNodes: []string{pre + "entry", pre + "f1b2", pre + "f2b2", pre + "cap32", pre + "boss"}}
if got := phylacteryReviveCharges("boss_valdris_ascendant", full); got != 0 {
t.Errorf("full-clear charges = %d, want 0", got)
}
// Only Valdris carries the Verses; a nil run and any other boss are 0.
if got := phylacteryReviveCharges("boss_aurvandryx", skip); got != 0 {
t.Errorf("non-Valdris boss charges = %d, want 0", got)
}
if got := phylacteryReviveCharges("boss_valdris_ascendant", nil); got != 0 {
t.Errorf("nil-run charges = %d, want 0", got)
}
}
func TestSeedBossRunStatuses_Phylactery(t *testing.T) {
const pre = "ossuary_ascendant."
skip := &DungeonRun{ZoneID: ZoneOssuaryAscendant, VisitedNodes: []string{pre + "entry", pre + "boss"}} // 3 unfound
// A skip-route seeds all three rebirths and the 25%-of-max revive pool.
sess := &CombatSession{}
if !seedBossRunStatuses(sess, "boss_valdris_ascendant", 560, skip) {
t.Fatal("skip-route seed returned false, want true (charges seeded)")
}
if sess.Statuses.EnemyReviveCharges != 3 {
t.Errorf("seeded charges = %d, want 3", sess.Statuses.EnemyReviveCharges)
}
if sess.Statuses.EnemyReviveHP != 140 { // 560/4
t.Errorf("seeded revive HP = %d, want 140", sess.Statuses.EnemyReviveHP)
}
// A full-clear seeds nothing and reports no change.
full := &DungeonRun{ZoneID: ZoneOssuaryAscendant, VisitedNodes: []string{pre + "f1b2", pre + "f2b2", pre + "cap32"}}
clean := &CombatSession{}
if seedBossRunStatuses(clean, "boss_valdris_ascendant", 560, full) {
t.Error("full-clear seed returned true, want false (no rebirths)")
}
if clean.Statuses.EnemyReviveCharges != 0 {
t.Errorf("full-clear seeded charges = %d, want 0", clean.Statuses.EnemyReviveCharges)
}
// Non-hooked boss: no-op even on a skip route.
other := &CombatSession{}
if seedBossRunStatuses(other, "boss_seamstress", 560, skip) {
t.Error("non-hooked boss seed returned true, want false")
}
}
// TestEnemyDown_PhylacteryRebirth exercises the engine primitive: each seeded
// charge revives the boss to the revive pool once, and the fight ends only when
// the last charge is spent.
func TestEnemyDown_PhylacteryRebirth(t *testing.T) {
player := &Combatant{Name: "Hero", IsPlayer: true, Stats: CombatStats{MaxHP: 100, AC: 15, Attack: 10}}
seat0 := newActor(player)
st := &combatState{actor: seat0, actors: []*actor{seat0}, enemyReviveCharges: 2, enemyReviveHP: 140}
// First lethal blow: a charge spends, boss revives to the pool, not down.
st.enemyHP = 0
if enemyDown(st, "test") {
t.Fatal("first killing blow reported down, want rebirth")
}
if st.enemyHP != 140 || st.enemyReviveCharges != 1 {
t.Errorf("after 1st rebirth: HP=%d charges=%d, want 140/1", st.enemyHP, st.enemyReviveCharges)
}
// Second lethal blow: last charge spends, revives again.
st.enemyHP = -5
if enemyDown(st, "test") {
t.Fatal("second killing blow reported down, want rebirth")
}
if st.enemyHP != 140 || st.enemyReviveCharges != 0 {
t.Errorf("after 2nd rebirth: HP=%d charges=%d, want 140/0", st.enemyHP, st.enemyReviveCharges)
}
// Third lethal blow: no charges left, the lich stays dead.
st.enemyHP = 0
if !enemyDown(st, "test") {
t.Error("charge-less killing blow reported alive, want down")
}
// A rebirth event was emitted per revival (two), for the narrator.
rebirths := 0
for _, e := range st.events {
if e.Action == "phylactery_rebirth" {
rebirths++
}
}
if rebirths != 2 {
t.Errorf("phylactery_rebirth events = %d, want 2", rebirths)
}
}
func approxEq(a, b float64) bool { func approxEq(a, b float64) bool {
d := a - b d := a - b
return d < 1e-9 && d > -1e-9 return d < 1e-9 && d > -1e-9