mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
N5/D1a: journal pages — the Hollow King campaign collectible
First slice of the N5 story layer. Adds a 24-page serialized campaign threaded through the zones as collectible journal pages. - Storage: one journal_pages INTEGER bitmask on player_meta (bit i == page i+1). DEFAULT 0 is correct for every existing row, so no bootstrap. Grants are an atomic bitwise-OR (INSERT ... ON CONFLICT DO UPDATE SET journal_pages = journal_pages | excluded.journal_pages) so a page found mid-expedition can't be lost to a stale character save. Journal pages are therefore grant-only + overlay-read, never in the bulk upsert. - Drop seam: elite kills roll a page (22%, tunable) in dropZoneLoot, gated on isElite — bosses get epilogues (D1b), not pages. Not on SimulateCombat's path, so TestCombatCharacterization is byte-identical. - Viewer: !adventure journal renders found pages in story order with runs of missing pages collapsed to a single "…". - Catalog + bitmask helpers + render live in the new adventure_flavor_campaign.go; the pages are in-world found artifacts, not TwinBee's voice. Golden byte-identical; go test ./... green (incl. a real-DB round-trip that pins the atomic OR + overlay read). Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
@@ -378,6 +378,14 @@ func runMigrations(d *sql.DB) error {
|
|||||||
`ALTER TABLE player_meta ADD COLUMN pet2_armor_tier INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE player_meta ADD COLUMN pet2_armor_tier INTEGER NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE player_meta ADD COLUMN pet2_flags_json TEXT NOT NULL DEFAULT '{}'`,
|
`ALTER TABLE player_meta ADD COLUMN pet2_flags_json TEXT NOT NULL DEFAULT '{}'`,
|
||||||
`ALTER TABLE player_meta ADD COLUMN pet2_level_10_date TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE player_meta ADD COLUMN pet2_level_10_date TEXT NOT NULL DEFAULT ''`,
|
||||||
|
// N5/D1 the Hollow King campaign (gogobee_engagement_plan.md §D1). Found
|
||||||
|
// journal pages are a bitmask (bit i == page i+1 discovered), granted one at
|
||||||
|
// a time from elite kills and secret rooms. A single INTEGER rather than a
|
||||||
|
// rows table: pages are static content, only found/not-found is per-player,
|
||||||
|
// and grants are an atomic bitwise-OR so a page can't be lost to a stale
|
||||||
|
// 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`,
|
||||||
}
|
}
|
||||||
for _, stmt := range columnMigrations {
|
for _, stmt := range columnMigrations {
|
||||||
if _, err := d.Exec(stmt); err != nil {
|
if _, err := d.Exec(stmt); err != nil {
|
||||||
|
|||||||
@@ -533,6 +533,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
|||||||
return p.handleTreasuresCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "treasures")))
|
return p.handleTreasuresCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "treasures")))
|
||||||
case lower == "vault" || strings.HasPrefix(lower, "vault "):
|
case lower == "vault" || strings.HasPrefix(lower, "vault "):
|
||||||
return p.handleVaultCmd(ctx, strings.TrimSpace(args[len("vault"):]))
|
return p.handleVaultCmd(ctx, strings.TrimSpace(args[len("vault"):]))
|
||||||
|
case lower == "journal":
|
||||||
|
return p.handleJournalCmd(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.")
|
return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.")
|
||||||
@@ -564,6 +566,7 @@ const advHelpText = `**Adventure Commands**
|
|||||||
` + "`!adventure recipes`" + ` — List crafting recipes known at your current Foraging level
|
` + "`!adventure recipes`" + ` — List crafting recipes known at your current Foraging level
|
||||||
` + "`!adventure mastery`" + ` — Per-slot equipment mastery progress and active bonus
|
` + "`!adventure mastery`" + ` — Per-slot equipment mastery progress and active bonus
|
||||||
` + "`!adventure treasures`" + ` — List your treasures · ` + "`treasures lock`" + ` to refuse swaps
|
` + "`!adventure treasures`" + ` — List your treasures · ` + "`treasures lock`" + ` to refuse swaps
|
||||||
|
` + "`!adventure journal`" + ` — Read the campaign pages you've recovered
|
||||||
` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead)
|
` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead)
|
||||||
` + "`!thom`" + ` — Visit Thom Krooke (housing and loans)
|
` + "`!thom`" + ` — Visit Thom Krooke (housing and loans)
|
||||||
` + "`!adventure help`" + ` — This message
|
` + "`!adventure help`" + ` — This message
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ type AdventureCharacter struct {
|
|||||||
CraftsSucceeded int
|
CraftsSucceeded int
|
||||||
DeathSource string // "adventure" | "arena" | "" (legacy/unknown — treated as adventure)
|
DeathSource string // "adventure" | "arena" | "" (legacy/unknown — treated as adventure)
|
||||||
DeathLocation string // human-readable location of last death; cleared on revive is not required
|
DeathLocation string // human-readable location of last death; cleared on revive is not required
|
||||||
|
// N5/D1 the Hollow King campaign. Bitmask of discovered journal pages (bit i
|
||||||
|
// == page i+1). Read-only overlay from player_meta.journal_pages; writes go
|
||||||
|
// through the atomic grantJournalPageDB, never the bulk character save.
|
||||||
|
JournalPages int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdvEquipment struct {
|
type AdvEquipment struct {
|
||||||
|
|||||||
199
internal/plugin/adventure_flavor_campaign.go
Normal file
199
internal/plugin/adventure_flavor_campaign.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Hollow King campaign (N5/D1). A light serialized story threaded through
|
||||||
|
// the zones by way of collectible journal pages. "The Hollow King" is already
|
||||||
|
// the Forest Shadows (T2) boss; this frames him as a realm-spanning antagonist
|
||||||
|
// whose fragments turn up wherever players fight elites and open secret rooms.
|
||||||
|
//
|
||||||
|
// The fragments are in-world found artifacts — journal entries, torn letters,
|
||||||
|
// stone inscriptions — not TwinBee's voice. TwinBee's own reactions (D1b) obey
|
||||||
|
// the first-person voice rules; this text does not.
|
||||||
|
|
||||||
|
type journalPage struct {
|
||||||
|
Title string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// journalPages is the ordered campaign. Reading the discovered pages top to
|
||||||
|
// bottom tells the fall of a kingdom to the thing its king became. Order is the
|
||||||
|
// story order; players find them out of sequence, which is why the viewer marks
|
||||||
|
// gaps.
|
||||||
|
var journalPages = []journalPage{
|
||||||
|
{"The Long Winter", "A crown is only a circle of gold until a man decides never to take it off. Ours decided in a winter that would not end, and the frost took its cue from him."},
|
||||||
|
{"The Last Physician", "The court healers were sent home one by one, each promising the king had years left. The last was not sent home. We heard him thank the king for the honour."},
|
||||||
|
{"On Shadows", "He stopped casting a shadow before he stopped casting a reflection. The steward struck both from the list of things a servant may notice aloud."},
|
||||||
|
{"The Quiet Ledger", "Grain still left the granaries; no mouths were fed. I stopped auditing the difference the night the number began to feel like a name."},
|
||||||
|
{"A Bargain Overheard", "Through the chapel door: the king's voice, and a second that used his own words a breath before he did. Only one of them was asking."},
|
||||||
|
{"The Hollowing", "They call it a coronation in the records. Those of us who carried the braziers call it what it was. A king was emptied so a crown could keep wearing him."},
|
||||||
|
{"The First Knights", "His honour guard did not die. That was the mercy offered and the price paid. They stand at the old gate yet, and they still salute a throne no living thing sits on."},
|
||||||
|
{"Letters Home, Unsent", "\"Tell mother the pay is good and the work is guarding.\" The satchel held forty such letters, every hand different, every promise the same, none of them sent."},
|
||||||
|
{"The Map Redrawn", "The kingdom did not fall so much as come apart at the seams — a warren here, a drowned temple there, each piece keeping a splinter of him like a tooth in a wound."},
|
||||||
|
{"Root and Rot", "The forest north of the manor grew wrong and grew fast. The woodsmen say the trees lean toward the ruin at dusk. The woodsmen no longer go at dusk."},
|
||||||
|
{"The Warren Below", "Even the goblins gave the deep tunnels to him and asked nothing back. When a scavenger yields ground for free, ask what it saw down there."},
|
||||||
|
{"Water That Remembers", "The temple sank in a single night with the bells still ringing. Divers say the bells ring still, slow, as if something below is counting."},
|
||||||
|
{"The Manor's Long Guest", "Blackspire changed hands nine times in a decade. Every deed names a different owner. Every household names the same tenant, and none will write it down."},
|
||||||
|
{"The Forge Unbanked", "The underforge keeps a heat with no fuel and no smith. What it makes, no one has seen leave. What it makes, we are told, was promised elsewhere long ago."},
|
||||||
|
{"Descent", "The deep roads were a trade route once. Now they are a throat. Everything the surface loses is swallowed the same direction, and the direction has a door at the end."},
|
||||||
|
{"The Bright Country", "Past the crossing the colours are too kind and the days too long. It is the most beautiful place I have run from. He is patient there; he can afford to be."},
|
||||||
|
{"What the Dragon Keeps", "The wyrm hoards more than gold. Deep in the lair, behind the coin, a single crown sits on no head and is guarded better than the hoard."},
|
||||||
|
{"The Portal's Arithmetic", "The abyss gate opens outward. Everyone assumes a door lets things in. This one was built by someone who only ever intended to leave through it."},
|
||||||
|
{"The Regent's Confession", "I ruled in his name for thirty years and never once saw him rule. I signed what the crown wanted signed. I am writing this so that one honest page exists."},
|
||||||
|
{"The Names of the Guard", "I have set down every knight's name here so that when this is read, they are grieved as men and not feared as things. It is the only rescue left to attempt."},
|
||||||
|
{"The Flaw in the Bargain", "The second voice took the king's life and his death both — and a thing that cannot die also cannot be finished. He is not immortal. He is unpaid, and waiting to collect."},
|
||||||
|
{"How to Call Him", "He answers only where his fragments gather and only to one who has gathered them. Do not do this to avenge us. Do it to end the account. Come with the whole ledger or do not come."},
|
||||||
|
{"The Empty Throne", "I have seen the seat at the heart of it all. It is not empty. It is occupied by the shape of a man who left, kept warm against his return."},
|
||||||
|
{"Last Page", "If you are reading in order, you have walked the ruin of everything he emptied to stay. One page is missing from every telling — the one you write by going in. Bring a light. He hates the light. It is the one thing he could never hollow out."},
|
||||||
|
}
|
||||||
|
|
||||||
|
// journalTotalPages is the campaign length; the drop/viewer/finale all read it
|
||||||
|
// so the story can grow by appending to journalPages alone.
|
||||||
|
var journalTotalPages = len(journalPages)
|
||||||
|
|
||||||
|
// journalPageDropChance is the per-elite-kill probability that a page turns up.
|
||||||
|
// Deliberately modest: 24 pages is a long-horizon collection, and secret rooms
|
||||||
|
// (D4) grant pages on top of this. Tunable.
|
||||||
|
const journalPageDropChance = 0.22
|
||||||
|
|
||||||
|
// setJournalPageBit is the in-memory twin of grantJournalPageDB's bitwise OR —
|
||||||
|
// the single definition of "page N lives in bit N-1". Out-of-range pages are a
|
||||||
|
// no-op.
|
||||||
|
func setJournalPageBit(mask int64, page int) int64 {
|
||||||
|
if page < 1 || page > 63 {
|
||||||
|
return mask
|
||||||
|
}
|
||||||
|
return mask | (int64(1) << (page - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func journalPageFound(mask int64, page int) bool {
|
||||||
|
if page < 1 || page > 63 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return mask&(int64(1)<<(page-1)) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func journalPageCount(mask int64) int {
|
||||||
|
n := 0
|
||||||
|
for i := 1; i <= journalTotalPages; i++ {
|
||||||
|
if journalPageFound(mask, i) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func journalComplete(mask int64) bool {
|
||||||
|
return journalPageCount(mask) >= journalTotalPages
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickUnfoundJournalPage returns a random not-yet-found page number, or 0 when
|
||||||
|
// the campaign is already complete.
|
||||||
|
func pickUnfoundJournalPage(mask int64, rng *rand.Rand) int {
|
||||||
|
var missing []int
|
||||||
|
for i := 1; i <= journalTotalPages; i++ {
|
||||||
|
if !journalPageFound(mask, i) {
|
||||||
|
missing = append(missing, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return missing[rngIntN(rng, len(missing))]
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeDropJournalPage rolls a page reward for an elite kill or secret room and,
|
||||||
|
// on a hit, grants a random unfound page and returns its narration line. Empty
|
||||||
|
// string means no drop (missed the roll, DB error, or campaign already
|
||||||
|
// complete). The roll draws from the same RNG the surrounding loot rolls use
|
||||||
|
// and never touches SimulateCombat's stream, so the combat golden is unmoved.
|
||||||
|
func (p *AdventurePlugin) maybeDropJournalPage(userID id.UserID, rng *rand.Rand) string {
|
||||||
|
if rngFloat(rng) >= journalPageDropChance {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p.grantJournalPage(userID, rng)
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantJournalPage grants a random unfound page unconditionally (used by secret
|
||||||
|
// rooms, which award a page for certain). Returns "" when already complete or on
|
||||||
|
// error.
|
||||||
|
func (p *AdventurePlugin) grantJournalPage(userID id.UserID, rng *rand.Rand) string {
|
||||||
|
mask, err := loadJournalPages(userID)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
page := pickUnfoundJournalPage(mask, rng)
|
||||||
|
if page == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if err := grantJournalPageDB(userID, page); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("📖 A torn journal page — _%s_ (page %d of %d). See `!adventure journal`.",
|
||||||
|
journalPages[page-1].Title, page, journalTotalPages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleJournalCmd renders the player's collected campaign pages.
|
||||||
|
func (p *AdventurePlugin) handleJournalCmd(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` to create one first.")
|
||||||
|
}
|
||||||
|
return p.SendDM(ctx.Sender, renderJournal(char.JournalPages))
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderJournal builds the `!adventure journal` view: discovered pages in story
|
||||||
|
// order, with runs of missing pages collapsed to a single "…".
|
||||||
|
func renderJournal(mask int64) string {
|
||||||
|
found := journalPageCount(mask)
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf("📖 **The Hollow King** — Journal (%d / %d pages)\n",
|
||||||
|
found, journalTotalPages))
|
||||||
|
|
||||||
|
if found == 0 {
|
||||||
|
b.WriteString("\nYou carry no pages yet. They surface where the realm's fragments gather — in the hands of elites, and behind doors most adventurers walk past.")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
gapOpen := false
|
||||||
|
for i := 1; i <= journalTotalPages; i++ {
|
||||||
|
if journalPageFound(mask, i) {
|
||||||
|
gapOpen = false
|
||||||
|
jp := journalPages[i-1]
|
||||||
|
b.WriteString(fmt.Sprintf("\n**%s. %s**\n%s\n", romanNumeral(i), jp.Title, jp.Text))
|
||||||
|
} else if !gapOpen {
|
||||||
|
gapOpen = true
|
||||||
|
b.WriteString("\n…\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if journalComplete(mask) {
|
||||||
|
b.WriteString("\nThe ledger is whole. Every page you needed, you have. What remains is to bring it to him.")
|
||||||
|
} else {
|
||||||
|
b.WriteString(fmt.Sprintf("\n_%d pages still scattered._", journalTotalPages-found))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// romanNumeral renders 1..24 as upper-case Roman numerals for the page headers.
|
||||||
|
// The campaign never exceeds a couple dozen pages, so a small table beats a
|
||||||
|
// general algorithm here.
|
||||||
|
func romanNumeral(n int) string {
|
||||||
|
if n < 1 || n > len(romanNumerals) {
|
||||||
|
return fmt.Sprintf("%d", n)
|
||||||
|
}
|
||||||
|
return romanNumerals[n-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
var romanNumerals = []string{
|
||||||
|
"I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X",
|
||||||
|
"XI", "XII", "XIII", "XIV", "XV", "XVI", "XVII", "XVIII", "XIX", "XX",
|
||||||
|
"XXI", "XXII", "XXIII", "XXIV", "XXV", "XXVI", "XXVII", "XXVIII", "XXIX", "XXX",
|
||||||
|
}
|
||||||
174
internal/plugin/adventure_flavor_campaign_test.go
Normal file
174
internal/plugin/adventure_flavor_campaign_test.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand/v2"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJournalGrant_DBRoundTripIsAtomicOR(t *testing.T) {
|
||||||
|
townTestDB(t)
|
||||||
|
u := id.UserID("@page:test.invalid")
|
||||||
|
|
||||||
|
// No row yet → zero pages.
|
||||||
|
if mask, err := loadJournalPages(u); err != nil || mask != 0 {
|
||||||
|
t.Fatalf("fresh player: mask=%d err=%v, want 0/nil", mask, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First grant creates the row (ON CONFLICT INSERT path).
|
||||||
|
if err := grantJournalPageDB(u, 5); err != nil {
|
||||||
|
t.Fatalf("grant page 5: %v", err)
|
||||||
|
}
|
||||||
|
// Second grant ORs into the existing row without clobbering the first.
|
||||||
|
if err := grantJournalPageDB(u, 2); err != nil {
|
||||||
|
t.Fatalf("grant page 2: %v", err)
|
||||||
|
}
|
||||||
|
// Re-granting a found page is idempotent.
|
||||||
|
if err := grantJournalPageDB(u, 5); err != nil {
|
||||||
|
t.Fatalf("re-grant page 5: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mask, err := loadJournalPages(u)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if !journalPageFound(mask, 2) || !journalPageFound(mask, 5) {
|
||||||
|
t.Fatalf("pages 2 and 5 should be found, mask=%b", mask)
|
||||||
|
}
|
||||||
|
if journalPageCount(mask) != 2 {
|
||||||
|
t.Fatalf("count=%d, want 2 (mask=%b)", journalPageCount(mask), mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay populates the character field the viewer reads.
|
||||||
|
var c AdventureCharacter
|
||||||
|
c.UserID = u
|
||||||
|
applyPlayerMetaOverlay(&c)
|
||||||
|
if c.JournalPages != mask {
|
||||||
|
t.Fatalf("overlay JournalPages=%b, want %b", c.JournalPages, mask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJournalCatalog_WellFormed(t *testing.T) {
|
||||||
|
if journalTotalPages != len(journalPages) {
|
||||||
|
t.Fatalf("journalTotalPages %d != len(journalPages) %d", journalTotalPages, len(journalPages))
|
||||||
|
}
|
||||||
|
if journalTotalPages < 1 || journalTotalPages > 63 {
|
||||||
|
t.Fatalf("journal must fit an int64 bitmask (1..63); got %d", journalTotalPages)
|
||||||
|
}
|
||||||
|
if journalTotalPages > len(romanNumerals) {
|
||||||
|
t.Fatalf("more pages (%d) than roman numerals (%d)", journalTotalPages, len(romanNumerals))
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for i, jp := range journalPages {
|
||||||
|
if strings.TrimSpace(jp.Title) == "" {
|
||||||
|
t.Errorf("page %d has empty title", i+1)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(jp.Text) == "" {
|
||||||
|
t.Errorf("page %d (%q) has empty text", i+1, jp.Title)
|
||||||
|
}
|
||||||
|
if seen[jp.Title] {
|
||||||
|
t.Errorf("duplicate page title %q", jp.Title)
|
||||||
|
}
|
||||||
|
seen[jp.Title] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJournalBitmask_GrantQueryCount(t *testing.T) {
|
||||||
|
var mask int64
|
||||||
|
if journalPageCount(mask) != 0 {
|
||||||
|
t.Fatalf("empty mask should count 0")
|
||||||
|
}
|
||||||
|
if journalPageFound(mask, 1) {
|
||||||
|
t.Fatalf("page 1 should be unfound on empty mask")
|
||||||
|
}
|
||||||
|
mask = setJournalPageBit(mask, 3)
|
||||||
|
mask = setJournalPageBit(mask, 1)
|
||||||
|
if !journalPageFound(mask, 3) || !journalPageFound(mask, 1) {
|
||||||
|
t.Fatalf("pages 1 and 3 should be found")
|
||||||
|
}
|
||||||
|
if journalPageFound(mask, 2) {
|
||||||
|
t.Fatalf("page 2 should still be unfound")
|
||||||
|
}
|
||||||
|
if got := journalPageCount(mask); got != 2 {
|
||||||
|
t.Fatalf("count = %d, want 2", got)
|
||||||
|
}
|
||||||
|
// Re-setting a found page is idempotent.
|
||||||
|
if again := setJournalPageBit(mask, 3); again != mask {
|
||||||
|
t.Fatalf("re-granting page 3 changed the mask")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickUnfoundJournalPage_ExhaustsToZero(t *testing.T) {
|
||||||
|
rng := rand.New(rand.NewPCG(1, 2))
|
||||||
|
var mask int64
|
||||||
|
found := map[int]bool{}
|
||||||
|
for i := 0; i < journalTotalPages; i++ {
|
||||||
|
page := pickUnfoundJournalPage(mask, rng)
|
||||||
|
if page < 1 || page > journalTotalPages {
|
||||||
|
t.Fatalf("pick %d out of range", page)
|
||||||
|
}
|
||||||
|
if found[page] {
|
||||||
|
t.Fatalf("pick returned already-found page %d", page)
|
||||||
|
}
|
||||||
|
found[page] = true
|
||||||
|
mask = setJournalPageBit(mask, page)
|
||||||
|
}
|
||||||
|
if !journalComplete(mask) {
|
||||||
|
t.Fatalf("mask should be complete after %d picks", journalTotalPages)
|
||||||
|
}
|
||||||
|
if page := pickUnfoundJournalPage(mask, rng); page != 0 {
|
||||||
|
t.Fatalf("pick on complete mask = %d, want 0", page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderJournal_EmptyPartialFull(t *testing.T) {
|
||||||
|
// Empty.
|
||||||
|
empty := renderJournal(0)
|
||||||
|
if !strings.Contains(empty, "0 / ") {
|
||||||
|
t.Errorf("empty journal should show 0 found:\n%s", empty)
|
||||||
|
}
|
||||||
|
if strings.Contains(empty, "…") {
|
||||||
|
t.Errorf("empty journal should not render a gap marker:\n%s", empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial with a gap: pages 1 and 3 found, 2 missing → exactly one "…".
|
||||||
|
var partial int64
|
||||||
|
partial = setJournalPageBit(partial, 1)
|
||||||
|
partial = setJournalPageBit(partial, 3)
|
||||||
|
pv := renderJournal(partial)
|
||||||
|
if !strings.Contains(pv, journalPages[0].Title) || !strings.Contains(pv, journalPages[2].Title) {
|
||||||
|
t.Errorf("partial journal missing a found page title:\n%s", pv)
|
||||||
|
}
|
||||||
|
if strings.Contains(pv, journalPages[1].Title) {
|
||||||
|
t.Errorf("partial journal leaked an unfound page's text:\n%s", pv)
|
||||||
|
}
|
||||||
|
if !strings.Contains(pv, "…") {
|
||||||
|
t.Errorf("partial journal with a gap should render '…':\n%s", pv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full: every title present, no gap, completion line.
|
||||||
|
var full int64
|
||||||
|
for i := 1; i <= journalTotalPages; i++ {
|
||||||
|
full = setJournalPageBit(full, i)
|
||||||
|
}
|
||||||
|
fv := renderJournal(full)
|
||||||
|
if strings.Contains(fv, "…") {
|
||||||
|
t.Errorf("complete journal should have no gaps:\n%s", fv)
|
||||||
|
}
|
||||||
|
for _, jp := range journalPages {
|
||||||
|
if !strings.Contains(fv, jp.Title) {
|
||||||
|
t.Errorf("complete journal missing page %q", jp.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !journalComplete(full) {
|
||||||
|
t.Fatalf("full mask should be complete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJournalDropChance_Sane(t *testing.T) {
|
||||||
|
if journalPageDropChance <= 0 || journalPageDropChance >= 1 {
|
||||||
|
t.Fatalf("drop chance %v out of (0,1)", journalPageDropChance)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -517,6 +517,13 @@ func (p *AdventurePlugin) dropZoneLoot(userID id.UserID, zoneID ZoneID, monster
|
|||||||
extra = append(extra, line)
|
extra = append(extra, line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The Hollow King campaign (N5/D1). Elites carry the scattered pages; the
|
||||||
|
// gating boss gets an epilogue instead (D1b), not a page.
|
||||||
|
if isElite {
|
||||||
|
if line := p.maybeDropJournalPage(userID, nil); line != "" {
|
||||||
|
extra = append(extra, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
trailer := strings.Join(extra, "\n")
|
trailer := strings.Join(extra, "\n")
|
||||||
|
|
||||||
entry, tier, ok := rollZoneLoot(zoneID, monster.CR, isBoss, nil)
|
entry, tier, ok := rollZoneLoot(zoneID, monster.CR, isBoss, nil)
|
||||||
|
|||||||
@@ -386,6 +386,41 @@ func upsertPlayerMetaPet2State(userID id.UserID, s PetState) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadJournalPages reads the Hollow King campaign page bitmask (N5/D1). A
|
||||||
|
// missing row means no pages found yet.
|
||||||
|
func loadJournalPages(userID id.UserID) (int64, error) {
|
||||||
|
var mask int64
|
||||||
|
err := db.Get().QueryRow(
|
||||||
|
`SELECT journal_pages FROM player_meta WHERE user_id = ?`,
|
||||||
|
string(userID),
|
||||||
|
).Scan(&mask)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return mask, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantJournalPageDB sets the bit for one page atomically. The OR against the
|
||||||
|
// stored value (not a read-modify-write of the in-memory character) means a
|
||||||
|
// page granted mid-expedition survives a concurrent character save, and the
|
||||||
|
// call is idempotent — re-granting a found page is a no-op.
|
||||||
|
func grantJournalPageDB(userID id.UserID, page int) error {
|
||||||
|
if page < 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
bit := int64(1) << (page - 1)
|
||||||
|
_, err := db.Get().Exec(
|
||||||
|
`INSERT INTO player_meta (user_id, journal_pages) VALUES (?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
journal_pages = journal_pages | excluded.journal_pages`,
|
||||||
|
string(userID), bit,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// HouseState is the in-memory mirror of player_meta's house_* columns. Phase
|
// HouseState is the in-memory mirror of player_meta's house_* columns. Phase
|
||||||
// L4e ports housing/mortgage off AdvCharacter (gogobee_legacy_migration.md
|
// L4e ports housing/mortgage off AdvCharacter (gogobee_legacy_migration.md
|
||||||
// §6.5). All six fields mutate together at known sites (purchase, payoff,
|
// §6.5). All six fields mutate together at known sites (purchase, payoff,
|
||||||
@@ -1281,6 +1316,9 @@ func applyPlayerMetaOverlay(c *AdventureCharacter) {
|
|||||||
c.Pet2ChasedAway = s.ChasedAway
|
c.Pet2ChasedAway = s.ChasedAway
|
||||||
c.Pet2Reactivated = s.Reactivated
|
c.Pet2Reactivated = s.Reactivated
|
||||||
}
|
}
|
||||||
|
if mask, err := loadJournalPages(uid); err == nil {
|
||||||
|
c.JournalPages = mask
|
||||||
|
}
|
||||||
if s, err := loadHouseState(uid); err == nil {
|
if s, err := loadHouseState(uid); err == nil {
|
||||||
c.HouseTier = s.Tier
|
c.HouseTier = s.Tier
|
||||||
c.HouseLoanBalance = s.LoanBalance
|
c.HouseLoanBalance = s.LoanBalance
|
||||||
|
|||||||
Reference in New Issue
Block a user