diff --git a/internal/db/db.go b/internal/db/db.go index 0e0d708..31a08f5 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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 { diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index 8e0b08d..8ed4d2c 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -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 { diff --git a/internal/plugin/adventure_flavor_campaign.go b/internal/plugin/adventure_flavor_campaign.go index d7d3fe6..7449527 100644 --- a/internal/plugin/adventure_flavor_campaign.go +++ b/internal/plugin/adventure_flavor_campaign.go @@ -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", diff --git a/internal/plugin/adventure_flavor_campaign_test.go b/internal/plugin/adventure_flavor_campaign_test.go index 9cd1e09..04306ba 100644 --- a/internal/plugin/adventure_flavor_campaign_test.go +++ b/internal/plugin/adventure_flavor_campaign_test.go @@ -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") diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index c586795..201b788 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -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 { diff --git a/internal/plugin/player_meta.go b/internal/plugin/player_meta.go index 68989e4..e9bc747 100644 --- a/internal/plugin/player_meta.go +++ b/internal/plugin/player_meta.go @@ -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