mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
N5/D1c: the finale — !expedition start epilogue
Closes the Hollow King arc with a solo one-shot boss fight, reached via
`!expedition start epilogue` (intercepted before the zone/loadout
machinery). Unlock: all 24 journal pages found + both Tier-5 zones cleared.
- Encounter: runZoneCombat + renderBossOutcome, the arena's own pattern —
no supplies, no walk, no party, no new mechanics. The stat block is
Belaxath's (the abyss boss whose gate "lets one thing come home")
re-dressed as the King returned in full, HP ×1.25 for a capstone; the
ability/AC/CR carry over unchanged.
- Reward-once: first clear grants a unique title ("Kingsbane") + one
Legendary + a games-room notice; later clears are a flavour-only
rematch. The flag is a dedicated player_meta.epilogue_cleared column,
latched by an atomic UPDATE (never the bulk save) so the monotonic
false→true can't be clobbered — same discipline as D1a's journal
bitmask. A loss costs a death, as any boss fight does.
- Title is written after a fresh character reload so runZoneCombat's
post-fight HP persist isn't overwritten (the survivalist-milestone
gotcha).
Golden byte-identical; go test ./... green.
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
@@ -386,6 +386,13 @@ func runMigrations(d *sql.DB) error {
|
||||
// character save. DEFAULT 0 == "no pages found", correct for every
|
||||
// pre-existing row, so no bootstrap backfill.
|
||||
`ALTER TABLE player_meta ADD COLUMN journal_pages INTEGER NOT NULL DEFAULT 0`,
|
||||
// N5/D1c the Hollow King finale (gogobee_engagement_plan.md §D1). Set once,
|
||||
// the first time a player closes the account — the reward (unique title +
|
||||
// one Legendary) drops only on that first clear; later rematches are
|
||||
// flavour-only. Written by a dedicated atomic UPDATE, never the bulk
|
||||
// character save, so a monotonic false→true flag can't be clobbered.
|
||||
// DEFAULT 0 correct for every pre-existing row, so no bootstrap.
|
||||
`ALTER TABLE player_meta ADD COLUMN epilogue_cleared INTEGER NOT NULL DEFAULT 0`,
|
||||
}
|
||||
for _, stmt := range columnMigrations {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
|
||||
@@ -115,6 +115,9 @@ type AdventureCharacter struct {
|
||||
// == page i+1). Read-only overlay from player_meta.journal_pages; writes go
|
||||
// through the atomic grantJournalPageDB, never the bulk character save.
|
||||
JournalPages int64
|
||||
// N5/D1c the finale reward-once flag. True after the first finale clear;
|
||||
// overlay-read, written by the atomic markEpilogueClearedDB.
|
||||
EpilogueCleared bool
|
||||
}
|
||||
|
||||
type AdvEquipment struct {
|
||||
|
||||
@@ -2,9 +2,12 @@ package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
@@ -229,7 +232,7 @@ func renderJournal(mask int64) string {
|
||||
}
|
||||
|
||||
if journalComplete(mask) {
|
||||
b.WriteString("\nThe ledger is whole. Every page you needed, you have. What remains is to bring it to him.")
|
||||
b.WriteString("\nThe ledger is whole. Clear both Tier-5 zones and you can follow him to the end — `!expedition start epilogue`.")
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("\n_%d pages still scattered._", journalTotalPages-found))
|
||||
}
|
||||
@@ -246,6 +249,155 @@ func romanNumeral(n int) string {
|
||||
return romanNumerals[n-1]
|
||||
}
|
||||
|
||||
// ── D1c: the finale ─────────────────────────────────────────────────────────
|
||||
|
||||
// finaleTitle is the unique role awarded for the first finale clear.
|
||||
const finaleTitle = "Kingsbane"
|
||||
|
||||
const epilogueEntryFlavor = "You bring the whole ledger to the Empty Throne. The shape kept warm against his return stirs, and stands, and is finally, fully here."
|
||||
|
||||
// hollowKingFinaleMonster is the finale stat block: Belaxath's block — the abyss
|
||||
// boss whose gate "was meant to let one thing come home" — re-dressed as the
|
||||
// King returned in full, with HP bumped for a capstone. A variant stat block
|
||||
// with no new mechanics, per the plan. Built on the fly (not registered in
|
||||
// dndBestiary) because runZoneCombat takes the template directly, exactly as the
|
||||
// arena bosses do.
|
||||
func hollowKingFinaleMonster() DnDMonsterTemplate {
|
||||
m := dndBestiary["boss_belaxath"]
|
||||
m.ID = "boss_hollow_king_finale"
|
||||
m.Name = "The Hollow King, Unhoused"
|
||||
m.HP = int(float64(m.HP) * 1.25)
|
||||
m.Notes = "Finale encounter — the Hollow King returned in full through the abyss gate. Variant of the Balor stat block; no new mechanics."
|
||||
return m
|
||||
}
|
||||
|
||||
// epilogueUnlocked gates the finale on the whole ledger plus both Tier-5 clears.
|
||||
func (p *AdventurePlugin) epilogueUnlocked(char *AdventureCharacter) (bool, string) {
|
||||
if !journalComplete(char.JournalPages) {
|
||||
return false, fmt.Sprintf(
|
||||
"The trail runs cold. You've recovered **%d of %d** journal pages — find them all before you can follow him to the end. (`!adventure journal`)",
|
||||
journalPageCount(char.JournalPages), journalTotalPages)
|
||||
}
|
||||
if !clearedEveryZoneOfTier(db.Get(), char.UserID, ZoneTier(5)) {
|
||||
return false, "You hold the whole ledger, but not the standing to use it. Clear **both** Tier-5 zones — the Dragon's Lair and the Abyss Portal — then come back to the throne."
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// handleEpilogueEncounter runs the finale: a single auto-resolved boss fight
|
||||
// against the Hollow King's true form, reached via `!expedition start epilogue`.
|
||||
// Reward drops only on the first clear (reward-once); later clears are a
|
||||
// flavour-only rematch. A loss costs a death, as any boss fight does.
|
||||
func (p *AdventurePlugin) handleEpilogueEncounter(ctx MessageContext) error {
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` first.")
|
||||
}
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "You're in no shape to face him. Mend at `!hospital` first.")
|
||||
}
|
||||
if ok, why := p.epilogueUnlocked(char); !ok {
|
||||
return p.SendDM(ctx.Sender, why)
|
||||
}
|
||||
// Busy guards — the finale is a synchronous auto-resolved fight, so refuse if
|
||||
// anything else is in flight and could collide on HP or run state.
|
||||
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil {
|
||||
return p.SendDM(ctx.Sender, "Finish your expedition before you walk to the Empty Throne.")
|
||||
}
|
||||
if run, _ := getActiveZoneRun(ctx.Sender); run != nil {
|
||||
return p.SendDM(ctx.Sender, "Finish your current zone run first.")
|
||||
}
|
||||
if sess, _ := getActiveCombatSession(ctx.Sender); sess != nil {
|
||||
return p.SendDM(ctx.Sender, "You're already in a fight. Finish it first.")
|
||||
}
|
||||
|
||||
monster := hollowKingFinaleMonster()
|
||||
displayName, _ := loadDisplayName(ctx.Sender)
|
||||
if displayName == "" {
|
||||
displayName = "You"
|
||||
}
|
||||
|
||||
preHP, _ := dndHPSnapshot(ctx.Sender)
|
||||
result, err := p.runZoneCombat(ctx.Sender, monster, 5, bossCombatPhases, 50)
|
||||
if err != nil {
|
||||
slog.Error("epilogue: combat failed", "user", ctx.Sender, "err", err)
|
||||
return p.SendDM(ctx.Sender, "Something went wrong at the throne. Try again in a moment.")
|
||||
}
|
||||
postHP, maxHP := dndHPSnapshot(ctx.Sender)
|
||||
nat20s, nat1s := countNat20sAnd1s(result)
|
||||
|
||||
intro := fmt.Sprintf("👑 **The Empty Throne — %s** (HP %d, AC %d)\n_%s_",
|
||||
monster.Name, monster.HP, monster.AC, epilogueEntryFlavor)
|
||||
phases := RenderCombatLog(result, displayName, monster.Name)
|
||||
outcome := renderBossOutcome(BossOutcomeInputs{
|
||||
// Synthetic ZoneArena keeps twinBeeLine's deterministic pickers off any
|
||||
// real zone's pool, exactly as the arena does.
|
||||
ZoneID: ZoneArena,
|
||||
RunID: "epilogue-" + string(ctx.Sender),
|
||||
RoomIdx: 0,
|
||||
Monster: monster,
|
||||
Result: result,
|
||||
PreHP: preHP,
|
||||
PostHP: postHP,
|
||||
MaxHP: maxHP,
|
||||
PhaseTwoAt: 0.40,
|
||||
Nat20s: nat20s,
|
||||
Nat1s: nat1s,
|
||||
DefeatHeadline: "💀 The Hollow King folds you into the quiet he keeps. The account goes unpaid a while longer.",
|
||||
VictoryHeadline: fmt.Sprintf("🏆 **The Hollow King falls — and this time stays down.** You finished at **%d/%d HP**.", postHP, maxHP),
|
||||
})
|
||||
|
||||
var tail string
|
||||
if !result.PlayerWon {
|
||||
markAdventureDead(ctx.Sender, "adventure", "the Empty Throne")
|
||||
} else {
|
||||
tail = p.finishEpilogueWin(ctx.Sender, char.EpilogueCleared)
|
||||
}
|
||||
|
||||
_ = p.sendZoneCombatMessages(ctx.Sender, append([]string{intro}, phases...), joinLootLines(outcome, tail))
|
||||
return nil
|
||||
}
|
||||
|
||||
// finishEpilogueWin grants the reward on a first clear and returns the reward
|
||||
// narration; a repeat clear is flavour only. The character is reloaded before
|
||||
// the title write so runZoneCombat's post-fight HP persist isn't clobbered.
|
||||
func (p *AdventurePlugin) finishEpilogueWin(userID id.UserID, alreadyCleared bool) string {
|
||||
if alreadyCleared {
|
||||
return "_The throne stays empty. You came to be sure. You are sure._"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("🎖️ **The account is closed.** You are named **" + finaleTitle + "** — the one who unhoused the Hollow King.\n")
|
||||
if mi, ok := pickMagicItemForRarity(RarityLegendary, nil); ok {
|
||||
if line := p.dropMagicItemLoot(userID, mi, LootTierLegendary); line != "" {
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
} else {
|
||||
slog.Error("epilogue: no legendary in registry", "user", userID)
|
||||
}
|
||||
|
||||
if fresh, err := loadAdvCharacter(userID); err == nil && fresh != nil {
|
||||
if fresh.Title != finaleTitle {
|
||||
fresh.Title = finaleTitle
|
||||
if err := saveAdvCharacter(fresh); err != nil {
|
||||
slog.Error("epilogue: title save failed", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := markEpilogueClearedDB(userID); err != nil {
|
||||
slog.Error("epilogue: mark cleared failed", "user", userID, "err", err)
|
||||
}
|
||||
|
||||
if gr := gamesRoom(); gr != "" {
|
||||
if dn, _ := loadDisplayName(userID); dn != "" {
|
||||
p.SendMessage(id.RoomID(gr), fmt.Sprintf(
|
||||
"👑 **%s** carried the whole ledger to the Empty Throne and closed the account. The Hollow King is ended. **%s.**",
|
||||
dn, finaleTitle))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
var romanNumerals = []string{
|
||||
"I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X",
|
||||
"XI", "XII", "XIII", "XIV", "XV", "XVI", "XVII", "XVIII", "XIX", "XX",
|
||||
|
||||
@@ -188,6 +188,97 @@ func TestBossEpilogues_EveryRegisteredZoneHasOne(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHollowKingFinaleMonster_ClonesBelaxathWithBump(t *testing.T) {
|
||||
base := dndBestiary["boss_belaxath"]
|
||||
if base.ID == "" {
|
||||
t.Fatal("boss_belaxath missing from bestiary — finale clones it")
|
||||
}
|
||||
m := hollowKingFinaleMonster()
|
||||
if m.ID != "boss_hollow_king_finale" {
|
||||
t.Errorf("finale ID = %q", m.ID)
|
||||
}
|
||||
if m.Name == base.Name || m.Name == "" {
|
||||
t.Errorf("finale name should differ from Belaxath: %q", m.Name)
|
||||
}
|
||||
if m.HP <= base.HP {
|
||||
t.Errorf("finale HP %d should exceed base %d (capstone bump)", m.HP, base.HP)
|
||||
}
|
||||
// No new mechanics: the ability and defensive profile carry over unchanged.
|
||||
if m.Ability != base.Ability {
|
||||
t.Errorf("finale ability pointer changed — should reuse Belaxath's, no new mechanics")
|
||||
}
|
||||
if m.AC != base.AC || m.CR != base.CR {
|
||||
t.Errorf("finale AC/CR drifted from base: AC %d/%d CR %v/%v", m.AC, base.AC, m.CR, base.CR)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpilogueRewardOnce_FlagRoundTrip(t *testing.T) {
|
||||
townTestDB(t)
|
||||
u := id.UserID("@finale:test.invalid")
|
||||
|
||||
if got, err := loadEpilogueCleared(u); err != nil || got {
|
||||
t.Fatalf("fresh player: cleared=%v err=%v, want false/nil", got, err)
|
||||
}
|
||||
if err := markEpilogueClearedDB(u); err != nil {
|
||||
t.Fatalf("mark cleared: %v", err)
|
||||
}
|
||||
if got, err := loadEpilogueCleared(u); err != nil || !got {
|
||||
t.Fatalf("after mark: cleared=%v err=%v, want true/nil", got, err)
|
||||
}
|
||||
// Idempotent.
|
||||
if err := markEpilogueClearedDB(u); err != nil {
|
||||
t.Fatalf("re-mark: %v", err)
|
||||
}
|
||||
|
||||
var c AdventureCharacter
|
||||
c.UserID = u
|
||||
applyPlayerMetaOverlay(&c)
|
||||
if !c.EpilogueCleared {
|
||||
t.Fatalf("overlay did not carry EpilogueCleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpilogueUnlocked_Gates(t *testing.T) {
|
||||
townTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
|
||||
// Incomplete journal → refused, page-count message.
|
||||
partial := &AdventureCharacter{UserID: id.UserID("@u1:test.invalid")}
|
||||
partial.JournalPages = setJournalPageBit(0, 1)
|
||||
ok, why := p.epilogueUnlocked(partial)
|
||||
if ok {
|
||||
t.Fatalf("incomplete journal should not unlock")
|
||||
}
|
||||
if !strings.Contains(why, "journal pages") {
|
||||
t.Errorf("expected page-count refusal, got: %s", why)
|
||||
}
|
||||
|
||||
// Complete journal but no Tier-5 clears → refused, T5 message.
|
||||
full := &AdventureCharacter{UserID: id.UserID("@u2:test.invalid")}
|
||||
for i := 1; i <= journalTotalPages; i++ {
|
||||
full.JournalPages = setJournalPageBit(full.JournalPages, i)
|
||||
}
|
||||
ok, why = p.epilogueUnlocked(full)
|
||||
if ok {
|
||||
t.Fatalf("no T5 clears should not unlock")
|
||||
}
|
||||
if !strings.Contains(why, "Tier-5") {
|
||||
t.Errorf("expected Tier-5 refusal, got: %s", why)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishEpilogueWin_RepeatIsFlavorOnly(t *testing.T) {
|
||||
// The already-cleared branch is pure flavour: no reward, no client calls.
|
||||
p := &AdventurePlugin{}
|
||||
repeat := p.finishEpilogueWin(id.UserID("@u:test.invalid"), true)
|
||||
if strings.Contains(repeat, finaleTitle) {
|
||||
t.Errorf("a repeat clear must not re-award the title: %s", repeat)
|
||||
}
|
||||
if strings.TrimSpace(repeat) == "" {
|
||||
t.Errorf("a repeat clear should still say something")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwinBeeJournalReaction_DeterministicAndGuarded(t *testing.T) {
|
||||
if twinBeeJournalReaction(3, 0) != "" {
|
||||
t.Errorf("zero pages should produce no reaction")
|
||||
|
||||
@@ -283,6 +283,12 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
formatRespecDuration(remaining)))
|
||||
}
|
||||
zoneTok, packTok := splitFirstWord(rest)
|
||||
// N5/D1c: the campaign finale is reached here but is not a normal zone — it
|
||||
// runs a single auto-resolved boss fight with its own unlock gate, no
|
||||
// supplies, no walk. Intercept before the zone/loadout machinery.
|
||||
if strings.EqualFold(zoneTok, "epilogue") {
|
||||
return p.handleEpilogueEncounter(ctx)
|
||||
}
|
||||
available := zonesForLevel(c.Level)
|
||||
zoneID, ok := resolveZoneInput(zoneTok, available)
|
||||
if !ok {
|
||||
|
||||
@@ -421,6 +421,33 @@ func grantJournalPageDB(userID id.UserID, page int) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// loadEpilogueCleared reads the finale reward-once flag (N5/D1c).
|
||||
func loadEpilogueCleared(userID id.UserID) (bool, error) {
|
||||
var cleared int
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT epilogue_cleared FROM player_meta WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&cleared)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return cleared != 0, nil
|
||||
}
|
||||
|
||||
// markEpilogueClearedDB latches the finale reward-once flag atomically, so the
|
||||
// monotonic false→true set survives any concurrent character save.
|
||||
func markEpilogueClearedDB(userID id.UserID) error {
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO player_meta (user_id, epilogue_cleared) VALUES (?, 1)
|
||||
ON CONFLICT(user_id) DO UPDATE SET epilogue_cleared = 1`,
|
||||
string(userID),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// HouseState is the in-memory mirror of player_meta's house_* columns. Phase
|
||||
// L4e ports housing/mortgage off AdvCharacter (gogobee_legacy_migration.md
|
||||
// §6.5). All six fields mutate together at known sites (purchase, payoff,
|
||||
@@ -1319,6 +1346,9 @@ func applyPlayerMetaOverlay(c *AdventureCharacter) {
|
||||
if mask, err := loadJournalPages(uid); err == nil {
|
||||
c.JournalPages = mask
|
||||
}
|
||||
if cleared, err := loadEpilogueCleared(uid); err == nil {
|
||||
c.EpilogueCleared = cleared
|
||||
}
|
||||
if s, err := loadHouseState(uid); err == nil {
|
||||
c.HouseTier = s.Tier
|
||||
c.HouseLoanBalance = s.LoanBalance
|
||||
|
||||
Reference in New Issue
Block a user