12 Commits

Author SHA1 Message Date
prosolis
401b17c3bb N6/C2: player-initiated duels — staked, no-death PvP
`!duel @user [stake]` (+ `!adventure duel`; accept/decline/status). A staked
arena bout resolved through the auto-resolve combat engine.

The engine is asymmetric — N player-seats vs one monster-shaped enemy, no seat
for a second PC — so a duel builds each fighter as their real player Combatant
and synthesises the opponent as an enemy stat block from their sheet. That is
lopsided (player side = full kit, enemy = flat stat block), so the bout runs
BOTH orientations and decides on aggregate remaining-HP fraction, cancelling the
attacker-seat edge. To-hit stays faithful both ways (d20 + AttackBonus vs AC);
damage folds into one enemy Attack (per-hit × swings + companion-proc
expectation); DamageReduct ports over. Casters fight weapon-only in both
orientations — accepted, PvP class balance is out of scope per the plan.

Economics: both escrow the stake on accept (pool 2×stake); winner 70%, community
pot rakes 30% (a sink); exact-HP-tie draw refunds both. Stake capped at
level×€500. W/L reuses adventure_rival_records; shared 7-day pair cooldown.

Terminal fate is serialised by an atomic claim (DELETE … WHERE id=? gated on
RowsAffected==1, mirroring communityPotDebit): accept/decline/the expiry ticker
all claim first, only the winner moves euros — no stake can be both resolved and
refunded across the 24h boundary. issueDuel runs under advUserLock; accept
re-checks the challenger and builds both fighters before debiting the challenged.
Rides the 1-min eventTicker; combat golden byte-identical; suite green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 19:18:45 -07:00
prosolis
3026fe6012 N6/C3 review cleanup: retire the combined-level reducer duplication
The W1 extraction added combinedAdvLevel but left twinBeeMaxTier and
distributeTwinBeeRewards inlining the identical dndLevel+3-skills expression,
so the reducer lived in three places. Rewire both twinbee sites to
combinedAdvLevel — pure refactor, byte-identical arithmetic.

Deliberately did NOT share tierForCombinedLevel's switch into twinbee: its
default arm is worldBossMinTier, a world-boss knob, and coupling twinbee's
tier floor to it would let a world-boss retune silently leak into TwinBee's
activity selection. The reducer is the safe shared piece; the tier thresholds
stay independent.

Suite green, combat golden byte-identical.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 18:27:32 -07:00
prosolis
e2c0c3359b N6/C3 review fixes: close the killed-boss-resolves-as-survived window
Two findings from an adversarial review of the C3 diff, both fixed:

1. (medium) A killing blow commits the shared pool to 0 inline, but the status
   flip to 'defeated' was deferred until AFTER the multi-second narration stream.
   In that window a crash/redeploy or the ticker's survive path could resolve a
   boss the town KILLED as a survival — debiting the pot and paying no bounties.
   Fix: resolve the defeat BEFORE streaming narration, and add a ticker safety
   net that resolves any active 0-HP boss as defeated (never falls through to the
   survive/pot-debit branch). Both guarded by setWorldBossStatus, so still once.

2. (minor) Pool damage was applied before the best-effort contrib/gate write, so
   a failed upsert let a player refight a pool they had already drained and lose
   the credit. Fix: write the contribution (which sets the once-per-day gate)
   BEFORE draining the pool, as a hard error that aborts the bout with the pool
   untouched.

Also corrected the applyWorldBossDamage comment (two concurrent killers CAN both
see killed=true; the status guard dedupes the payout — the old comment claimed
only one would). New ticker tests cover both resolution paths; suite green,
golden byte-identical.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 18:09:11 -07:00
prosolis
127d252982 N6/C3 W2: world boss — the daily bout, command, operator spawn
Wires the player-facing half of the Siege on top of W1's model + lifecycle.

- !adventure worldboss [status|fight|spawn] (alias !adventure siege). Status
  shows the shared-pool board + your daily bout state + the muster; fight
  takes today's bout; spawn is an admin override (the "both" spawn decision).
- The bout is an arena-style solo fight through runZoneCombat vs a disposable
  per-tier stat block; the damage dealt (EnemyEntryHP-EnemyEndHP) is subtracted
  from the shared pool atomically (MAX(0, …) WHERE status='active') win or lose.
  Real HP cost like the arena, but no death/no hospital: worldBossFloorHP
  raises a 0-HP loser to 1 so a loss reads as "battered", not a corpse.
- One bout per player per UTC day (worldBossBoutUsedToday off the contrib's
  last_fight_date). The per-user advUserLock serialises a player's own repeat
  submits so the gate can't be raced; cross-player pool-crossing is safe
  because only the setWorldBossStatus winner resolves a defeat.
- A killing bout trips resolveWorldBossDefeated (minted bounty by fights +
  cache + treasure roll), after the fighter's own bout DM has streamed.

Bout core (resolveWorldBossBout) + the once/day predicate + the HP floor are
factored out of the DM path so they're unit-tested end to end with a real
fightable character; the DM/games-room emission stays thin (no client stub).
Combat golden byte-identical; full plugin suite green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 17:59:17 -07:00
prosolis
c3e122694b N6/C3 W1: world boss — model, scaling, spawn + lifecycle
The monthly communal "Siege": a named boss camps outside town for 72h with
a single shared HP pool. This lands the model and the automatic lifecycle;
the player-facing bout command is W2.

- New tables world_boss + world_boss_contrib (own tables, outside the
  saveAdvCharacter fan-out, so a char save can't clobber the shared pool —
  the isolation adventure_shadow earns). Absent active row == no event; no
  bootstrap.
- Boss sized to the town it will fight: tier from the MEDIAN combined level
  of any-chat-active players (feedback_presence_is_any_chat, off
  daily_activity), pool HP = arena per-bout HP × ~2 bouts/active player,
  clamped [4,60] bouts. Floored at T3.
- Lifecycle rides the 1-min eventTicker (no net-new goroutine): auto-spawn
  on the 1st of each UTC month (JobCompleted dedup) + resolve a lapsed
  window as a survival. Operator override + the daily bout are W2.
- Resolution: defeat mints a bounty scaled by fights fought (not damage —
  accessibility) + a consumable cache + one low-rate treasure roll each;
  survival debits 20% of the community pot as a tribute (a pot sink). Both
  close-outs are guarded on status='active' so they fire once.
- Announcements post to the games room (no-op when GAMES_ROOM unset).

Pure logic (median, tier bucket, HP scaling, pool subtract/clamp, payout
split) is unit-tested; euro/DM emission left thin per the repo's no-client
-stub convention. Combat golden byte-identical (never touches SimulateCombat);
full plugin suite green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 17:50:56 -07:00
prosolis
cc165bed1f N6/D3: the Shadow — a simulated rival adventurer
A per-player NPC rival who "runs" the same zone progression on a midnight
ticker at ~1.3x the player's own clear pace, staying just ahead so it's a
race you can always see and nearly catch. Pure theatre: no combat, no
punishment, only race pressure and two payoffs at each zone clear.

- New adventure_shadow table, deliberately OUTSIDE the player_meta save
  fan-out so a character save can never clobber the ticker's advance (the
  isolation journal_pages earns by being grant-only, made structural). No
  bootstrap: absent row == no Shadow, minted lazily on first advance.
- midnightReset advances every player's Shadow once per UTC day (own
  idempotency guard); lead-capped so it never runs >2.5 zones ahead. When it
  clears a zone the player hasn't, it leaves a journal page waiting (D1 tie-in).
- Morning-briefing race-pressure one-liners (TwinBee voice, deterministic).
- Zone-clear payoff in finalizeExpeditionOnZoneClear: a bonus-XP crow when the
  player got there first, or the Shadow's waiting page when it did.
- !adventure shadow status view.

Review fixes (3 finders + verify) folded in before commit:
- Crow XP is now set-once per zone (crowed_mask), so re-running a zone the
  Shadow hasn't reached can't farm it.
- The waiting page is granted BEFORE the pending bit is retired, so a transient
  grant failure leaves the debt for the next clear instead of swallowing a page.
- The crow line no longer claims "+XP" when the grant errored.

Combat golden byte-identical (Shadow never touches SimulateCombat); go
build/vet/test green repo-wide.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 17:26:16 -07:00
prosolis
81b2359109 N5 review fixes: protect keys from Robbie, fire epilogue on autopilot, atomic finale reward
Five correctness fixes from a code review of the N5 branch:

- Robbie no longer sweeps/sells cross-zone keys (Type "key"), which
  permanently broke the vault unlock they exist to open.
- Robbie's gift tier now reads the canonical DnD level, not the frozen
  legacy CombatLevel that pegged every gift at tier 1.
- Boss epilogue (D1b) now fires on the compact autopilot boss resolve —
  the primary long-expedition path — not just manual !fight. Deduped the
  two manual sites into a shared writeBossEpilogue helper.
- Finale reward latches epilogue_cleared before granting the Legendary +
  title, so a failed/late write can't make the reward repeatable.
- Misty arc beat's occupied-slot guard moved above the counter increment,
  so a contended pending slot defers the encounter instead of consuming a
  5/15/30 beat forever.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 16:53:32 -07:00
prosolis
3103b519fb N5/D2: NPC arcs — Misty stages, Robbie's gift, Thom's final letter
Three flavor arcs on existing per-player counters (no schema, golden
byte-identical):

- Misty: 3 deepening dialogue beats at MistyEncounterCount 5/15/30,
  prepended to the encounter opening the one time the counter lands on a
  threshold. Fiction only — no copy ties a donation to the hidden arena
  effects.
- Robbie: every 10th visit he leaves a consumable "for the trouble,"
  drawn from the dungeon pool at a level-matched tier.
- Thom: paying off the Tier-4 mortgage (no next tier) sends a final
  letter instead of the stock payoff line, plus an inert pet-treat
  keepsake if the player keeps a pet.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 16:12:53 -07:00
prosolis
c37c95a3e3 N5/D4: secret content pass — treasure-cache secret rooms + cross-zone keys
Secret rooms were dead content: every NodeKindSecret node silently
collapsed to a normal exploration fight and its authored LootBias
(1.5-3.0) was never read at runtime. D4 makes them what they read as —
no-combat treasure caches.

resolveRoom now diverts a secret node (keyed off the graph node, since
CurrentRoomType has already lost the kind) to resolveSecretRoom before
the RoomType switch — shared by manual !zone advance, !expedition run
autopilot, and the sim. Each secret pays a guaranteed journal page
(the D1a grant hook built "for secret rooms"), a LootBias-weighted
treasure roll (floored at elite weight), and a guaranteed zone-tier
consumable cache, with bespoke in-world discovery flavor.

Underdark was the only T2+ zone with no secret; added at throne_gallery
on the universal R4 tail (Lost Reliquary, Perception DC 17), merging to
throne_steps so it's length-neutral.

Two cross-zone keys: the Sunken Temple's Coral Reliquary grants a Sunken
Sigil that opens a Sealed Reliquary in Manor Blackspire; the Underforge's
Forge Vault grants an Underforge Seal that opens a Sealed Vault in the
Underdark. Keys are persistent inventory items matched against LockKey
key_id; grants are idempotent.

Graph validator + no-soft-lock pass on both touched graphs; combat golden
byte-identical; go build/vet/test green repo-wide.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 16:02:15 -07:00
prosolis
9b6c1ff9a4 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
2026-07-10 15:37:53 -07:00
prosolis
aab7a7bad0 N5/D1b: boss epilogues + TwinBee's journal reactions
- Boss epilogues: a 2-3 sentence campaign capstone per zone boss, tying
  each kill to the Hollow King arc. Appended to the boss-down moment in
  both close-out paths (finishCombatSession solo, finishPartyWin party),
  gated on the boss room (!elite) so it fires for any boss kill —
  expedition or legacy !zone — and never for elites or the arena (which
  has no ZoneID entry). Forest of Shadows is the King himself; its
  epilogue frames the fall as a shed shell, leaving the arc for the finale.
- TwinBee digest reactions: a journal page found mid-expedition writes a
  "journal" log beat; the end-of-day digest emits one first-person,
  deterministically-picked TwinBee line reacting to the day's pages. No
  net-new DM — it rides the existing night-camp digest.

Golden byte-identical; go test ./... green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 15:26:20 -07:00
prosolis
fd7803b13c 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
2026-07-10 15:20:09 -07:00
36 changed files with 4624 additions and 34 deletions

View File

@@ -378,6 +378,21 @@ 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_flags_json 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`,
// 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 {
@@ -1595,6 +1610,20 @@ CREATE TABLE IF NOT EXISTS adventure_rival_challenges (
);
CREATE INDEX IF NOT EXISTS idx_rival_challenges_user ON adventure_rival_challenges(challenged_id, expires_at);
-- Duels (N6/C2): player-initiated, staked, no-death combat bouts. Both stakes
-- are escrowed while the row lives; expiry/decline refunds the challenger. W/L
-- history reuses adventure_rival_records. No bootstrap — absent row == no duel.
CREATE TABLE IF NOT EXISTS adventure_duel_challenges (
challenge_id TEXT PRIMARY KEY,
challenger_id TEXT NOT NULL,
challenged_id TEXT NOT NULL,
stake INTEGER NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_duel_challenges_user ON adventure_duel_challenges(challenged_id, expires_at);
CREATE INDEX IF NOT EXISTS idx_duel_challenges_expiry ON adventure_duel_challenges(expires_at);
CREATE TABLE IF NOT EXISTS community_pot (
id INTEGER PRIMARY KEY DEFAULT 1,
balance INTEGER NOT NULL DEFAULT 0,
@@ -2200,6 +2229,73 @@ CREATE TABLE IF NOT EXISTS expedition_invite (
);
CREATE INDEX IF NOT EXISTS idx_expedition_invite_user
ON expedition_invite(user_id);
-- ── N6/D3 — the Shadow: a simulated rival adventurer ──────────────────────
-- One row per player, born lazily the first midnight after their character
-- exists. The Shadow "runs" the same zone progression on a simulated schedule
-- (~1.3x the player's own clear pace), advanced once per UTC day by the
-- midnight ticker. Pure theatre: no combat, no punishment — only race
-- pressure surfaced in the morning briefing and a payoff at each zone clear.
--
-- Absent == the player has no Shadow yet, which is true of every row that
-- existed before N6, so there is nothing to backfill. The ticker mints the
-- row on first advance. This table is deliberately NOT part of the
-- player_meta save fan-out: the ticker owns it, so a character save can never
-- clobber the Shadow's advance (the same isolation journal_pages earns by
-- being grant-only, made structural here).
--
-- name: the rival's proper name, seeded deterministically from the
-- player's display name at birth.
-- progress: cumulative zone-units the Shadow has run (fractional, so a
-- slow player still sees it creep between clears).
-- zones_cleared: floor(progress) at the last advance — the committed count,
-- stored so the next advance can tell which zones were newly
-- finished.
-- pending_mask: zones (by progression index bit) the Shadow cleared before
-- the player did — a journal page waits in each until the
-- player clears that zone's boss.
-- crowed_mask: zones the player beat the Shadow to and has already been
-- crowed a bonus for — set-once, so re-running a zone the
-- Shadow hasn't reached can't farm the crow XP.
-- day_counter: the Shadow's own day count, for flavour variety.
-- last_advanced: UTC date of the last advance; the per-day idempotency guard.
CREATE TABLE IF NOT EXISTS adventure_shadow (
user_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
progress REAL NOT NULL DEFAULT 0,
zones_cleared INTEGER NOT NULL DEFAULT 0,
pending_mask INTEGER NOT NULL DEFAULT 0,
crowed_mask INTEGER NOT NULL DEFAULT 0,
day_counter INTEGER NOT NULL DEFAULT 0,
last_advanced TEXT NOT NULL DEFAULT '',
born_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- World boss (N6/C3) — the monthly communal "Siege". One event at a time is
-- live (status='active'); the ticker resolves it at hp_current<=0 (defeated) or
-- after ends_at (survived). History rows are retained (autoincrement id), so
-- world_boss_contrib keys on boss_id. Deliberately its own tables, outside the
-- player_meta save fan-out, so a character save can't clobber the shared pool.
CREATE TABLE IF NOT EXISTS world_boss (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
tier INTEGER NOT NULL DEFAULT 5,
hp_max INTEGER NOT NULL,
hp_current INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
starts_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
ends_at DATETIME NOT NULL,
resolved_at DATETIME
);
CREATE TABLE IF NOT EXISTS world_boss_contrib (
boss_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
fights INTEGER NOT NULL DEFAULT 0,
damage INTEGER NOT NULL DEFAULT 0,
last_fight_date TEXT NOT NULL DEFAULT '',
PRIMARY KEY (boss_id, user_id)
);
`
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.

View File

@@ -169,6 +169,7 @@ func (p *AdventurePlugin) Commands() []CommandDef {
{Name: "rest", Description: "Rest up (`short`: quick breather, 1h cooldown — `long`: full night, 24h, needs housing or inn)", Usage: "!rest short|long", Category: "Games"},
{Name: "arm", Description: "Ready an ability so it fires the moment your next fight starts", Usage: "!arm <ability>", Category: "Games"},
{Name: "roll", Description: "Roll dice (NdN+M format, e.g. 2d6+3, d20, 4d6-1)", Usage: "!roll <dice>", Category: "Games"},
{Name: "duel", Description: "Challenge another adventurer to a staked, no-death bout", Usage: "!duel @user [stake]", Category: "Games"},
{Name: "stats", Description: "Show your ability scores", Usage: "!stats", Category: "Games"},
{Name: "level", Description: "Show your level and XP progress", Usage: "!level", Category: "Games"},
{Name: "cast", Description: "Cast a spell (queues for next combat, or resolves now if out of combat)", Usage: "!cast <spell> [--upcast N] [--target @user]", Category: "Games"},
@@ -337,6 +338,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "rest") {
return p.handleDnDRestCmd(ctx, p.GetArgs(ctx.Body, "rest"))
}
if p.IsCommand(ctx.Body, "duel") {
return p.handleDuelCmd(ctx, p.GetArgs(ctx.Body, "duel"))
}
if p.IsCommand(ctx.Body, "arm") {
return p.handleDnDArmCmd(ctx, p.GetArgs(ctx.Body, "arm"))
}
@@ -513,6 +517,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
return p.SendDM(ctx.Sender, advHelpText)
case lower == "rivals":
return p.handleRivalsCmd(ctx)
case lower == "duel" || strings.HasPrefix(lower, "duel "):
return p.handleDuelCmd(ctx, strings.TrimSpace(args[len("duel"):]))
case lower == "babysit" || strings.HasPrefix(lower, "babysit "):
return p.handleBabysitCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "babysit")))
case lower == "blacksmith" || lower == "repair":
@@ -533,6 +539,14 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
return p.handleTreasuresCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "treasures")))
case lower == "vault" || strings.HasPrefix(lower, "vault "):
return p.handleVaultCmd(ctx, strings.TrimSpace(args[len("vault"):]))
case lower == "journal":
return p.handleJournalCmd(ctx)
case lower == "shadow":
return p.handleShadowCmd(ctx)
case lower == "worldboss" || strings.HasPrefix(lower, "worldboss "):
return p.handleWorldBossCmd(ctx, strings.TrimSpace(args[len("worldboss"):]))
case lower == "siege" || strings.HasPrefix(lower, "siege "):
return p.handleWorldBossCmd(ctx, strings.TrimSpace(args[len("siege"):]))
}
return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.")
@@ -554,6 +568,7 @@ const advHelpText = `**Adventure Commands**
` + "`!adventure leaderboard`" + ` — View the leaderboard
` + "`!adventure respond`" + ` — Respond to a mid-day event
` + "`!adventure rivals`" + ` — View rival duel records
` + "`!duel @user [stake]`" + ` — Challenge another adventurer to a staked, no-death bout (` + "`!duel accept`" + `/` + "`decline`" + `/` + "`status`" + `)
` + "`!adventure babysit`" + ` — Adventurer Babysitting Service
` + "`!adventure babysit auto`" + ` — Toggle auto-babysit (protects streaks on missed days)
` + "`!adventure babysit focus <skill>`" + ` — Pick the skill auto-babysit trains (mining/fishing/foraging)
@@ -564,6 +579,9 @@ const advHelpText = `**Adventure Commands**
` + "`!adventure recipes`" + ` — List crafting recipes known at your current Foraging level
` + "`!adventure mastery`" + ` — Per-slot equipment mastery progress and active bonus
` + "`!adventure treasures`" + ` — List your treasures · ` + "`treasures lock`" + ` to refuse swaps
` + "`!adventure journal`" + ` — Read the campaign pages you've recovered
` + "`!adventure shadow`" + ` — See how your rival's run compares to yours
` + "`!adventure worldboss`" + ` — The monthly Siege: one communal bout a day (` + "`fight`" + ` to join)
` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead)
` + "`!thom`" + ` — Visit Thom Krooke (housing and loans)
` + "`!adventure help`" + ` — This message

View File

@@ -111,6 +111,13 @@ type AdventureCharacter struct {
CraftsSucceeded int
DeathSource string // "adventure" | "arena" | "" (legacy/unknown — treated as adventure)
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
// 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 {

View File

@@ -0,0 +1,701 @@
package plugin
// C2 — player-initiated duels (engagement plan N6).
//
// `!duel @user [stake]` — a staked, no-death arena bout between two real
// characters, resolved through the auto-resolve combat engine. Both players
// escrow the stake on accept; the winner takes 70% of the pooled 2×stake and
// the community pot rakes the remaining 30% (a real pot sink). Records land in
// adventure_rival_records, so one W/L history and one 7-day per-pair cooldown
// covers both duel kinds (the bot-driven RPS rival and this).
//
// Why this is a wrapper and not a combat-engine change: the turn engine is an
// N-players-vs-1-monster model — there is no seat for a second real character
// on the enemy side (see the C2 feasibility note in the engagement plan). So a
// duel does not seat two PCs against each other. Instead each fighter is built
// as their real player Combatant (full kit: weapon, subclass passives, extra
// attacks, race traits) and the *opponent* is synthesised as a monster-shaped
// enemy stat block from their own sheet. That is asymmetric — the "player" side
// runs its full kit while the synthesised enemy is a flat stat block — so a
// single bout would systematically favour whoever is the attacker. The duel
// runs the bout in BOTH orientations and decides on the aggregate HP margin,
// which cancels that edge symmetrically. To-hit is faithful either way (both
// sides roll d20 + AttackBonus vs AC); only damage-per-hit is folded into the
// synthesised enemy's flat Attack. Class-vs-class PvP balance is explicitly out
// of scope (bragging rights, capped stakes).
import (
"fmt"
"math"
"math/rand/v2"
"strings"
"time"
"gogobee/internal/db"
"github.com/google/uuid"
"maunium.net/go/mautrix/id"
)
const (
duelMinLevel = 3 // both duellists must be at least this level
duelAcceptWindow = 24 * time.Hour // an unanswered challenge expires and refunds
duelStakePerLvl = 500 // stake cap = challenger level × this
duelMinStake = 100 // floor so a "duel" always has something on it
duelWinnerPct = 0.70 // winner's cut of the pooled 2×stake; pot rakes the rest
)
// advDuelChallenge is one outstanding duel invitation. Unlike the RPS rival
// challenge it carries no per-round score — a combat duel resolves in a single
// step on accept. The challenger's stake is already escrowed (debited) when the
// row exists; the challenged player's is debited on accept.
type advDuelChallenge struct {
ChallengeID string
ChallengerID id.UserID
ChallengedID id.UserID
Stake int
ExpiresAt time.Time
CreatedAt time.Time
}
// ── Persistence ──────────────────────────────────────────────────────────────
func insertDuelChallenge(c *advDuelChallenge) {
db.Exec("duel: insert challenge",
`INSERT INTO adventure_duel_challenges
(challenge_id, challenger_id, challenged_id, stake, expires_at)
VALUES (?, ?, ?, ?, ?)`,
c.ChallengeID, string(c.ChallengerID), string(c.ChallengedID), c.Stake, c.ExpiresAt,
)
}
func deleteDuelChallenge(challengeID string) {
db.Exec("duel: delete challenge",
`DELETE FROM adventure_duel_challenges WHERE challenge_id = ?`, challengeID)
}
// claimDuelChallenge atomically deletes the row and reports whether THIS caller
// is the one that removed it. It is the single serialization point for a
// challenge's terminal fate: accept, decline and the expiry ticker all race to
// claim it, and exactly one wins. Only the winner moves euros — that is what
// stops a stake being both resolved and refunded across the 24h boundary, or a
// double `!duel decline` double-refunding. Mirrors communityPotDebit's
// RowsAffected gate.
func claimDuelChallenge(challengeID string) bool {
res := db.ExecResult("duel: claim challenge",
`DELETE FROM adventure_duel_challenges WHERE challenge_id = ?`, challengeID)
if res == nil {
return false
}
n, _ := res.RowsAffected()
return n == 1
}
func scanDuelChallenge(row interface{ Scan(...any) error }) (*advDuelChallenge, error) {
c := &advDuelChallenge{}
err := row.Scan(&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake, &c.ExpiresAt, &c.CreatedAt)
if err != nil {
return nil, err
}
return c, nil
}
// pendingDuelForChallenged returns the live (unexpired) challenge awaiting this
// player's answer, or nil. Expired rows are ignored here and swept by the
// ticker, so a player is never shown a challenge they can no longer accept.
func pendingDuelForChallenged(userID id.UserID) *advDuelChallenge {
c := &advDuelChallenge{}
err := db.Get().QueryRow(`
SELECT challenge_id, challenger_id, challenged_id, stake, expires_at, created_at
FROM adventure_duel_challenges
WHERE challenged_id = ? AND expires_at > CURRENT_TIMESTAMP
ORDER BY created_at DESC LIMIT 1`, string(userID)).Scan(
&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake, &c.ExpiresAt, &c.CreatedAt)
if err != nil {
return nil
}
return c
}
// duelInvolves reports whether the player has any live challenge in flight, as
// challenger or challenged. Used to enforce one duel at a time per player — the
// escrow makes overlapping challenges a way to double-commit the same euros.
func duelInvolves(userID id.UserID) bool {
var n int
db.Get().QueryRow(`
SELECT COUNT(*) FROM adventure_duel_challenges
WHERE (challenger_id = ? OR challenged_id = ?) AND expires_at > CURRENT_TIMESTAMP`,
string(userID), string(userID)).Scan(&n)
return n > 0
}
func loadExpiredDuelChallenges() []advDuelChallenge {
rows, err := db.Get().Query(`
SELECT challenge_id, challenger_id, challenged_id, stake, expires_at, created_at
FROM adventure_duel_challenges WHERE expires_at <= CURRENT_TIMESTAMP`)
if err != nil {
return nil
}
defer rows.Close()
var out []advDuelChallenge
for rows.Next() {
if c, err := scanDuelChallenge(rows); err == nil {
out = append(out, *c)
}
}
return out
}
// ── Stake / gating ───────────────────────────────────────────────────────────
// duelMaxStake caps the stake at level × duelStakePerLvl so a strong player
// can't farm a runaway pot off a weaker one, per the plan's imbalance cap.
func duelMaxStake(level int) int { return level * duelStakePerLvl }
// duelPayout splits the pooled 2×stake: the winner takes duelWinnerPct, the
// community pot rakes the remainder. Both fighters escrowed stake, so the
// winner's net gain is winnerShare stake and the pot is a real sink.
func duelPayout(stake int) (winnerShare, potShare int) {
pool := stake * 2
winnerShare = int(math.Round(float64(pool) * duelWinnerPct))
potShare = pool - winnerShare
return
}
// duelEligible reports whether a character can duel at all: alive, set up, and
// at least duelMinLevel. Returns a player-facing reason when it can't.
func (p *AdventurePlugin) duelEligible(char *AdventureCharacter) (int, string) {
if char == nil {
return 0, "no adventurer"
}
if !char.Alive {
return 0, "dead"
}
lvl := rivalLevelForUser(char)
if lvl < duelMinLevel {
return lvl, fmt.Sprintf("below level %d", duelMinLevel)
}
return lvl, ""
}
// ── Combat synthesis ─────────────────────────────────────────────────────────
// buildDuelist derives a fighter's full player Combatant — the same build a
// zone encounter uses, so weapon, subclass passives, extra attacks and race
// traits all apply — then resets it to full health. A duel is a fresh arena
// bout, not a continuation of a wounded expedition, so wound carry-over is
// dropped (StartHP=0 = "enter at MaxHP"). Mood 50 is the neutral band: no
// initiative bias, no enemy-attack tilt.
//
// The armed-ability slot is left empty: actively-armed one-shots (a Berserker's
// rage) don't fire, but always-on passives (Extra Attack, Sneak Attack, Divine
// Strike, race traits) still layer in via the class/subclass passive builders.
func (p *AdventurePlugin) buildDuelist(userID id.UserID) (Combatant, error) {
var noMonster DnDMonsterTemplate
player, _, _, err := p.buildZoneCombatants(userID, noMonster, 1, 50, "")
if err != nil {
return Combatant{}, err
}
player.Stats.StartHP = 0
return player, nil
}
// duelPerHitDamage is a fighter's expected damage on a single landed hit, used
// to size the flat Attack of their synthesised enemy stat block. It mirrors the
// player damage path's terms that the enemy path can't express: weapon dice
// average (or the legacy flat Attack for a weaponless build), the always-on
// per-hit riders (Divine Strike, Sneak Attack, Hunter's Mark, rage), then the
// multiplicative damage bonus. Crit chance and once-per-fight openers are not
// modelled — a coarse mean is enough for a capped-stakes bout decided over two
// orientations.
func duelPerHitDamage(pc Combatant) float64 {
base := float64(pc.Stats.Attack)
if w := pc.Stats.Weapon; w != nil {
if pc.Stats.TwoHandedMode && w.HasProperty(PropVersatile) && w.VersaCount > 0 {
base = float64(w.VersaCount)*(float64(w.VersaSides)+1)/2 +
float64(pc.Stats.AbilityModForDamage) + float64(w.MagicBonus)
} else {
base = avgWeaponDamage(w, pc.Stats.AbilityModForDamage)
}
}
riders := float64(pc.Mods.DivineStrikePerHit)
riders += float64(pc.Mods.SneakAttackDie) * 3.5
riders += float64(pc.Mods.HuntersMarkDie) * 3.5
if pc.Mods.BerserkerRage {
riders += float64(pc.Mods.RageMeleeDmg)
}
perHit := (base + riders) * (1 + pc.Mods.DamageBonus)
if perHit < 1 {
perHit = 1
}
return perHit
}
// duelPerRoundProcDamage is the expected per-round damage from a fighter's
// companion strikes — a Cleric's spiritual weapon and a Beastmaster's pet —
// which land once per round on a proc roll rather than per weapon hit. The live
// fighter rolls these natively in the engine; folding their expectation into the
// synth enemy's round damage keeps both orientations comparable for those
// builds (each formula is Dmg + d5, mean +3).
func duelPerRoundProcDamage(pc Combatant) float64 {
dmg := pc.Mods.SpiritWeaponProc * (float64(pc.Mods.SpiritWeaponDmg) + 3)
dmg += pc.Mods.PetAttackProc * (float64(pc.Mods.PetAttackDmg) + 3)
return dmg
}
// synthDuelEnemy turns a fighter's player Combatant into the monster-shaped
// enemy the opposite fighter faces. HP, AC and AttackBonus carry over verbatim
// (to-hit stays faithful — the enemy rolls d20 + AttackBonus vs the attacker's
// AC exactly as the player does). DamageReduct carries over too, so a
// Barbarian's / Monk's flat mitigation is as tanky as the enemy as it is as the
// live PC. Offense is the lossy term: the enemy path deals a flat Attack, so a
// whole round — weapon swings (per-hit × (1 + extra attacks)) plus the expected
// companion-strike damage — is folded into one enemy Attack, preserving expected
// round damage while widening variance (a wash across the two orientations).
//
// Deliberately NOT modelled on the enemy side (accepted scope — the plan frames
// duels as bragging rights and puts class-vs-class PvP balance explicitly out of
// scope): reactive one-shots the enemy engine has no channel for — heal items,
// wards, the Sovereign death-save, the arcane ward buffer — and spell damage,
// which buildZoneCombatants never wires for the live caster either, so a caster
// fights weapon-only in both orientations. Defense is dropped: the weapon-damage
// path players use reads DamageReduct, not the enemy's Defense stat.
func synthDuelEnemy(pc Combatant) Combatant {
swings := 1 + pc.Mods.ExtraAttacks
roundDmg := duelPerHitDamage(pc)*float64(swings) + duelPerRoundProcDamage(pc)
return Combatant{
Name: pc.Name,
IsPlayer: false,
Stats: CombatStats{
MaxHP: pc.Stats.MaxHP,
AC: pc.Stats.AC,
AttackBonus: pc.Stats.AttackBonus,
Attack: int(math.Round(roundDmg)),
},
Mods: CombatModifiers{DamageReduct: pc.Mods.DamageReduct},
}
}
// duelResult is the two-orientation verdict. challengerMargin/challengedMargin
// are aggregate remaining-HP fractions across both bouts (0..2 each); the higher
// wins. decisive is true when one fighter won both orientations outright — pure
// narration, the margin is what decides.
type duelResult struct {
challengerWins bool
draw bool
decisive bool
challengerFrac float64
challengedFrac float64
}
func hpFraction(end, start int) float64 {
if start <= 0 {
return 0
}
if end < 0 {
end = 0
}
return float64(end) / float64(start)
}
// resolveDuel runs the bout both ways and decides on the aggregate HP margin.
// Orientation 1 seats the challenger as the live fighter against the challenged
// player's stat block; orientation 2 swaps them. Each fighter's margin sums the
// HP fraction they kept as the live fighter in one bout and as the stat block in
// the other, so the attacker's-kit advantage is applied to both fighters once
// and cancels. rng is threaded for the seeded characterization/unit tests; nil
// is production (package-global RNG).
func resolveDuel(challenger, challenged Combatant, rng *rand.Rand) duelResult {
r1 := simulateCombatWithRNG(challenger, synthDuelEnemy(challenged), defaultCombatPhases, rng)
r2 := simulateCombatWithRNG(challenged, synthDuelEnemy(challenger), defaultCombatPhases, rng)
cFrac := hpFraction(r1.PlayerEndHP, r1.PlayerStartHP) + hpFraction(r2.EnemyEndHP, r2.EnemyStartHP)
dFrac := hpFraction(r1.EnemyEndHP, r1.EnemyStartHP) + hpFraction(r2.PlayerEndHP, r2.PlayerStartHP)
res := duelResult{challengerFrac: cFrac, challengedFrac: dFrac}
switch {
case cFrac > dFrac:
res.challengerWins = true
case dFrac > cFrac:
res.challengerWins = false
default:
res.draw = true
}
// Decisive = the winner also took both orientations outright.
if !res.draw {
if res.challengerWins {
res.decisive = r1.PlayerWon && !r2.PlayerWon
} else {
res.decisive = r2.PlayerWon && !r1.PlayerWon
}
}
return res
}
// ── Command surface ──────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleDuelCmd(ctx MessageContext, args string) error {
args = strings.TrimSpace(args)
fields := strings.Fields(args)
if len(fields) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
"⚔️ **Duels** — challenge another adventurer to a staked, no-death bout.\n"+
"`!duel @user [stake]` — throw down · `!duel accept` / `!duel decline` · `!duel status`")
}
switch strings.ToLower(fields[0]) {
case "accept":
return p.acceptDuel(ctx)
case "decline":
return p.declineDuel(ctx)
case "status":
return p.duelStatusCmd(ctx)
default:
return p.issueDuel(ctx, fields)
}
}
func (p *AdventurePlugin) issueDuel(ctx MessageContext, fields []string) error {
targetID, ok := p.ResolveUser(fields[0], ctx.RoomID)
if !ok {
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve that user. Usage: `!duel @user [stake]`")
}
if targetID == ctx.Sender {
return p.SendReply(ctx.RoomID, ctx.EventID, "You can't duel yourself.")
}
// Serialise the one-duel gate, balance check and escrow so two rapid `!duel`
// from the same challenger can't both pass duelInvolves and double-debit.
lock := p.advUserLock(ctx.Sender)
lock.Lock()
defer lock.Unlock()
challenger, err := loadAdvCharacter(ctx.Sender)
if err != nil || challenger == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no adventurer. Type `!adventure` to create one.")
}
lvl, reason := p.duelEligible(challenger)
if reason != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, duelIneligibleSelf(reason))
}
challenged, err := loadAdvCharacter(targetID)
if err != nil || challenged == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s has no adventurer to duel.", p.DisplayName(targetID)))
}
if _, reason := p.duelEligible(challenged); reason != "" {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("%s can't duel right now (%s).", p.DisplayName(targetID), reason))
}
if challenged.BabysitActive {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("%s left word with the Babysitting Service — no duels while they're away.", p.DisplayName(targetID)))
}
// One duel at a time per player, either side — the escrow makes overlaps a
// double-commit of the same euros.
if duelInvolves(ctx.Sender) {
return p.SendReply(ctx.RoomID, ctx.EventID, "You already have a duel in flight. Resolve it first.")
}
if duelInvolves(targetID) {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s already has a duel in flight.", p.DisplayName(targetID)))
}
// Shared 7-day per-pair cooldown across both duel kinds (records table).
if last := lastDuelBetween(ctx.Sender, targetID); !last.IsZero() && time.Since(last) < rivalSamePairCooldown {
wait := rivalSamePairCooldown - time.Since(last)
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("You've dueled %s too recently. Try again %s.", p.DisplayName(targetID), formatDuration(wait)))
}
stake, err := p.parseDuelStake(fields, lvl)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, err.Error())
}
if bal := p.euro.GetBalance(ctx.Sender); int(bal) < stake {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("You can't cover a €%d stake — you have €%d.", stake, int(bal)))
}
// Escrow the challenger's stake now (debited to nowhere; the pot/winner split
// redistributes it on resolution, and expiry/decline refunds it).
if !p.euro.Debit(ctx.Sender, float64(stake), "duel_escrow") {
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't hold your stake. Try again.")
}
ch := &advDuelChallenge{
ChallengeID: uuid.NewString(),
ChallengerID: ctx.Sender,
ChallengedID: targetID,
Stake: stake,
ExpiresAt: time.Now().UTC().Add(duelAcceptWindow),
}
insertDuelChallenge(ch)
p.SendDM(targetID, fmt.Sprintf(
"⚔️ **%s challenges you to a duel!**\n\nStake: €%d each — winner takes 70%% of the pot.\n"+
"No death, no hospital bill. Just bragging rights.\n\n`!duel accept` or `!duel decline` — you have 24 hours.",
p.DisplayName(ctx.Sender), stake))
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
"⚔️ You throw down the gauntlet before **%s** for €%d. They have 24 hours to answer.",
p.DisplayName(targetID), stake))
}
// parseDuelStake reads the optional stake argument, defaulting to the cap for
// the challenger's level and clamping into [duelMinStake, duelMaxStake].
func (p *AdventurePlugin) parseDuelStake(fields []string, level int) (int, error) {
maxStake := duelMaxStake(level)
if len(fields) < 2 {
return maxStake, nil
}
raw := strings.TrimPrefix(fields[1], "€")
stake := 0
if _, err := fmt.Sscanf(raw, "%d", &stake); err != nil || stake <= 0 {
return 0, fmt.Errorf("that's not a valid stake. Usage: `!duel @user [stake]`")
}
if stake < duelMinStake {
return 0, fmt.Errorf("minimum stake is €%d", duelMinStake)
}
if stake > maxStake {
return 0, fmt.Errorf("your stake is capped at €%d (level %d × €%d)", maxStake, level, duelStakePerLvl)
}
return stake, nil
}
func (p *AdventurePlugin) acceptDuel(ctx MessageContext) error {
// Serialise a player's own accept so a double-submit can't debit twice.
lock := p.advUserLock(ctx.Sender)
lock.Lock()
defer lock.Unlock()
ch := pendingDuelForChallenged(ctx.Sender)
if ch == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no pending duel to accept.")
}
// Cheap validation first — these leave the challenge open (the player can
// revive/earn and retry, or decline to release the challenger's escrow).
challenged, err := loadAdvCharacter(ctx.Sender)
if err != nil || challenged == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no adventurer.")
}
if _, reason := p.duelEligible(challenged); reason != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, duelIneligibleSelf(reason))
}
if bal := p.euro.GetBalance(ctx.Sender); int(bal) < ch.Stake {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("You can't cover the €%d stake — you have €%d. `!duel decline` to back out.", ch.Stake, int(bal)))
}
// The challenger may have died or dropped below the gate during the 24h
// window. If so, cancel the duel: claim the row (so the ticker/decline can't
// also refund) and hand the challenger their escrow back.
challenger, cerr := loadAdvCharacter(ch.ChallengerID)
if cerr != nil || challenger == nil {
if claimDuelChallenge(ch.ChallengeID) {
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_challenger_gone")
}
return p.SendReply(ctx.RoomID, ctx.EventID, "Your challenger is no longer available. The duel is off.")
}
if _, reason := p.duelEligible(challenger); reason != "" {
if claimDuelChallenge(ch.ChallengeID) {
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_challenger_ineligible")
p.SendDM(ch.ChallengerID, fmt.Sprintf(
"⚔️ Your duel with **%s** was called off — you can't fight right now. Your €%d stake is refunded.",
p.DisplayName(ctx.Sender), ch.Stake))
}
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("**%s** can't duel right now (%s). The challenge is off.", p.DisplayName(ch.ChallengerID), reason))
}
// Build both fighters BEFORE debiting the challenged player, so the deep
// sheet-loading / combat code (the panic-prone part) runs while nobody but
// the challenger is committed. A build failure refunds only the challenger.
challengerC, err1 := p.buildDuelist(ch.ChallengerID)
challengedC, err2 := p.buildDuelist(ctx.Sender)
if err1 != nil || err2 != nil {
if claimDuelChallenge(ch.ChallengeID) {
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_error")
}
return p.SendReply(ctx.RoomID, ctx.EventID, "The duel couldn't be staged. The challenger's stake is refunded.")
}
// Commit: claim the row (exactly one path wins), then take the challenged
// player's stake. From here both stakes are held and resolution is pure
// arithmetic — no further build or DB read that could panic mid-payout.
if !claimDuelChallenge(ch.ChallengeID) {
return p.SendReply(ctx.RoomID, ctx.EventID, "That duel is no longer available.")
}
if !p.euro.Debit(ctx.Sender, float64(ch.Stake), "duel_escrow") {
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_error")
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't hold your stake. The duel is off.")
}
return p.settleDuel(ctx, ch, challengerC, challengedC)
}
func (p *AdventurePlugin) declineDuel(ctx MessageContext) error {
// Same lock as accept so a player's own accept/decline can't interleave; the
// claim is the real guard, but the lock keeps the read→claim window tight.
lock := p.advUserLock(ctx.Sender)
lock.Lock()
defer lock.Unlock()
ch := pendingDuelForChallenged(ctx.Sender)
if ch == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no pending duel to decline.")
}
if !claimDuelChallenge(ch.ChallengeID) {
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no pending duel to decline.")
}
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_declined")
p.SendDM(ch.ChallengerID, fmt.Sprintf(
"🛡️ **%s** declined your duel. Your €%d stake is refunded.", p.DisplayName(ctx.Sender), ch.Stake))
return p.SendReply(ctx.RoomID, ctx.EventID, "You decline the duel. No harm done.")
}
func (p *AdventurePlugin) duelStatusCmd(ctx MessageContext) error {
if ch := pendingDuelForChallenged(ctx.Sender); ch != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
"⚔️ **%s** has challenged you for €%d. `!duel accept` / `!duel decline` (expires %s).",
p.DisplayName(ch.ChallengerID), ch.Stake, formatDuration(time.Until(ch.ExpiresAt))))
}
// Any challenge we issued that's still pending?
c := &advDuelChallenge{}
err := db.Get().QueryRow(`
SELECT challenge_id, challenger_id, challenged_id, stake, expires_at, created_at
FROM adventure_duel_challenges
WHERE challenger_id = ? AND expires_at > CURRENT_TIMESTAMP
ORDER BY created_at DESC LIMIT 1`, string(ctx.Sender)).Scan(
&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake, &c.ExpiresAt, &c.CreatedAt)
if err == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
"⚔️ You've challenged **%s** for €%d. Waiting on their answer (expires %s).",
p.DisplayName(c.ChallengedID), c.Stake, formatDuration(time.Until(c.ExpiresAt))))
}
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no duels in flight. `!duel @user [stake]` to start one.")
}
// ── Resolution ───────────────────────────────────────────────────────────────
// settleDuel resolves the two-orientation bout, splits the pooled 2×stake,
// records the result, and DMs both players. By the time it runs the challenge
// row is claimed, both fighters are built, and both stakes are held — so from
// here it is pure arithmetic and message sends, nothing that can panic and
// strand the pool. On an exact HP-margin tie both stakes are refunded.
func (p *AdventurePlugin) settleDuel(ctx MessageContext, ch *advDuelChallenge, challengerC, challengedC Combatant) error {
res := resolveDuel(challengerC, challengedC, nil)
if res.draw {
// Exact HP-margin tie: refund both, record nothing, no cooldown burned.
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_draw")
p.euro.Credit(ch.ChallengedID, float64(ch.Stake), "duel_refund_draw")
draw := fmt.Sprintf("⚔️ **%s** and **%s** fought to a standstill. Both stakes refunded.",
p.DisplayName(ch.ChallengerID), p.DisplayName(ch.ChallengedID))
p.SendDM(ch.ChallengerID, draw)
p.SendDM(ch.ChallengedID, draw)
return p.SendReply(ctx.RoomID, ctx.EventID, draw)
}
winnerID, loserID := ch.ChallengedID, ch.ChallengerID
if res.challengerWins {
winnerID, loserID = ch.ChallengerID, ch.ChallengedID
}
winnerShare, potShare := duelPayout(ch.Stake)
p.euro.Credit(winnerID, float64(winnerShare), "duel_win")
if potShare > 0 {
communityPotAdd(potShare)
// The rake came out of a pool both funded; attribute it half each.
trackTaxPaid(winnerID, potShare/2)
trackTaxPaid(loserID, potShare-potShare/2)
}
upsertRivalRecord(winnerID, loserID, true)
upsertRivalRecord(loserID, winnerID, false)
p.announceDuel(ctx, winnerID, loserID, ch.Stake, winnerShare, potShare, res.decisive)
return nil
}
func (p *AdventurePlugin) announceDuel(ctx MessageContext, winnerID, loserID id.UserID, stake, winnerShare, potShare int, decisive bool) {
winName, loseName := p.DisplayName(winnerID), p.DisplayName(loserID)
flair := pickDuelFlavor(duelWinFlavor)
if decisive {
flair = pickDuelFlavor(duelDecisiveFlavor)
}
p.SendDM(winnerID, fmt.Sprintf(
"⚔️ **You beat %s!**\n\n*%s*\n\nYou take €%d (net +€%d). €%d went to the community pot.",
loseName, flair, winnerShare, winnerShare-stake, potShare))
p.SendDM(loserID, fmt.Sprintf(
"⚔️ **%s bested you.**\n\n*%s*\n\nYou're down €%d — but you walk away, no worse for wear.",
winName, pickDuelFlavor(duelLossFlavor), stake))
// Broadcast the result to the games room for bragging rights — unless the
// duel was accepted there, in which case the SendReply below already covers
// it and a second line would just be noise.
if gr := gamesRoom(); gr != "" && gr != ctx.RoomID {
verb := "defeated"
if decisive {
verb = "decisively defeated"
}
p.SendMessage(gr, fmt.Sprintf("⚔️ **%s** %s **%s** in a duel for €%d.", winName, verb, loseName, stake))
}
// Confirm in the room the challenge was accepted from too.
p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("⚔️ **%s** wins the duel, taking €%d.", winName, winnerShare))
}
// expireDuelChallenges refunds and clears any challenge whose 24h window lapsed.
// Rides the 1-minute event ticker (no new goroutine); only the challenger was
// escrowed, so only they are refunded.
func (p *AdventurePlugin) expireDuelChallenges() {
for _, ch := range loadExpiredDuelChallenges() {
// Claim before refunding: if an accept/decline already took this row at
// the 24h boundary, we must not also refund the challenger.
if !claimDuelChallenge(ch.ChallengeID) {
continue
}
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_expired")
p.SendDM(ch.ChallengerID, fmt.Sprintf(
"⌛ **%s** never answered your duel. Your €%d stake is refunded.",
p.DisplayName(ch.ChallengedID), ch.Stake))
}
}
func duelIneligibleSelf(reason string) string {
switch reason {
case "dead":
return "You can't duel while dead. Visit `!hospital` first."
case "no adventurer":
return "You have no adventurer. Type `!adventure` to create one."
default:
return fmt.Sprintf("You need to be at least level %d to duel.", duelMinLevel)
}
}
// ── Flavor ───────────────────────────────────────────────────────────────────
var duelWinFlavor = []string{
"Steel rang, and the crowd knew its champion.",
"A clean finish — you left them nothing to answer with.",
"They fought well. You fought better.",
}
var duelDecisiveFlavor = []string{
"It was over before it began. Utterly one-sided.",
"You never gave them a foothold. A rout.",
"The arena barely had time to hush.",
}
var duelLossFlavor = []string{
"You'll want that one back. Next time.",
"A hard lesson — but only your pride is bleeding.",
"Close, in places. Not close enough.",
}
func pickDuelFlavor(pool []string) string {
if len(pool) == 0 {
return ""
}
return pool[rand.IntN(len(pool))]
}

View File

@@ -0,0 +1,274 @@
package plugin
import (
"math"
"math/rand/v2"
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func newDuelTestDB(t *testing.T) {
t.Helper()
db.Close()
if err := db.Init(t.TempDir()); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
}
// duelSeed builds a deterministic RNG so the two-orientation resolver is
// reproducible across runs — the production path passes nil (global RNG).
func duelSeed(i uint64) *rand.Rand { return rand.New(rand.NewPCG(0x1d4e11, i)) }
func TestDuelPayout(t *testing.T) {
// Both escrow 2000 → pool 4000 → winner 70% (2800), pot 30% (1200).
w, pot := duelPayout(2000)
if w != 2800 || pot != 1200 {
t.Fatalf("duelPayout(2000) = (%d, %d), want (2800, 1200)", w, pot)
}
// The pot always gets exactly what the winner doesn't; nothing is minted
// or lost against the pooled 2×stake.
for _, stake := range []int{100, 137, 999, 6000} {
w, pot := duelPayout(stake)
if w+pot != stake*2 {
t.Errorf("duelPayout(%d): winner+pot = %d, want pool %d", stake, w+pot, stake*2)
}
if w <= pot {
t.Errorf("duelPayout(%d): winner %d should exceed pot %d", stake, w, pot)
}
}
}
func TestDuelMaxStake(t *testing.T) {
if got := duelMaxStake(5); got != 2500 {
t.Fatalf("duelMaxStake(5) = %d, want 2500", got)
}
}
func TestDuelPerHitDamage(t *testing.T) {
// 1d8 (avg 4.5) + ability mod 3 = 7.5 base, + Divine Strike 2 = 9.5,
// × (1 + 0.25 damage bonus) = 11.875.
pc := Combatant{
Stats: CombatStats{
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8},
AbilityModForDamage: 3,
},
Mods: CombatModifiers{DivineStrikePerHit: 2, DamageBonus: 0.25},
}
got := duelPerHitDamage(pc)
if math.Abs(got-11.875) > 1e-9 {
t.Fatalf("duelPerHitDamage = %v, want 11.875", got)
}
// Weaponless build falls back to the flat Attack stat.
bare := Combatant{Stats: CombatStats{Attack: 9}}
if got := duelPerHitDamage(bare); got != 9 {
t.Fatalf("weaponless duelPerHitDamage = %v, want 9", got)
}
// A build that computes to <1 is floored to 1 (a fight always deals damage).
weak := Combatant{Stats: CombatStats{Attack: 0}}
if got := duelPerHitDamage(weak); got != 1 {
t.Fatalf("floored duelPerHitDamage = %v, want 1", got)
}
}
func TestSynthDuelEnemy(t *testing.T) {
pc := Combatant{
Name: "Aria",
IsPlayer: true,
Stats: CombatStats{
MaxHP: 123,
AC: 17,
AttackBonus: 8,
Defense: 5,
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8},
AbilityModForDamage: 3,
},
Mods: CombatModifiers{ExtraAttacks: 1}, // two swings/round
}
e := synthDuelEnemy(pc)
if e.IsPlayer {
t.Error("synthesised enemy must not be flagged IsPlayer")
}
if e.Ability != nil {
t.Error("v1 synth enemy carries no MonsterAbility")
}
// HP / AC / to-hit carry over verbatim: to-hit stays faithful both ways.
if e.Stats.MaxHP != 123 || e.Stats.AC != 17 || e.Stats.AttackBonus != 8 {
t.Errorf("stat carryover wrong: got HP=%d AC=%d AB=%d", e.Stats.MaxHP, e.Stats.AC, e.Stats.AttackBonus)
}
// Damage: per-hit (7.5) × swings (2) folded into one flat Attack = 15.
if e.Stats.Attack != 15 {
t.Errorf("folded Attack = %d, want 15 (7.5 × 2 swings)", e.Stats.Attack)
}
}
// TestResolveDuel_StrongerWinsRegardlessOfRole pins that a clearly superior
// build wins whether it issues or receives the challenge — the two-orientation
// resolver must not hand the win to the attacker seat.
func TestResolveDuel_StrongerWinsRegardlessOfRole(t *testing.T) {
strong := Combatant{
Name: "Champion", IsPlayer: true,
Stats: CombatStats{
MaxHP: 220, AC: 19, AttackBonus: 12,
Weapon: &WeaponProfile{DamageCount: 2, DamageSides: 8}, AbilityModForDamage: 5, WeaponProficient: true,
},
}
weak := Combatant{
Name: "Novice", IsPlayer: true,
Stats: CombatStats{
MaxHP: 45, AC: 11, AttackBonus: 3,
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 4}, AbilityModForDamage: 0, WeaponProficient: true,
},
}
for i := uint64(0); i < 50; i++ {
if r := resolveDuel(strong, weak, duelSeed(i)); r.draw || !r.challengerWins {
t.Fatalf("seed %d: strong-as-challenger should win, got %+v", i, r)
}
if r := resolveDuel(weak, strong, duelSeed(i)); r.draw || r.challengerWins {
t.Fatalf("seed %d: strong-as-challenged should win, got %+v", i, r)
}
}
}
// TestResolveDuel_IdenticalFightersAreUnbiased is the fairness guarantee that
// justifies running both orientations: with two identical builds neither the
// challenger nor the challenged seat may have a systematic edge. A single-
// orientation model (attacker uses full kit, defender is a stat block) would
// skew hard toward the challenger; the both-orientations margin cancels it.
func TestResolveDuel_IdenticalFightersAreUnbiased(t *testing.T) {
fighter := Combatant{
Name: "Mirror", IsPlayer: true,
Stats: CombatStats{
MaxHP: 110, AC: 15, AttackBonus: 7,
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8}, AbilityModForDamage: 3, WeaponProficient: true,
},
}
const n = 1200
challengerWins, draws := 0, 0
for i := uint64(0); i < n; i++ {
r := resolveDuel(fighter, fighter, duelSeed(i))
switch {
case r.draw:
draws++
case r.challengerWins:
challengerWins++
}
}
decided := n - draws
if decided == 0 {
t.Fatal("all identical-fighter duels drew — resolver isn't discriminating")
}
// Unbiased by construction (challenger wins iff D1 > D2 for iid D); allow a
// generous ±10% band so this stays a bias check, not a flaky coin-count.
rate := float64(challengerWins) / float64(decided)
if rate < 0.40 || rate > 0.60 {
t.Fatalf("challenger win rate %.3f over %d decided duels — systematic bias (want ~0.5)", rate, decided)
}
}
// TestClaimDuelChallenge pins the serialization primitive that guards every
// euro path: exactly one caller may claim a challenge, so accept/decline/expire
// can never both move money for the same row.
func TestClaimDuelChallenge(t *testing.T) {
newDuelTestDB(t)
ch := &advDuelChallenge{
ChallengeID: "duel-1",
ChallengerID: id.UserID("@a:x"),
ChallengedID: id.UserID("@b:x"),
Stake: 1000,
ExpiresAt: time.Now().UTC().Add(time.Hour),
}
insertDuelChallenge(ch)
if pendingDuelForChallenged(id.UserID("@b:x")) == nil {
t.Fatal("challenge should be pending for the challenged player before any claim")
}
if !duelInvolves(id.UserID("@a:x")) || !duelInvolves(id.UserID("@b:x")) {
t.Fatal("duelInvolves should see both parties while the challenge is live")
}
if !claimDuelChallenge("duel-1") {
t.Fatal("first claim must succeed")
}
if claimDuelChallenge("duel-1") {
t.Fatal("second claim must fail — the row is gone, so no double refund/payout")
}
if pendingDuelForChallenged(id.UserID("@b:x")) != nil {
t.Fatal("claimed challenge must no longer be pending")
}
}
func TestLoadExpiredDuelChallenges(t *testing.T) {
newDuelTestDB(t)
insertDuelChallenge(&advDuelChallenge{
ChallengeID: "live", ChallengerID: "@a:x", ChallengedID: "@b:x",
Stake: 500, ExpiresAt: time.Now().UTC().Add(time.Hour),
})
insertDuelChallenge(&advDuelChallenge{
ChallengeID: "stale", ChallengerID: "@c:x", ChallengedID: "@d:x",
Stake: 500, ExpiresAt: time.Now().UTC().Add(-time.Hour),
})
got := loadExpiredDuelChallenges()
if len(got) != 1 || got[0].ChallengeID != "stale" {
t.Fatalf("loadExpiredDuelChallenges = %+v, want only the stale one", got)
}
}
// TestSynthDuelEnemy_PortsDefenseAndProcs pins the fidelity carry-overs added
// after review: a tank's DamageReduct rides onto the synth enemy, and companion
// procs fold their expected damage into its round Attack.
func TestSynthDuelEnemy_PortsDefenseAndProcs(t *testing.T) {
pc := Combatant{
Stats: CombatStats{
MaxHP: 100, AC: 15, AttackBonus: 6,
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8}, AbilityModForDamage: 2, // 6.5/hit
},
Mods: CombatModifiers{
DamageReduct: 0.6, // Barbarian-style mitigation
SpiritWeaponProc: 0.5, SpiritWeaponDmg: 4, // expected 0.5×(4+3)=3.5/round
},
}
e := synthDuelEnemy(pc)
if e.Mods.DamageReduct != 0.6 {
t.Errorf("DamageReduct not ported: got %v, want 0.6", e.Mods.DamageReduct)
}
// One swing (6.5) + proc expectation (3.5) = 10 → rounded 10.
if e.Stats.Attack != 10 {
t.Errorf("folded Attack = %d, want 10 (6.5 weapon + 3.5 proc)", e.Stats.Attack)
}
}
func TestParseDuelStake(t *testing.T) {
p := &AdventurePlugin{}
const level = 5 // cap = 2500
// No stake → default to the cap.
if got, err := p.parseDuelStake([]string{"@u"}, level); err != nil || got != 2500 {
t.Fatalf("default stake = (%d, %v), want (2500, nil)", got, err)
}
// Explicit in-band stake, with and without the € prefix.
if got, err := p.parseDuelStake([]string{"@u", "€1500"}, level); err != nil || got != 1500 {
t.Fatalf("€1500 stake = (%d, %v), want (1500, nil)", got, err)
}
// Over the cap is rejected.
if _, err := p.parseDuelStake([]string{"@u", "9999"}, level); err == nil {
t.Error("stake above cap should error")
}
// Below the floor is rejected.
if _, err := p.parseDuelStake([]string{"@u", "10"}, level); err == nil {
t.Error("stake below the minimum should error")
}
// Garbage is rejected.
if _, err := p.parseDuelStake([]string{"@u", "lots"}, level); err == nil {
t.Error("non-numeric stake should error")
}
}

View File

@@ -120,6 +120,14 @@ func (p *AdventurePlugin) eventTicker() {
// Reclaim invites nobody answered (N3/P6b). Every read already
// filters on the TTL; this just stops the rows accumulating.
purgeExpiredInvites()
// World boss (N6/C3): auto-spawn the monthly Siege on the 1st and
// resolve one whose 72h window has lapsed. Own dedup inside.
p.worldBossTick()
// Duels (N6/C2): refund and clear any challenge whose 24h accept
// window lapsed.
p.expireDuelChallenges()
}
}
}

View File

@@ -0,0 +1,519 @@
package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"gogobee/internal/db"
"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 ""
}
// If the page turned up mid-expedition, drop a log beat so the end-of-day
// digest can have TwinBee react to it. No expedition (legacy !zone, or a
// secret room opened outside a run) simply means no digest to react in.
if exp, err := getActiveExpedition(userID); err == nil && exp != nil {
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "journal",
journalPages[page-1].Title, fmt.Sprintf("page %d", page))
}
return fmt.Sprintf("📖 A torn journal page — _%s_ (page %d of %d). See `!adventure journal`.",
journalPages[page-1].Title, page, journalTotalPages)
}
// --- N5/D4 secret content pass ---------------------------------------------
//
// Every NodeKindSecret room resolves as a no-combat treasure cache (see
// resolveSecretRoom in dnd_zone_combat.go). Finding it — via the perception /
// stat check on the fork, or a cross-zone key — is the reward: a guaranteed
// journal page, a LootBias-weighted treasure roll, and a guaranteed consumable
// cache. The flavor and the key catalog live here with the rest of the
// campaign; the resolution mechanics live next to the other room resolvers.
// secretRoomCacheCount is how many zone-tier consumables a secret room always
// hands over, so the room pays out something visible even for a player who has
// every page and misses the treasure roll.
const secretRoomCacheCount = 2
// crossZoneKey pairs a source secret room with the key item it grants. The key
// is a plain inventory item; a LockKey edge in the destination zone matches its
// lower-cased Name against lock_data.key_id (see evaluateEdgeLock). Keys persist
// in inventory, so the unlock is permanent once earned.
type crossZoneKey struct {
item AdvItem
unlocksIn ZoneID // destination zone, for the grant narration
unlockHint string // what the key opens, surfaced when it's granted
}
// secretRoomKeys — the two cross-zone keys (N5/D4). A Sunken Temple sigil opens
// a sealed reliquary in Manor Blackspire; an Underforge seal opens a vault deep
// in the Underdark throne approach. Keyed by the source secret's node ID.
var secretRoomKeys = map[string]crossZoneKey{
"sunken_temple.coral_reliquary": {
item: AdvItem{Name: "Sunken Sigil", Type: "key", Tier: 2},
unlocksIn: ZoneManorBlackspire,
unlockHint: "a drowned god's mark — Blackspire has a door that remembers it",
},
"underforge.forge_vault": {
item: AdvItem{Name: "Underforge Seal", Type: "key", Tier: 3},
unlocksIn: ZoneUnderdark,
unlockHint: "the forge's own brand — something in the deep roads was promised its work",
},
}
// grantSecretRoomKey hands over the cross-zone key a secret room carries, if it
// carries one and the player isn't already holding it. Idempotent: re-finding
// the room on a later run won't stack duplicate keys. Returns the narration line
// (empty when the room grants no key, the player already has it, or on error).
func (p *AdventurePlugin) grantSecretRoomKey(userID id.UserID, node ZoneNode) string {
key, ok := secretRoomKeys[node.NodeID]
if !ok {
return ""
}
items, err := loadAdvInventory(userID)
if err != nil {
return ""
}
want := strings.ToLower(key.item.Name)
for _, it := range items {
if strings.ToLower(it.Name) == want {
return "" // already earned — don't stack it
}
}
if err := addAdvInventoryItem(userID, key.item); err != nil {
slog.Error("secret room: grant key", "user", userID, "key", key.item.Name, "err", err)
return ""
}
return fmt.Sprintf("🗝 **%s** — %s.", key.item.Name, key.unlockHint)
}
// secretRoomDiscovery is the bespoke in-world discovery line for a secret room,
// keyed by node ID. Not TwinBee's voice — the same in-world register the journal
// pages use. Nodes without an entry fall back to a generic line built from the
// node label.
var secretRoomDiscovery = map[string]string{
"crypt_valdris.secret_chamber": "🔍 **A sealed chamber.** The other grave-robbers pried at the wrong wall. This one gives — Valdris kept something back even from his own tomb.",
"forest_shadows.sapling_shrine": "🔍 **The Sapling Shrine.** A ring of young antlers grown from the dirt, and something votive left at the centre — an offering the King's shell never came back to claim.",
"sunken_temple.coral_reliquary": "🔍 **The Coral Reliquary.** Behind a lattice of living coral, a niche the flood sealed rather than drowned. What the temple hid, it hid well.",
"manor_blackspire.hidden_oratory": "🔍 **The Hidden Oratory.** A chapel with no door in the floor plan, kept for a tenant the deeds refuse to name.",
"manor_blackspire.sealed_reliquary": "🔍 **The Sealed Reliquary.** The drowned god's sigil bites into the lock and turns. Blackspire kept a piece of the temple's secret behind a door only the temple could open.",
"underforge.forge_vault": "🔍 **The Forge Vault.** A strongroom off the unbanked heat, its ledger of commissions all addressed elsewhere and long overdue.",
"underdark.lost_reliquary": "🔍 **A lost reliquary.** A shrine the deep roads swallowed whole, still lit, still waiting on a procession that stopped coming a long age ago.",
"underdark.sealed_forge_vault": "🔍 **The Sealed Vault.** The Underforge's brand fits the seam, and the deep-road door gives up what the forge sent ahead — payment on the old account.",
"feywild_crossing.illusion_garden": "🔍 **The Illusion Garden.** A too-kind grove that isn't there when you look straight at it. He can afford beauty here; it costs him nothing to keep.",
"dragons_lair.hoard_pillar": "🔍 **A pillar of hoard.** Coin drifted to the ceiling around a single column the wyrm guards more than gold — as if something at its heart were worth more.",
"abyss_portal.reality_seam": "🔍 **A seam in the real.** The gate's arithmetic frays here, and through the frayed place a hand once reached to leave a thing behind on the way out.",
}
// secretRoomDiscoveryLine returns the discovery flavor for a secret room,
// falling back to a label-driven generic when the node has no bespoke entry.
func secretRoomDiscoveryLine(node ZoneNode) string {
if line, ok := secretRoomDiscovery[node.NodeID]; ok {
return line
}
label := node.Label
if label == "" {
label = "a hidden room"
}
return fmt.Sprintf("🔍 **%s** — a room the others walked past. Something was left here for whoever looked closer.", label)
}
// bossEpilogues ties each zone boss's death to the Hollow King arc: a 2-3
// sentence capstone appended to the boss-down moment. Forest of Shadows is the
// King himself — but what falls there is a shell he shed, which is why the arc
// (and the finale) outlives it. In-world narration, not TwinBee's voice.
var bossEpilogues = map[ZoneID]string{
ZoneGoblinWarrens: "Grol dies clutching a coin no goblin minted — a king's face worn smooth by handling. Whatever paid the warren to give up its deep tunnels, it paid in a currency older than these hills.",
ZoneCryptValdris: "Valdris tried to cheat the grave and managed only to furnish it. In his last rattle he says a name that isn't his — _hollow, hollow_ — as if warning you of a colleague who did it better.",
ZoneForestShadows: "The Hollow King falls without weight, a coat slipped from its peg — and the woods do not go quiet. What you felled here was a thing he shed, not the thing he is. Somewhere, the account he owes goes on accruing.",
ZoneSunkenTemple: "The Aboleth's dream breaks and the drowned bells still at last. In the silence you understand what they were counting toward — and that the count did not begin with this temple, and does not end with it.",
ZoneManorBlackspire: "Aldric was hollowed the same way, by the same hand, and made a poor imitation: a lord kept past his death to hold a house for a guest who never came. He thanks you. It is the first thing he has meant in a century.",
ZoneUnderforge: "Thyrak's fires gutter out, and the half-made things on the anvils cool into what they were always going to be — regalia, and soldiers, and a crown with no head to fit. The forge was filling an order placed a long time ago.",
ZoneUnderdark: "Ilvaras ruled the throat that swallows everything downward, toward the door at the bottom of the world. She dies certain she served a queen. She served a direction, and the direction has a name it never told her.",
ZoneFeywildCrossing: "The Thornmother's garden was the loveliest cage on the road, tended for a patient guest. He can afford patience; you are learning why. She wilts, and the too-kind light dims by exactly one degree.",
ZoneDragonsLair: "Behind Infernax's hoard, past the last of the gold, a single crown rests on no head — guarded better than the treasure, because it was the one thing here he was ever paid to keep. The dragon dies never knowing what it was.",
ZoneAbyssPortal: "Belaxath guarded a door that opens outward, built by someone who only ever meant to leave through it. As the demon falls, the gate does not close. It was never meant to keep things out — only to let one thing come home.",
}
// bossEpilogueLine returns the campaign capstone for a zone boss, or "" for
// zones with none (and for the synthetic arena, which has no ZoneID entry).
func bossEpilogueLine(zoneID ZoneID) string {
return bossEpilogues[zoneID]
}
// writeBossEpilogue appends a zone's campaign capstone (if any) to a
// victory narration. Shared by every boss-down render path — the manual
// turn-based finishes (finishCombatSession, finishPartyWin) and the compact
// autopilot boss resolve — so the D1b epilogue fires no matter how the boss
// was cleared. Caller gates on "this was a boss, not an elite".
func writeBossEpilogue(b *strings.Builder, zoneID ZoneID) {
if ep := bossEpilogueLine(zoneID); ep != "" {
b.WriteString("\n" + ep + "\n")
}
}
// twinBeeJournalReactions are TwinBee's morning/digest reactions to pages found
// during the day — first-person, implicit subject, he/him, one line, curious,
// never expository (feedback_twinbee_voice, feedback_twinbee_is_male). Picked
// deterministically so a re-rendered digest reads the same.
var twinBeeJournalReactions = []string{
"📖 Found a torn page in your kit tonight — been reading it by the fire while you sleep. This king of theirs was not a well man.",
"📖 Another page. Keep turning them up and I keep piecing him together, and I do not much like the shape.",
"📖 Read the new page twice. Whoever wrote it was frightened of something patient. I think we are walking toward it.",
"📖 Slipped the day's page into the others. The story's filling in at the edges, and none of the edges are kind.",
}
// twinBeeJournalReaction picks one reaction line deterministically from the day
// and the number of pages found, so the digest is stable across re-renders.
func twinBeeJournalReaction(day, pagesToday int) string {
if len(twinBeeJournalReactions) == 0 || pagesToday <= 0 {
return ""
}
idx := (day + pagesToday) % len(twinBeeJournalReactions)
if idx < 0 {
idx = -idx
}
return twinBeeJournalReactions[idx]
}
// 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. 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))
}
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]
}
// ── 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._"
}
// Latch the once-only flag BEFORE handing out the Legendary + title, so a
// failed write (or a crash) can never leave the reward repeatable. If the
// latch fails, skip the grant entirely — the player re-enters uncleared and
// can try again, rather than pocketing a second Legendary on the retry.
if err := markEpilogueClearedDB(userID); err != nil {
slog.Error("epilogue: mark cleared failed", "user", userID, "err", err)
return "_The account won't quite close — the ledger jams. Try `!expedition start epilogue` again._"
}
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 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",
"XXI", "XXII", "XXIII", "XXIV", "XXV", "XXVI", "XXVII", "XXVIII", "XXIX", "XXX",
}

View File

@@ -0,0 +1,300 @@
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)
}
}
func TestBossEpilogues_EveryRegisteredZoneHasOne(t *testing.T) {
for _, z := range allZones() {
if strings.TrimSpace(bossEpilogueLine(z.ID)) == "" {
t.Errorf("zone %s (%s) has no boss epilogue", z.ID, z.Display)
}
}
// The synthetic arena is not a campaign zone — it must have none.
if bossEpilogueLine(ZoneArena) != "" {
t.Errorf("arena should have no boss epilogue")
}
if bossEpilogueLine(ZoneID("nonexistent")) != "" {
t.Errorf("unknown zone should have no epilogue")
}
}
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")
}
first := twinBeeJournalReaction(3, 2)
if first == "" {
t.Fatalf("a found page should produce a reaction")
}
if again := twinBeeJournalReaction(3, 2); again != first {
t.Errorf("reaction not deterministic: %q vs %q", first, again)
}
// Voice guard: TwinBee never speaks of himself in the third person
// (feedback_twinbee_voice). None of the pooled lines may contain "TwinBee".
for _, line := range twinBeeJournalReactions {
if strings.Contains(line, "TwinBee") {
t.Errorf("third-person TwinBee reference in reaction: %q", line)
}
}
}

View File

@@ -24,6 +24,31 @@ var mistyAcceptLines = []string{
const mistyDeclineLine = "She nods once and walks away. She doesn't look back."
// ── Misty — Arc Beats (D2) ─────────────────────────────────────────────────
//
// Three deepening dialogue beats keyed to MistyEncounterCount (5 / 15 / 30),
// prepended to the encounter opening the one time the counter lands on a
// threshold. Fiction only: nothing here connects a donation to a combat
// outcome — the arena effects are a hidden discovery mechanic and stay hidden.
var mistyArcBeats = map[int]string{
5: "It's you again. I was hoping it would be. I know your face now -- you're one of the few who ever stops. " +
"Most people look right through me. You never have.",
15: "Can I tell you something? I wasn't always out here asking strangers on the road. " +
"I had a home once. A garden that got away from me every summer. Someone who used to wait up. " +
"It's gone now, all of it. But you keep reminding me there are still kind people in the world. That's not nothing.",
30: "I don't think of you as a stranger anymore. Is it strange to say that? " +
"I catch myself watching the road for you now -- not for the gold, though I won't pretend it doesn't help. " +
"Just to see you're still standing. Whatever's out there, I hope it knows better than to take you from me too.",
}
// mistyArcBeat returns the deepening dialogue for the exact encounter count at
// which a Misty stage lands, or "" for every other encounter.
func mistyArcBeat(encounterCount int) string {
return mistyArcBeats[encounterCount]
}
// ── Arina — Opening Lines ──────────────────────────────────────────────────
var arinaOpenings = []string{

View File

@@ -40,6 +40,11 @@ var robbieMasterworkAlreadyHas = "Oh. I see you already have one of these. Excel
var robbieAllShopGear = "Nothing fancy today but that's alright. Clean inventory is its own reward. " +
"Well. The €%d is the reward. The clean inventory is a bonus. Cheerio!"
// robbieLeftConsumable is appended when Robbie leaves a little something on his
// every-10th-visit (D2). Takes the item name.
var robbieLeftConsumable = "Oh -- one more thing. I tucked a %s into your bag on the way out. " +
"You've had me round enough times now that it felt rude not to. For the trouble, eh? _winks_"
// ── Room Announcements ───────────────────────────────────────────────────────
var robbieRoomStandard = "🎩 Robbie paid %s a visit and collected %d item(s) from their inventory. " +

View File

@@ -146,6 +146,18 @@ var thomFreezePet = "Ten missed payments. I've stopped trying for now. The house
var thomPaidOffNoPet = "Paid off. Noted. The next tier is available when you're ready. I'll be here."
var thomPaidOffPet = "Paid off. Good. Come in when you're ready for the next tier. I've been thinking about what %s would need in a better space. I have thoughts."
// thomPetTreatItem is the keepsake Thom leaves for the pet on the final
// mortgage payoff. Inert (Type "card" like the medical-debt card): not a
// consumable, not gear, and Robbie's sweep skips it.
func thomPetTreatItem() AdvItem {
return AdvItem{
Name: "Krooke's Finest Pet Treat",
Type: "card",
Tier: 0,
Value: 0,
}
}
// thomGreeting returns an appropriate greeting based on pet state.
func thomGreeting(char *AdventureCharacter) string {
if char.HasPet() && char.PetLevel >= 10 {
@@ -553,11 +565,22 @@ func (p *AdventurePlugin) processMortgagePayments() {
freshChar.HouseLoanBalance = 0
freshChar.HouseMissedPayments = 0
_ = saveAdvCharacter(freshChar)
// The last house: no next tier means Thom is done with this
// player. A letter, and a treat in the bag if they keep a pet.
finalPayoff := houseNextTier(freshChar.HouseTier) == nil
if finalPayoff && freshChar.HasPet() {
_ = addAdvInventoryItem(freshChar.UserID, thomPetTreatItem())
}
userMu.Unlock()
// Paid off!
if freshChar.HasPet() {
switch {
case finalPayoff && freshChar.HasPet():
letter := strings.ReplaceAll(thomFinalLetterPet, "{pet}", freshChar.PetName)
_ = p.SendDM(freshChar.UserID, "🏠 "+letter)
case finalPayoff:
_ = p.SendDM(freshChar.UserID, "🏠 "+thomFinalLetterNoPet)
case freshChar.HasPet():
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomPaidOffPet, freshChar.PetName)))
} else {
default:
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", thomPaidOffNoPet))
}
continue

View File

@@ -0,0 +1,91 @@
package plugin
import (
"strings"
"testing"
)
// D2 NPC arcs — pure-helper coverage. None of these touch combat, so the
// characterization golden is unaffected; these just pin the arc thresholds,
// gift tiers, and the keepsake shape.
func TestMistyArcBeat_FiresOnlyAtThresholds(t *testing.T) {
want := map[int]bool{5: true, 15: true, 30: true}
for count := 0; count <= 40; count++ {
got := mistyArcBeat(count)
if want[count] {
if got == "" {
t.Errorf("encounter %d: expected an arc beat, got none", count)
}
} else if got != "" {
t.Errorf("encounter %d: expected no arc beat, got %q", count, got)
}
}
}
func TestMistyArcBeats_DoNotLeakBuffMechanics(t *testing.T) {
// The arena effects are a hidden discovery mechanic; arc copy must never
// tie a donation to a combat outcome.
banned := []string{"arena", "damage", "buff", "combat", "attack", "heal", "bonus"}
for count, beat := range mistyArcBeats {
lower := strings.ToLower(beat)
for _, b := range banned {
if strings.Contains(lower, b) {
t.Errorf("beat %d leaks mechanic word %q: %s", count, b, beat)
}
}
}
}
func TestRobbieGiftTier_MatchesArenaBands(t *testing.T) {
cases := []struct {
level int
tier int
}{
{1, 1}, {3, 1}, {4, 2}, {7, 2}, {8, 3}, {12, 3},
{13, 4}, {17, 4}, {18, 5}, {20, 5},
}
for _, c := range cases {
if got := robbieGiftTier(c.level); got != c.tier {
t.Errorf("robbieGiftTier(%d) = %d, want %d", c.level, got, c.tier)
}
}
}
func TestRobbieGiftCadence(t *testing.T) {
// The gift lands on every 10th visit and no other.
for visit := 1; visit <= 35; visit++ {
gives := visit%robbieGiftEveryNVisits == 0
if gives != (visit == 10 || visit == 20 || visit == 30) {
t.Errorf("visit %d: unexpected gift cadence", visit)
}
}
}
func TestThomPetTreat_IsRobbieSafeKeepsake(t *testing.T) {
treat := thomPetTreatItem()
if treat.Name == "" {
t.Fatal("treat has no name")
}
// Robbie's sweep skips "card" items, so the keepsake survives him.
if treat.Type != "card" {
t.Errorf("treat type = %q, want card so Robbie's sweep skips it", treat.Type)
}
if treat.Value != 0 {
t.Errorf("treat value = %d, want 0 (inert keepsake)", treat.Value)
}
// Confirm the sweep filter actually excludes it.
if got := robbieQualifyingItems([]AdvItem{treat}, nil); len(got) != 0 {
t.Errorf("Robbie would take the treat: %+v", got)
}
}
func TestThomFinalLetterPet_SubstitutesPetName(t *testing.T) {
letter := strings.ReplaceAll(thomFinalLetterPet, "{pet}", "Biscuit")
if strings.Contains(letter, "{pet}") {
t.Error("unsubstituted {pet} placeholder remains")
}
if !strings.Contains(letter, "Biscuit") {
t.Error("pet name not substituted into the letter")
}
}

View File

@@ -131,6 +131,15 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) {
return
}
// Don't consume the encounter — or its one-shot arc beat — if the player is
// already mid-interaction (shop, treasure, another NPC). Bail before
// touching MistyEncounterCount so a contended slot defers the whole
// encounter to a later fire instead of durably advancing the counter past
// a 5/15/30 threshold and losing that beat forever.
if _, occupied := p.pending.Load(string(userID)); occupied {
return
}
now := time.Now().UTC()
var opening, prompt string
@@ -141,6 +150,12 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) {
// Pool union: legacy mistyOpenings + D&D MistyGreeting flavor.
mistyPool := dndMistyGreetingPool()
opening = mistyPool[rand.IntN(len(mistyPool))]
// D2 arc: a deepening beat lands the one time the counter hits a
// threshold (5/15/30). MistyEncounterCount only ever increments by one
// above, so each beat is delivered exactly once.
if beat := mistyArcBeat(char.MistyEncounterCount); beat != "" {
opening = beat + "\n\n" + opening
}
prompt = fmt.Sprintf("👤 A woman approaches you.\n\n_%s_\n\n"+
"Reply `yes` to give €%d, or `no` to walk away.", opening, mistyCost)
case "arina":
@@ -159,11 +174,6 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) {
slog.Error("player_meta: npc last_seen dual-write failed", "user", userID, "npc", npc, "err", err)
}
// Don't overwrite an existing pending interaction (shop, treasure, etc.)
if _, occupied := p.pending.Load(string(userID)); occupied {
return
}
// Set pending interaction — NPC encounters stay valid until end of UTC day
endOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Add(24 * time.Hour)
p.pending.Store(string(userID), &advPendingInteraction{

View File

@@ -164,16 +164,28 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
gaveCard = true
}
// Update visit count
// Update visit count, and every 10th visit leave a small consumable
// "for the trouble" (D2 NPC arc).
var leftGift *AdvItem
char, err := loadAdvCharacter(userID)
if err == nil {
char.RobbieVisitCount++
if char.RobbieVisitCount%robbieGiftEveryNVisits == 0 {
// Use the canonical DnD level (like the arena's tier gate), not the
// frozen legacy CombatLevel — that snapshots at 13 once D&D setup
// completes, so reading it here would peg every gift at tier 1.
if gifts := consumableCache(robbieGiftTier(arenaDnDLevelOrZero(userID)), 1); len(gifts) > 0 {
if err := addAdvInventoryItem(userID, gifts[0]); err == nil {
leftGift = &gifts[0]
}
}
}
_ = saveAdvCharacter(char)
_ = upsertPlayerMetaNPCState(userID, npcStateFromAdvChar(char))
}
// Send DM
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard)
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard, leftGift)
if err := p.SendDM(userID, dm); err != nil {
slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err)
}
@@ -191,10 +203,12 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem {
var result []AdvItem
for _, item := range inv {
// Never touch Arena gear, cards, or consumables. Consumables are a
// player-curated stockpile (crafted or dropped); selling them is an
// explicit decision the player must make themselves.
if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" {
// Never touch Arena gear, cards, consumables, or keys. Consumables are
// a player-curated stockpile (crafted or dropped); selling them is an
// explicit decision the player must make themselves. Keys are cross-zone
// unlock tokens (N5/D4) that must persist in inventory to open their
// vault later — sweeping one permanently breaks that unlock.
if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" || item.Type == "key" {
continue
}
@@ -243,7 +257,27 @@ func robbiePlayerHasCard(userID id.UserID) bool {
// ── DM Rendering ─────────────────────────────────────────────────────────────
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool) string {
// robbieGiftEveryNVisits is how often Robbie leaves a consumable behind.
const robbieGiftEveryNVisits = 10
// robbieGiftTier maps a player's combat level to a consumable tier, matching
// the arena tier bands (13 / 47 / 812 / 1317 / 18+).
func robbieGiftTier(level int) int {
switch {
case level >= 18:
return 5
case level >= 13:
return 4
case level >= 8:
return 3
case level >= 4:
return 2
default:
return 1
}
}
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool, leftGift *AdvItem) string {
var sb strings.Builder
// Opening
@@ -290,6 +324,12 @@ func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gav
}
sb.WriteString("\n\n")
// Every-10th-visit consumable (D2).
if leftGift != nil {
sb.WriteString(fmt.Sprintf(robbieLeftConsumable, leftGift.Name))
sb.WriteString("\n\n")
}
// Closing
closing, _ := advPickFlavor(robbieClosings, userID, "robbie_closing")
sb.WriteString(closing)

View File

@@ -420,6 +420,12 @@ func (p *AdventurePlugin) midnightReset() error {
dmsSent := 0
for _, char := range chars {
// Advance this player's Shadow (N6/D3) once for the day, before any
// streak/idle branching below — the rival runs whether or not the
// player did, which is the whole point of the race pressure. Own table,
// own idempotency guard; never touches char, never fails the reset.
p.advanceShadow(&char)
// Died inside the window — no shame, no streak change. Covers both
// currently-dead players and players revived at midnight (Alive
// already flipped to true by the reminder loop before this runs).

View File

@@ -0,0 +1,216 @@
package plugin
import (
"strings"
"testing"
"maunium.net/go/mautrix/id"
)
// registeredSecretNodes collects every NodeKindSecret node across all
// registered zone graphs, keyed by node ID.
func registeredSecretNodes(t *testing.T) map[string]ZoneNode {
t.Helper()
out := map[string]ZoneNode{}
for _, z := range allZones() {
g, ok := loadZoneGraph(z.ID)
if !ok {
continue
}
for id, n := range g.Nodes {
if n.Kind == NodeKindSecret {
out[id] = n
}
}
}
return out
}
// TestSecretRoomDiscovery_EverySecretHasBespokeFlavor guards that no secret
// node ships on the generic fallback — every one gets an authored line. Catches
// a future secret room added without a discovery entry.
func TestSecretRoomDiscovery_EverySecretHasBespokeFlavor(t *testing.T) {
for id, n := range registeredSecretNodes(t) {
if _, ok := secretRoomDiscovery[id]; !ok {
t.Errorf("secret node %q (%q) has no bespoke discovery line", id, n.Label)
}
if line := secretRoomDiscoveryLine(n); strings.TrimSpace(line) == "" {
t.Errorf("secret node %q produced an empty discovery line", id)
}
}
}
func TestSecretRoomDiscoveryLine_FallsBackOnUnknownNode(t *testing.T) {
n := ZoneNode{NodeID: "made_up.node", Kind: NodeKindSecret, Label: "Nowhere Nook"}
line := secretRoomDiscoveryLine(n)
if !strings.Contains(line, "Nowhere Nook") {
t.Errorf("fallback should name the node label, got: %s", line)
}
}
// TestSecretRoomKeys_UnlockRealDestinationEdges is the cross-zone-key
// consistency check: every key's item Name must match a LockKey edge's key_id
// (lower-cased) in its destination zone, and every source must be a real secret
// room. A typo between the granted item name and the authored key_id would
// silently make a vault permanently unreachable; this catches it.
func TestSecretRoomKeys_UnlockRealDestinationEdges(t *testing.T) {
secrets := registeredSecretNodes(t)
for srcNode, key := range secretRoomKeys {
if _, ok := secrets[srcNode]; !ok {
t.Errorf("key source %q is not a registered secret room", srcNode)
}
g, ok := loadZoneGraph(key.unlocksIn)
if !ok {
t.Errorf("%s: destination zone %q has no graph", srcNode, key.unlocksIn)
continue
}
wantKeyID := strings.ToLower(key.item.Name)
found := false
for _, outs := range g.Edges {
for _, e := range outs {
if e.Lock == LockKey && strings.ToLower(lockDataString(e.LockData, "key_id")) == wantKeyID {
found = true
}
}
}
if !found {
t.Errorf("key %q (from %s) has no LockKey edge with key_id=%q in zone %q — vault unreachable",
key.item.Name, srcNode, wantKeyID, key.unlocksIn)
}
}
}
// TestCrossZoneKey_UnlocksWithItemNotWithout exercises the runtime lock
// evaluation end-to-end on a real authored edge.
func TestCrossZoneKey_UnlocksWithItemNotWithout(t *testing.T) {
g, ok := loadZoneGraph(ZoneManorBlackspire)
if !ok {
t.Fatal("manor graph missing")
}
var keyEdge ZoneEdge
for _, e := range g.outgoingEdges("manor_blackspire.upper_hall") {
if e.Lock == LockKey {
keyEdge = e
}
}
if keyEdge.To == "" {
t.Fatal("no LockKey edge off manor upper_hall")
}
base := edgeUnlockCtx{RunID: "r", FromNode: "manor_blackspire.upper_hall"}
if ok, _ := evaluateEdgeLock(keyEdge, base); ok {
t.Error("edge should be locked without the key in inventory")
}
withKey := base
withKey.InventoryNames = map[string]bool{"sunken sigil": true}
if ok, reason := evaluateEdgeLock(keyEdge, withKey); !ok {
t.Errorf("edge should unlock with the sigil in inventory, got locked: %s", reason)
}
}
// TestGrantSecretRoomKey_GrantsOnceIdempotent verifies a key-bearing secret
// hands its key over exactly once.
func TestGrantSecretRoomKey_GrantsOnceIdempotent(t *testing.T) {
townTestDB(t)
p := &AdventurePlugin{}
u := id.UserID("@keyholder:test.invalid")
node := ZoneNode{NodeID: "sunken_temple.coral_reliquary", Kind: NodeKindSecret}
line := p.grantSecretRoomKey(u, node)
if !strings.Contains(line, "Sunken Sigil") {
t.Fatalf("first grant should name the key, got: %q", line)
}
items, err := loadAdvInventory(u)
if err != nil {
t.Fatalf("load inventory: %v", err)
}
sigils := 0
for _, it := range items {
if strings.EqualFold(it.Name, "Sunken Sigil") {
sigils++
}
}
if sigils != 1 {
t.Fatalf("want exactly 1 Sunken Sigil, got %d", sigils)
}
// Second find on a later run must not stack a duplicate.
if again := p.grantSecretRoomKey(u, node); again != "" {
t.Errorf("re-finding the room should not re-grant the key, got: %q", again)
}
items, _ = loadAdvInventory(u)
sigils = 0
for _, it := range items {
if strings.EqualFold(it.Name, "Sunken Sigil") {
sigils++
}
}
if sigils != 1 {
t.Fatalf("still want exactly 1 Sunken Sigil after re-find, got %d", sigils)
}
}
// TestGrantSecretRoomKey_NoKeyForPlainSecret confirms a secret room that carries
// no cross-zone key stays silent.
func TestGrantSecretRoomKey_NoKeyForPlainSecret(t *testing.T) {
townTestDB(t)
p := &AdventurePlugin{}
u := id.UserID("@plain:test.invalid")
node := ZoneNode{NodeID: "forest_shadows.sapling_shrine", Kind: NodeKindSecret}
if line := p.grantSecretRoomKey(u, node); line != "" {
t.Errorf("plain secret should grant no key, got: %q", line)
}
}
// TestResolveSecretRoom_GrantsPageCacheAndKey checks the full treasure-cache
// payout: a journal page, the consumable cache, and (for a key-bearing secret)
// the cross-zone key — with no combat touching HP.
func TestResolveSecretRoom_GrantsPageCacheAndKey(t *testing.T) {
townTestDB(t)
p := &AdventurePlugin{}
u := id.UserID("@secret:test.invalid")
zone, ok := getZone(ZoneSunkenTemple)
if !ok {
t.Fatal("sunken temple zone missing")
}
node := ZoneNode{
NodeID: "sunken_temple.coral_reliquary", Kind: NodeKindSecret,
Label: "Coral Reliquary",
Content: ZoneNodeContent{LootBias: 1.8},
}
run := &DungeonRun{UserID: string(u), ZoneID: ZoneSunkenTemple, CurrentNode: node.NodeID}
out := p.resolveSecretRoom(u, run, zone, node)
if !strings.Contains(out, "Coral Reliquary") {
t.Errorf("outcome should carry the discovery line:\n%s", out)
}
// A journal page was granted (fresh player, nothing found yet).
if mask, err := loadJournalPages(u); err != nil || journalPageCount(mask) != 1 {
t.Fatalf("want exactly 1 journal page granted, got count=%d err=%v", journalPageCount(mask), err)
}
// The guaranteed cache + the cross-zone key are in inventory.
items, err := loadAdvInventory(u)
if err != nil {
t.Fatalf("load inventory: %v", err)
}
var caches, keys int
for _, it := range items {
switch {
case it.Type == "key":
keys++
case it.Type == "consumable":
caches++
}
}
if caches != secretRoomCacheCount {
t.Errorf("want %d cache consumables, got %d", secretRoomCacheCount, caches)
}
if keys != 1 {
t.Errorf("want the Sunken Sigil key granted, got %d keys", keys)
}
}

View File

@@ -0,0 +1,432 @@
package plugin
import (
"database/sql"
"fmt"
"hash/fnv"
"log/slog"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// The Shadow (N6/D3) — a per-player simulated rival adventurer. It "runs" the
// same zone progression the player does, advanced once per UTC day by the
// midnight ticker at ~1.3x the player's own long-run clear pace, so it stays
// just ahead: a rival you can always see and always nearly catch.
//
// It is pure theatre. There is no Shadow combat, no punishment, no state the
// player can lose. The only mechanics are race pressure (a morning-briefing
// line telling you where it is relative to you) and two payoffs at each zone
// clear: beat the Shadow to a zone and TwinBee crows a little bonus XP; let the
// Shadow clear it first and it leaves a journal page waiting in the boss room
// (the D1 campaign tie-in). All Shadow narration TwinBee speaks is first-person,
// he/him, implicit-subject — the campaign voice rules (feedback_twinbee_voice,
// feedback_twinbee_is_male). The Shadow itself is referred to as "the Shadow"
// (or by its name); "he" in TwinBee's lines is TwinBee, never a third-person
// "TwinBee".
const (
// shadowPaceMultiplier keeps the Shadow just ahead: it clears at ~1.3x the
// player's own long-run pace.
shadowPaceMultiplier = 1.3
// shadowMinDailyStep is the floor on a day's advance, so a brand-new or idle
// player still watches the rival creep forward rather than sit frozen.
shadowMinDailyStep = 0.2
// shadowMaxDailyStep caps a day's advance at just under one zone, so even a
// prolific player never sees the Shadow clear two zones overnight.
shadowMaxDailyStep = 1.0
// shadowMaxLead caps how far ahead the Shadow may run — it is a race, not a
// runaway. It will never sit more than ~2.5 zones past the player's own count.
shadowMaxLead = 2.5
// shadowCrowXPPerTier scales the "you beat me here" bonus by zone tier, so a
// T5 crow is worth more than a T1 one. Small on purpose (lift, not a spike).
shadowCrowXPPerTier = 12
)
// shadowState mirrors the adventure_shadow row.
type shadowState struct {
UserID id.UserID
Name string
Progress float64
ZonesCleared int
PendingMask int64
CrowedMask int64
DayCounter int
LastAdvanced string
}
// The Shadow walks zoneOrder — the design-doc order the zone registry is built
// in — start to finish, tier 1 → 5. Bit i of a Shadow's masks is zoneOrder[i].
// shadowZoneIndex returns the progression index of a zone, or -1 if it is not
// on the path (the arena, an unknown id).
func shadowZoneIndex(z ZoneID) int {
for i, id := range zoneOrder {
if id == z {
return i
}
}
return -1
}
func shadowSetBit(mask int64, idx int) int64 {
if idx < 0 || idx > 62 {
return mask
}
return mask | (int64(1) << idx)
}
func shadowClearBit(mask int64, idx int) int64 {
if idx < 0 || idx > 62 {
return mask
}
return mask &^ (int64(1) << idx)
}
func shadowBitSet(mask int64, idx int) bool {
if idx < 0 || idx > 62 {
return false
}
return mask&(int64(1)<<idx) != 0
}
// loadShadow reads a player's Shadow, or (nil, nil) if it hasn't been born yet.
func loadShadow(userID id.UserID) (*shadowState, error) {
s := &shadowState{UserID: userID}
err := db.Get().QueryRow(
`SELECT name, progress, zones_cleared, pending_mask, crowed_mask, day_counter, last_advanced
FROM adventure_shadow WHERE user_id = ?`,
string(userID),
).Scan(&s.Name, &s.Progress, &s.ZonesCleared, &s.PendingMask, &s.CrowedMask, &s.DayCounter, &s.LastAdvanced)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return s, nil
}
// upsertShadow persists the whole Shadow row. The ticker is the only writer, so
// a plain last-write-wins upsert is safe — there is no concurrent mutator to
// race (unlike the player_meta columns a character save also touches).
func upsertShadow(s *shadowState) error {
_, err := db.Get().Exec(
`INSERT INTO adventure_shadow
(user_id, name, progress, zones_cleared, pending_mask, crowed_mask, day_counter, last_advanced)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
name = excluded.name,
progress = excluded.progress,
zones_cleared = excluded.zones_cleared,
pending_mask = excluded.pending_mask,
crowed_mask = excluded.crowed_mask,
day_counter = excluded.day_counter,
last_advanced = excluded.last_advanced`,
string(s.UserID), s.Name, s.Progress, s.ZonesCleared, s.PendingMask,
s.CrowedMask, s.DayCounter, s.LastAdvanced,
)
return err
}
// shadowNames is the pool the rival's proper name is drawn from — cold,
// half-familiar names for a thing that shadows you. Picked deterministically
// from the player's own name so a given player always faces the same Shadow.
var shadowNames = []string{
"Vael", "Corriss", "Mordane", "Ashen", "Locke", "Grimma", "Sable",
"Thane", "Wren", "Vesper", "Cael", "Orrin", "Dain", "Wraithe",
"Nyx", "Solenne",
}
// shadowNameFor seeds the Shadow's name from the player's display name so it is
// stable per player and "seeded from the player's" (D3 spec). Falls back to the
// user id when the display name is empty.
func shadowNameFor(displayName string, userID id.UserID) string {
seed := strings.ToLower(strings.TrimSpace(displayName))
if seed == "" {
seed = string(userID)
}
h := fnv.New32a()
_, _ = h.Write([]byte(seed))
return shadowNames[int(h.Sum32())%len(shadowNames)]
}
// newShadow mints a fresh Shadow at the start line.
func newShadow(userID id.UserID, displayName string) *shadowState {
return &shadowState{
UserID: userID,
Name: shadowNameFor(displayName, userID),
}
}
// advanceShadow moves a player's Shadow forward one UTC day. Idempotent per day
// via last_advanced, so the once-a-day midnight guard plus this makes a double
// tick a no-op. Mints the Shadow on first call. Errors are logged and swallowed
// — a stalled Shadow is cosmetic, and must never break the midnight reset loop.
func (p *AdventurePlugin) advanceShadow(char *AdventureCharacter) {
today := time.Now().UTC().Format("2006-01-02")
s, err := loadShadow(char.UserID)
if err != nil {
slog.Warn("shadow: load", "user", char.UserID, "err", err)
return
}
if s == nil {
s = newShadow(char.UserID, char.DisplayName)
}
if s.LastAdvanced == today {
return // already advanced this UTC day
}
cleared := clearedZoneIDs(db.Get(), char.UserID)
playerCount := len(cleared)
// The player's long-run pace, in zones per day, drives the Shadow's speed.
// Floor it so a new/idle player still gets a creeping rival; the 1.3x keeps
// the Shadow just ahead; cap the daily step so it never leaps two zones.
step := (float64(playerCount) / float64(shadowPlayerAgeDays(char))) * shadowPaceMultiplier
if step < shadowMinDailyStep {
step = shadowMinDailyStep
}
if step > shadowMaxDailyStep {
step = shadowMaxDailyStep
}
newProg := s.Progress + step
// Keep it a race: never more than shadowMaxLead zones past the player's count.
if leadCap := float64(playerCount) + shadowMaxLead; newProg > leadCap {
newProg = leadCap
}
if maxProg := float64(len(zoneOrder)); newProg > maxProg {
newProg = maxProg
}
if newProg < s.Progress {
newProg = s.Progress // never regress (a lead cap that dropped below current)
}
// Any progression zone the Shadow newly finishes, and the player hasn't,
// leaves a page waiting. Compare per-zone (not by count): the Shadow walks
// in order while the player may clear out of order, so "did the Shadow clear
// zone i first" is a per-zone question.
newCleared := int(newProg)
if newCleared > len(zoneOrder) {
newCleared = len(zoneOrder)
}
for idx := s.ZonesCleared; idx < newCleared; idx++ {
if !cleared[zoneOrder[idx]] {
s.PendingMask = shadowSetBit(s.PendingMask, idx)
}
}
s.Progress = newProg
s.ZonesCleared = newCleared
s.DayCounter++
s.LastAdvanced = today
if err := upsertShadow(s); err != nil {
slog.Warn("shadow: advance upsert", "user", char.UserID, "err", err)
}
}
// shadowPlayerAgeDays estimates how many days the player has been adventuring,
// clamped to at least 1 so a same-day character never divides by zero. Used as
// the denominator of the player's pace.
func shadowPlayerAgeDays(char *AdventureCharacter) float64 {
if char.CreatedAt.IsZero() {
return 1
}
days := time.Since(char.CreatedAt).Hours() / 24
if days < 1 {
return 1
}
return days
}
// shadowOnPlayerZoneClear is the payoff, fired when the player clears a zone's
// boss. Three outcomes: the Shadow left a page here (pending bit) → grant the
// page and clear the bit; the player beat the Shadow to this zone → a crow plus
// a little bonus XP; or the Shadow already passed through with nothing owed →
// nothing. Returns the narration line to append to the clear message ("" for
// the silent case, or when the Shadow hasn't been born yet).
func (p *AdventurePlugin) shadowOnPlayerZoneClear(userID id.UserID, zoneID ZoneID) string {
s, err := loadShadow(userID)
if err != nil || s == nil {
return ""
}
idx := shadowZoneIndex(zoneID)
if idx < 0 {
return ""
}
if shadowBitSet(s.PendingMask, idx) {
// The Shadow got here first and left a page. Grant it BEFORE retiring the
// debt: grantJournalPage returns "" both when the campaign is already
// complete and on a transient DB error, so clear the pending bit only once
// the page has actually landed (or the ledger is genuinely full). A
// transient failure then leaves the bit set and the next clear retries,
// rather than swallowing a page the player earned.
pageLine := p.grantJournalPage(userID, nil)
mask, _ := loadJournalPages(userID)
if pageLine == "" && !journalComplete(mask) {
slog.Warn("shadow: waiting page grant failed; leaving debt for retry",
"user", userID, "zone", zoneID)
return ""
}
s.PendingMask = shadowClearBit(s.PendingMask, idx)
if err := upsertShadow(s); err != nil {
// The page is already granted; a failed bit-clear can at worst re-grant
// a different unfound page on a later re-clear, which is benign (pages
// are collectible and the campaign self-limits). Better than losing it.
slog.Warn("shadow: clear pending bit", "user", userID, "zone", zoneID, "err", err)
}
if pageLine == "" {
// Ledger already complete — nothing to leave, but the Shadow was first.
return shadowBeatYouHereLine(s)
}
return shadowLeftPageLine(s) + "\n" + pageLine
}
// No page owed. If the Shadow hasn't reached this zone yet, the player beat
// it here — crow and hand over a small bonus, but only once per zone: the
// crowed bit makes re-running a not-yet-reached zone unable to farm the XP.
if int(s.Progress) <= idx && !shadowBitSet(s.CrowedMask, idx) {
xp := shadowCrowXP(zoneID)
if _, err := p.grantDnDXP(userID, xp); err != nil {
// Don't promise XP we didn't grant, and don't burn the crow — leave the
// bit unset so the next clear can crow for real.
slog.Warn("shadow: crow xp", "user", userID, "err", err)
return ""
}
s.CrowedMask = shadowSetBit(s.CrowedMask, idx)
if err := upsertShadow(s); err != nil {
slog.Warn("shadow: set crowed bit", "user", userID, "zone", zoneID, "err", err)
}
return shadowCrowLine(s, xp)
}
return ""
}
// shadowCrowXP is the bonus for beating the Shadow to a zone, scaled by tier.
func shadowCrowXP(zoneID ZoneID) int {
tier := 1
if z, ok := getZone(zoneID); ok {
tier = int(z.Tier)
}
if tier < 1 {
tier = 1
}
return tier * shadowCrowXPPerTier
}
// shadowBriefingLine is the morning-briefing race-pressure beat: where the
// Shadow stands relative to the player. Deterministic by day so a re-rendered
// briefing reads the same, and silent until the Shadow has been born and has
// something to say. TwinBee's voice.
func (p *AdventurePlugin) shadowBriefingLine(e *Expedition) string {
userID := id.UserID(e.UserID)
s, err := loadShadow(userID)
if err != nil || s == nil {
return ""
}
playerCount := len(clearedZoneIDs(db.Get(), userID))
lead := s.ZonesCleared - playerCount
return shadowRacePressureLine(s, e.CurrentDay, lead)
}
// ── Flavour ────────────────────────────────────────────────────────────────
//
// TwinBee's voice: first-person, implicit subject, he/him, one line. "The
// Shadow"/"{name}" is the rival; "I"/"we" is TwinBee and the player. Never the
// third-person "TwinBee" (guarded by test, same as the journal reactions).
// shadowAheadLines — TwinBee, the Shadow is ahead of us. Race pressure.
var shadowAheadLines = []string{
"Passed the Shadow's camp at dawn — cold ashes, a day old. %s is ahead of us again.",
"Found %s's mark cut into a doorframe up ahead. Still warm. We're not gaining.",
"%s came through here already. I can smell the pitch of a torch not long snuffed.",
"The Shadow's been and gone. %s leaves the doors open behind, like he wants us to know.",
}
// shadowBehindLines — TwinBee, we're ahead of the Shadow. Earned swagger.
var shadowBehindLines = []string{
"No sign of %s ahead — I think we're out in front for once. Let's keep it that way.",
"We've outpaced the Shadow. %s is somewhere behind us, eating our dust. Good.",
"Quiet up ahead. %s hasn't reached this deep yet. First ones through.",
}
// shadowNeckLines — TwinBee, we're level with the Shadow. Tension.
var shadowNeckLines = []string{
"%s is right on our heels — or we're on his. Hard to say who's chasing who now.",
"Neck and neck with the Shadow. I keep catching %s at the edge of the torchlight.",
"%s is close. Same rooms, hours apart. Whoever clears the next zone first wins the day.",
}
// shadowRacePressureLine picks a race-pressure line by the day and the Shadow's
// lead, deterministically. lead > 0: Shadow ahead; lead < 0: player ahead; 0:
// level.
func shadowRacePressureLine(s *shadowState, day, lead int) string {
var pool []string
switch {
case lead > 0:
pool = shadowAheadLines
case lead < 0:
pool = shadowBehindLines
default:
pool = shadowNeckLines
}
if len(pool) == 0 {
return ""
}
idx := (day + s.DayCounter) % len(pool)
if idx < 0 {
idx = -idx
}
return "👤 " + fmt.Sprintf(pool[idx], s.Name)
}
// shadowLeftPageLine prefaces the waiting journal page: the Shadow cleared this
// zone first and left a page behind for us. TwinBee's voice.
func shadowLeftPageLine(s *shadowState) string {
return fmt.Sprintf("👤 %s beat us to this one — but left a torn page pinned in the boss's lair, like a note for whoever came next.", s.Name)
}
// shadowBeatYouHereLine is the page-less acknowledgement (campaign already
// complete): the Shadow was first, but there's nothing left to find.
func shadowBeatYouHereLine(s *shadowState) string {
return fmt.Sprintf("👤 %s was here before us. Boot-prints in the dust, and nothing left to take. We know how this ends now anyway.", s.Name)
}
// shadowCrowLine is TwinBee crowing that we beat the Shadow to a zone, with the
// bonus XP.
func shadowCrowLine(s *shadowState, xp int) string {
return fmt.Sprintf("👤 First ones through — %s is still somewhere behind us. I'd crow if it wouldn't give us away. (**+%d XP**, for beating the Shadow here.)", s.Name, xp)
}
// handleShadowCmd renders the player's standing against the Shadow. A quiet
// status view — the feature lives mostly in the briefing and at zone clears.
func (p *AdventurePlugin) handleShadowCmd(ctx MessageContext) error {
s, err := loadShadow(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't read the trail just now. Try again in a moment.")
}
if s == nil {
return p.SendDM(ctx.Sender, "👤 No one's shadowing you yet. Clear a zone or two and someone will take an interest.")
}
playerCount := len(clearedZoneIDs(db.Get(), ctx.Sender))
total := len(zoneOrder)
var b strings.Builder
b.WriteString(fmt.Sprintf("👤 **The Shadow — %s**\n\n", s.Name))
b.WriteString(fmt.Sprintf("🏴 **%s's trail:** %d / %d zones cleared\n", s.Name, s.ZonesCleared, total))
b.WriteString(fmt.Sprintf("🚩 **Your trail:** %d / %d zones cleared\n", playerCount, total))
switch lead := s.ZonesCleared - playerCount; {
case lead > 0:
b.WriteString(fmt.Sprintf("\n_%s is **%d** ahead. Pages wait where he cleared first — take them back by clearing those zones yourself._", s.Name, lead))
case lead < 0:
b.WriteString(fmt.Sprintf("\n_You're **%d** ahead. Keep it that way._", -lead))
default:
b.WriteString("\n_Dead level. The next zone decides who's chasing whom._")
}
return p.SendDM(ctx.Sender, b.String())
}

View File

@@ -0,0 +1,360 @@
package plugin
import (
"strings"
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func newShadowTestDB(t *testing.T) {
t.Helper()
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
}
// freshChar builds a same-day character so shadowPlayerAgeDays reads 1 day.
func freshChar(user id.UserID) *AdventureCharacter {
return &AdventureCharacter{
UserID: user,
DisplayName: "Tester",
CreatedAt: time.Now().UTC(),
}
}
func TestShadowName_DeterministicAndInPool(t *testing.T) {
user := id.UserID("@name:test.invalid")
first := shadowNameFor("Aria", user)
if again := shadowNameFor("Aria", user); again != first {
t.Errorf("name not deterministic: %q vs %q", first, again)
}
inPool := false
for _, n := range shadowNames {
if n == first {
inPool = true
break
}
}
if !inPool {
t.Errorf("generated name %q not in shadowNames pool", first)
}
// Empty display name falls back to the user id and still yields a pool name.
if got := shadowNameFor("", user); got == "" {
t.Error("empty display name produced an empty shadow name")
}
}
// TestShadowFlavor_NoThirdPersonTwinBee: every Shadow line TwinBee speaks obeys
// the campaign voice rule — he never refers to himself in the third person
// (feedback_twinbee_voice). Same guard the journal reactions carry.
func TestShadowFlavor_NoThirdPersonTwinBee(t *testing.T) {
pools := [][]string{shadowAheadLines, shadowBehindLines, shadowNeckLines}
for _, pool := range pools {
for _, line := range pool {
if strings.Contains(line, "TwinBee") {
t.Errorf("third-person TwinBee in Shadow pool line: %q", line)
}
if !strings.Contains(line, "%s") {
t.Errorf("race-pressure line missing the name slot: %q", line)
}
}
}
s := &shadowState{Name: "Vael"}
rendered := []string{
shadowLeftPageLine(s),
shadowBeatYouHereLine(s),
shadowCrowLine(s, 24),
shadowRacePressureLine(s, 3, 1),
shadowRacePressureLine(s, 3, -1),
shadowRacePressureLine(s, 3, 0),
}
for _, r := range rendered {
if strings.Contains(r, "TwinBee") {
t.Errorf("third-person TwinBee in rendered Shadow line: %q", r)
}
if !strings.Contains(r, "Vael") {
t.Errorf("rendered line dropped the Shadow's name: %q", r)
}
}
}
func TestShadowRacePressure_Deterministic(t *testing.T) {
s := &shadowState{Name: "Vael", DayCounter: 2}
first := shadowRacePressureLine(s, 4, 1)
if again := shadowRacePressureLine(s, 4, 1); again != first {
t.Errorf("race pressure not deterministic: %q vs %q", first, again)
}
// Direction picks a different pool: ahead vs behind must differ in wording.
ahead := shadowRacePressureLine(s, 4, 2)
behind := shadowRacePressureLine(s, 4, -2)
if ahead == behind {
t.Errorf("ahead and behind produced the same line: %q", ahead)
}
}
func TestShadowAdvance_CreepsAndIsIdempotent(t *testing.T) {
newShadowTestDB(t)
p := &AdventurePlugin{}
user := id.UserID("@creep:test.invalid")
char := freshChar(user)
p.advanceShadow(char)
s, err := loadShadow(user)
if err != nil || s == nil {
t.Fatalf("shadow not born: %v", err)
}
if s.Progress <= 0 {
t.Errorf("a born shadow should have crept forward, got progress %v", s.Progress)
}
if s.Name == "" {
t.Error("born shadow has no name")
}
firstProg := s.Progress
// Second advance the same UTC day is a no-op.
p.advanceShadow(char)
s2, _ := loadShadow(user)
if s2.Progress != firstProg {
t.Errorf("same-day re-advance moved the shadow: %v -> %v", firstProg, s2.Progress)
}
// Roll the clock back a day and it advances again.
s2.LastAdvanced = time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
if err := upsertShadow(s2); err != nil {
t.Fatal(err)
}
p.advanceShadow(char)
s3, _ := loadShadow(user)
if s3.Progress <= firstProg {
t.Errorf("a new day should have advanced the shadow: %v -> %v", firstProg, s3.Progress)
}
}
func TestShadowAdvance_PendingBitForUnclearedZone(t *testing.T) {
newShadowTestDB(t)
p := &AdventurePlugin{}
user := id.UserID("@pending:test.invalid")
char := freshChar(user)
// Seed the shadow one step short of clearing zone 0, dated yesterday so the
// next advance runs. The player has cleared nothing.
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
seed := newShadow(user, char.DisplayName)
seed.Progress = 0.95
seed.LastAdvanced = yesterday
if err := upsertShadow(seed); err != nil {
t.Fatal(err)
}
p.advanceShadow(char)
s, _ := loadShadow(user)
if s.ZonesCleared < 1 {
t.Fatalf("shadow should have cleared zone 0, zones_cleared=%d prog=%v", s.ZonesCleared, s.Progress)
}
if !shadowBitSet(s.PendingMask, 0) {
t.Errorf("shadow cleared zone 0 before the player but left no pending page (mask=%b)", s.PendingMask)
}
}
func TestShadowAdvance_NoPendingWhenPlayerClearedFirst(t *testing.T) {
newShadowTestDB(t)
p := &AdventurePlugin{}
user := id.UserID("@nopending:test.invalid")
char := freshChar(user)
// Player already cleared zone 0.
insertClearedExpedition(t, user, zoneOrder[0], ExpeditionStatusComplete, 1, "{}")
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
seed := newShadow(user, char.DisplayName)
seed.Progress = 0.95
seed.LastAdvanced = yesterday
if err := upsertShadow(seed); err != nil {
t.Fatal(err)
}
p.advanceShadow(char)
s, _ := loadShadow(user)
if s.ZonesCleared < 1 {
t.Fatalf("shadow should have cleared zone 0, zones_cleared=%d", s.ZonesCleared)
}
if shadowBitSet(s.PendingMask, 0) {
t.Errorf("player cleared zone 0 first — no page should wait (mask=%b)", s.PendingMask)
}
}
func TestShadowAdvance_LeadCapKeepsItARace(t *testing.T) {
newShadowTestDB(t)
p := &AdventurePlugin{}
user := id.UserID("@leadcap:test.invalid")
char := freshChar(user) // player has cleared nothing → count 0
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
seed := newShadow(user, char.DisplayName)
seed.Progress = shadowMaxLead // already at the cap for a 0-clear player
seed.ZonesCleared = 2
seed.LastAdvanced = yesterday
if err := upsertShadow(seed); err != nil {
t.Fatal(err)
}
p.advanceShadow(char)
s, _ := loadShadow(user)
if s.Progress > shadowMaxLead+0.0001 {
t.Errorf("shadow ran past the lead cap: progress %v > %v", s.Progress, shadowMaxLead)
}
}
func TestShadowZoneClear_PendingGrantsWaitingPage(t *testing.T) {
newShadowTestDB(t)
p := &AdventurePlugin{}
user := id.UserID("@page:test.invalid")
idx := 2
seed := newShadow(user, "Tester")
seed.Progress = 5
seed.ZonesCleared = 5
seed.PendingMask = shadowSetBit(0, idx)
if err := upsertShadow(seed); err != nil {
t.Fatal(err)
}
line := p.shadowOnPlayerZoneClear(user, zoneOrder[idx])
if strings.TrimSpace(line) == "" {
t.Fatal("a waiting page should have produced a clear-message line")
}
if !strings.Contains(line, "page") {
t.Errorf("expected a journal-page grant in the line, got: %q", line)
}
// Bit cleared so a re-clear can't re-award.
s, _ := loadShadow(user)
if shadowBitSet(s.PendingMask, idx) {
t.Error("pending bit not cleared after the page was granted")
}
// The page actually landed.
if mask, _ := loadJournalPages(user); journalPageCount(mask) != 1 {
t.Errorf("expected exactly one journal page granted, got %d", journalPageCount(mask))
}
// A second clear of the same zone grants nothing more.
again := p.shadowOnPlayerZoneClear(user, zoneOrder[idx])
if again != "" {
t.Errorf("re-clearing a paid-out zone should be silent, got: %q", again)
}
if mask, _ := loadJournalPages(user); journalPageCount(mask) != 1 {
t.Errorf("re-clear granted a second page (count %d)", journalPageCount(mask))
}
}
func TestShadowZoneClear_PlayerFirstCrows(t *testing.T) {
newShadowTestDB(t)
p := &AdventurePlugin{}
user := id.UserID("@crow:test.invalid")
idx := 3
seed := newShadow(user, "Tester")
seed.Progress = 0 // shadow hasn't reached this zone
if err := upsertShadow(seed); err != nil {
t.Fatal(err)
}
line := p.shadowOnPlayerZoneClear(user, zoneOrder[idx])
if !strings.Contains(line, "XP") {
t.Errorf("beating the shadow should crow a bonus, got: %q", line)
}
// No page granted on a crow.
if mask, _ := loadJournalPages(user); journalPageCount(mask) != 0 {
t.Errorf("a crow should not grant a page (count %d)", journalPageCount(mask))
}
// The crowed bit is set so a re-run of the same not-yet-reached zone can't
// farm the XP again — second clear is silent.
if s, _ := loadShadow(user); !shadowBitSet(s.CrowedMask, idx) {
t.Error("crowed bit not set after the first crow")
}
if again := p.shadowOnPlayerZoneClear(user, zoneOrder[idx]); again != "" {
t.Errorf("re-clearing an already-crowed zone should be silent, got: %q", again)
}
}
func TestShadowZoneClear_ShadowPassedNoDebt(t *testing.T) {
newShadowTestDB(t)
p := &AdventurePlugin{}
user := id.UserID("@passed:test.invalid")
idx := 1
seed := newShadow(user, "Tester")
seed.Progress = 5 // well past this zone
seed.ZonesCleared = 5
seed.PendingMask = 0 // nothing owed here
if err := upsertShadow(seed); err != nil {
t.Fatal(err)
}
if line := p.shadowOnPlayerZoneClear(user, zoneOrder[idx]); line != "" {
t.Errorf("a zone the shadow passed with nothing owed should be silent, got: %q", line)
}
}
func TestShadowZoneClear_SilentBeforeBirth(t *testing.T) {
newShadowTestDB(t)
p := &AdventurePlugin{}
user := id.UserID("@unborn:test.invalid")
if line := p.shadowOnPlayerZoneClear(user, zoneOrder[0]); line != "" {
t.Errorf("no shadow yet should be silent, got: %q", line)
}
}
func TestShadowBriefingLine_SilentUntilBorn(t *testing.T) {
newShadowTestDB(t)
p := &AdventurePlugin{}
user := id.UserID("@brief:test.invalid")
e := &Expedition{UserID: string(user), ZoneID: zoneOrder[0], CurrentDay: 2}
if line := p.shadowBriefingLine(e); line != "" {
t.Errorf("no shadow should mean no briefing line, got: %q", line)
}
seed := newShadow(user, "Tester")
seed.ZonesCleared = 2
if err := upsertShadow(seed); err != nil {
t.Fatal(err)
}
if line := p.shadowBriefingLine(e); strings.TrimSpace(line) == "" {
t.Error("a born shadow should produce a briefing line")
}
}
func TestHandleShadowCmd_DMsStatus(t *testing.T) {
newShadowTestDB(t)
p := &AdventurePlugin{}
sink := installSink(p)
user := id.UserID("@cmd:test.invalid")
// Before birth: a gentle "no one's shadowing you yet".
if err := p.handleShadowCmd(MessageContext{Sender: user}); err != nil {
t.Fatal(err)
}
if dms := sink.dmsTo(user); len(dms) != 1 || !strings.Contains(dms[0], "shadowing you yet") {
t.Errorf("expected an unborn-shadow DM, got: %v", sink.dmsTo(user))
}
seed := newShadow(user, "Tester")
seed.ZonesCleared = 4
if err := upsertShadow(seed); err != nil {
t.Fatal(err)
}
if err := p.handleShadowCmd(MessageContext{Sender: user}); err != nil {
t.Fatal(err)
}
dms := sink.dmsTo(user)
if len(dms) != 2 || !strings.Contains(dms[1], seed.Name) {
t.Errorf("expected a status DM naming the shadow, got: %v", dms)
}
}

View File

@@ -40,7 +40,7 @@ func twinBeeMaxTier() int {
if !c.Alive {
continue
}
combined := dndLevelForUser(c.UserID) + c.MiningSkill + c.ForagingSkill + c.FishingSkill
combined := combinedAdvLevel(&c)
if combined > bestLevel {
bestLevel = combined
}
@@ -283,7 +283,7 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe
weight := 4 // minimum (level 1 in all 4 skills)
for _, c := range chars {
if c.UserID == uid {
weight = dndLevelForUser(c.UserID) + c.MiningSkill + c.ForagingSkill + c.FishingSkill
weight = combinedAdvLevel(&c)
if weight < 4 {
weight = 4
}

View File

@@ -0,0 +1,784 @@
package plugin
import (
"database/sql"
"fmt"
"hash/fnv"
"log/slog"
"sort"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// World boss (N6/C3) — the monthly communal "Siege". A named boss camps outside
// town for a fixed window with a single shared HP pool. Every player gets one
// bout per day against it (an arena-style fight through runZoneCombat); the
// damage they deal is subtracted from the shared pool whether the individual
// bout is won or lost. The pool falling to zero defeats the boss; the window
// lapsing with the pool still up means the boss survives and loots the town.
//
// Economy (decisions locked 2026-07-10): a defeat pays every contributor from a
// freshly minted bounty scaled by fights fought (not damage — accessibility),
// plus a consumable cache and one low-rate treasure roll each; a survival debits
// the community pot as a tribute (a sink the economy wants). The bout itself
// costs real HP the way the arena does — no restore — but never permadeaths.
//
// The event lives in its own tables (world_boss / world_boss_contrib), outside
// the saveAdvCharacter fan-out, so a character save can never clobber the shared
// pool — the same structural isolation adventure_shadow earns.
const (
// worldBossWindow is how long the boss camps outside town once it spawns.
worldBossWindow = 72 * time.Hour
// worldBossPresenceDays is the any-chat presence lookback used to size the
// boss to the community that will actually fight it
// (feedback_presence_is_any_chat).
worldBossPresenceDays = 14
// worldBossMinTier floors the boss tier — the Siege is always a serious
// fight, never a T1/T2 pushover, even for a low-level town.
worldBossMinTier = 3
// worldBossBoutsPerPlayer sizes the shared pool: expected full-damage bouts
// to fell the boss ≈ activePlayers × this. Higher = a longer siege that needs
// more of the town to turn up. Tunable; watch prod turnout.
worldBossBoutsPerPlayer = 2.0
// worldBossMinBouts / worldBossMaxBouts clamp the pool so a near-empty town
// still gets a beatable boss and a huge town doesn't get an unkillable one.
worldBossMinBouts = 4
worldBossMaxBouts = 60
// worldBossDefeatBaseEuro is the minted bounty a single fight earns a
// contributor on a defeat; total payout = base × fights fought.
worldBossDefeatBaseEuro = 1500
// worldBossTributePct is the fraction of the community pot the boss loots on
// a survival — a visible pot sink.
worldBossTributePct = 0.20
// worldBossTreasureWeight is the (low) weight of the single treasure roll each
// contributor gets on a defeat.
worldBossTreasureWeight = advTreasureWeightElite
// worldBossCacheSize is how many tier consumables each contributor is handed
// on a defeat.
worldBossCacheSize = 2
)
// worldBossState mirrors an active world_boss row.
type worldBossState struct {
ID int64
Name string
Tier int
HPMax int
HPCurrent int
Status string // active | defeated | survived
StartsAt time.Time
EndsAt time.Time
}
// worldBossContrib mirrors a world_boss_contrib row — one player's tally against
// one boss.
type worldBossContrib struct {
BossID int64
UserID id.UserID
Fights int
Damage int
LastFightDate string
}
// worldBossNames is the pool a Siege boss is named from — big, ancient,
// town-threatening things. Picked deterministically per event so a given month's
// boss reads the same to everyone.
var worldBossNames = []string{
"Gorloth the Sunderer", "The Ashen Wyrm", "Kravok, Maw of the Deep",
"Nyxaris the Devouring", "The Iron Colossus", "Ssath'ra, Coil of Ruin",
"Ymirok the Frostbound", "The Hollow Leviathan", "Dravmaug, Ender of Walls",
"The Bone Cathedral", "Vornath the Insatiable", "The Screaming Tide",
}
// worldBossNameFor picks a stable boss name from an event key (the spawn month),
// so the same Siege always reads by the same name.
func worldBossNameFor(eventKey string) string {
h := fnv.New32a()
_, _ = h.Write([]byte(eventKey))
return worldBossNames[int(h.Sum32())%len(worldBossNames)]
}
// ── Persistence ──────────────────────────────────────────────────────────────
func scanWorldBoss(row interface{ Scan(...any) error }) (*worldBossState, error) {
b := &worldBossState{}
err := row.Scan(&b.ID, &b.Name, &b.Tier, &b.HPMax, &b.HPCurrent, &b.Status, &b.StartsAt, &b.EndsAt)
if err != nil {
return nil, err
}
return b, nil
}
// loadActiveWorldBoss returns the live Siege, or (nil, nil) if none is camped.
func loadActiveWorldBoss() (*worldBossState, error) {
b, err := scanWorldBoss(db.Get().QueryRow(
`SELECT id, name, tier, hp_max, hp_current, status, starts_at, ends_at
FROM world_boss WHERE status = 'active' ORDER BY id DESC LIMIT 1`))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return b, nil
}
// loadWorldBoss reads one boss by id (history included). (nil, nil) if absent.
func loadWorldBoss(bossID int64) (*worldBossState, error) {
b, err := scanWorldBoss(db.Get().QueryRow(
`SELECT id, name, tier, hp_max, hp_current, status, starts_at, ends_at
FROM world_boss WHERE id = ?`, bossID))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return b, nil
}
// insertWorldBoss writes a fresh active boss and returns its id.
func insertWorldBoss(name string, tier, hpMax int, startsAt, endsAt time.Time) (int64, error) {
res, err := db.Get().Exec(
`INSERT INTO world_boss (name, tier, hp_max, hp_current, status, starts_at, ends_at)
VALUES (?, ?, ?, ?, 'active', ?, ?)`,
name, tier, hpMax, hpMax, startsAt, endsAt)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// applyWorldBossDamage subtracts damage from the shared pool atomically, clamped
// at zero, only while the boss is still active. Returns the pool's new value and
// whether the pool is now down (killed). The UPDATE and the re-read are separate
// statements, so two concurrent killers can BOTH observe killed=true — that is
// fine: resolveWorldBossDefeated dedupes on the status='active' guard in
// setWorldBossStatus, so only one payout ever fires. killed is just "should the
// caller attempt resolution".
func applyWorldBossDamage(bossID int64, dmg int) (remaining int, killed bool, err error) {
if dmg < 0 {
dmg = 0
}
_, err = db.Get().Exec(
`UPDATE world_boss SET hp_current = MAX(0, hp_current - ?)
WHERE id = ? AND status = 'active'`, dmg, bossID)
if err != nil {
return 0, false, err
}
b, err := loadWorldBoss(bossID)
if err != nil || b == nil {
return 0, false, err
}
return b.HPCurrent, b.HPCurrent <= 0 && b.Status == "active", nil
}
// setWorldBossStatus closes out a boss (defeated | survived) and stamps
// resolved_at. Guarded on status='active' so a double resolution is a no-op.
func setWorldBossStatus(bossID int64, status string) (bool, error) {
res, err := db.Get().Exec(
`UPDATE world_boss SET status = ?, resolved_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'active'`, status, bossID)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n == 1, nil
}
// upsertWorldBossContrib bumps a player's tally against a boss.
func upsertWorldBossContrib(bossID int64, userID id.UserID, dmg int, dateKey string) error {
_, err := db.Get().Exec(
`INSERT INTO world_boss_contrib (boss_id, user_id, fights, damage, last_fight_date)
VALUES (?, ?, 1, ?, ?)
ON CONFLICT(boss_id, user_id) DO UPDATE SET
fights = fights + 1,
damage = damage + excluded.damage,
last_fight_date = excluded.last_fight_date`,
bossID, string(userID), dmg, dateKey)
return err
}
// loadWorldBossContrib reads one player's tally, or (nil, nil) if they haven't
// fought this boss.
func loadWorldBossContrib(bossID int64, userID id.UserID) (*worldBossContrib, error) {
c := &worldBossContrib{BossID: bossID, UserID: userID}
err := db.Get().QueryRow(
`SELECT fights, damage, last_fight_date FROM world_boss_contrib
WHERE boss_id = ? AND user_id = ?`, bossID, string(userID)).
Scan(&c.Fights, &c.Damage, &c.LastFightDate)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return c, nil
}
// loadWorldBossContribs reads every contributor to a boss, ordered by fights
// desc then damage desc (the board order).
func loadWorldBossContribs(bossID int64) ([]worldBossContrib, error) {
rows, err := db.Get().Query(
`SELECT user_id, fights, damage, last_fight_date FROM world_boss_contrib
WHERE boss_id = ? ORDER BY fights DESC, damage DESC`, bossID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []worldBossContrib
for rows.Next() {
c := worldBossContrib{BossID: bossID}
var uid string
if err := rows.Scan(&uid, &c.Fights, &c.Damage, &c.LastFightDate); err != nil {
return nil, err
}
c.UserID = id.UserID(uid)
out = append(out, c)
}
return out, rows.Err()
}
// ── Scaling ──────────────────────────────────────────────────────────────────
// activePlayerIDs returns the user ids that have chatted (any message, not just
// commands) in the last `days` days — the any-chat presence signal
// (feedback_presence_is_any_chat), read off the streaks plugin's daily_activity
// table.
func activePlayerIDs(days int) ([]id.UserID, error) {
cutoff := time.Now().UTC().AddDate(0, 0, -days).Format("2006-01-02")
rows, err := db.Get().Query(
`SELECT DISTINCT user_id FROM daily_activity WHERE date >= ?`, cutoff)
if err != nil {
return nil, err
}
defer rows.Close()
var out []id.UserID
for rows.Next() {
var uid string
if err := rows.Scan(&uid); err != nil {
return nil, err
}
out = append(out, id.UserID(uid))
}
return out, rows.Err()
}
// combinedAdvLevel is the same "how strong is this adventurer" reducer TwinBee
// uses — combat level plus the three gathering skills.
func combinedAdvLevel(c *AdventureCharacter) int {
return dndLevelForUser(c.UserID) + c.MiningSkill + c.ForagingSkill + c.FishingSkill
}
// medianInt returns the median of a sorted-or-not int slice (0 for empty).
func medianInt(xs []int) int {
if len(xs) == 0 {
return 0
}
s := append([]int(nil), xs...)
sort.Ints(s)
mid := len(s) / 2
if len(s)%2 == 1 {
return s[mid]
}
return (s[mid-1] + s[mid]) / 2
}
// tierForCombinedLevel buckets a combined adventure level to a zone tier, floored
// at worldBossMinTier. Same thresholds as twinBeeMaxTier (combined 12 → T4,
// 21 → T5), applied here to the median rather than the max.
func tierForCombinedLevel(level int) int {
switch {
case level >= 21:
return 5
case level >= 12:
return 4
default:
return worldBossMinTier
}
}
// groupInt renders an int with thousands separators (no currency symbol),
// reusing fmtEuro's grouping.
func groupInt(n int) string {
return strings.TrimPrefix(fmtEuro(n), "€")
}
// worldBossSpawnPlan computes the boss's tier and pool HP from the players active
// in the presence window. Returns the tier, HP, and the active-player count that
// drove the sizing (0 players ⇒ a floor boss so a manual spawn still works).
func worldBossSpawnPlan() (tier, hpMax, activeN int) {
ids, err := activePlayerIDs(worldBossPresenceDays)
if err != nil {
slog.Warn("worldboss: active player lookup failed", "err", err)
}
active := make(map[id.UserID]bool, len(ids))
for _, uid := range ids {
active[uid] = true
}
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Warn("worldboss: char load failed", "err", err)
}
var levels []int
for i := range chars {
c := &chars[i]
if !active[c.UserID] || !c.Alive {
continue
}
levels = append(levels, combinedAdvLevel(c))
}
activeN = len(levels)
tier = tierForCombinedLevel(medianInt(levels))
bouts := int(float64(max(activeN, 1)) * worldBossBoutsPerPlayer)
if bouts < worldBossMinBouts {
bouts = worldBossMinBouts
}
if bouts > worldBossMaxBouts {
bouts = worldBossMaxBouts
}
perBout, _, _, _, _ := arenaTierBaseStats(tier)
hpMax = perBout * bouts
return tier, hpMax, activeN
}
// worldBossTemplate builds the disposable per-bout enemy stat block. The shared
// pool lives in world_boss.hp_current; this template is just the thing a single
// player swings at, so its own HP is the arena tier's boss HP — beatable in a
// bout, which lets a strong player land a full boss's worth of damage on the
// pool.
func worldBossTemplate(name string, tier int) DnDMonsterTemplate {
hp, ac, atk, ab, cr := arenaTierBaseStats(tier)
return DnDMonsterTemplate{
ID: fmt.Sprintf("worldboss_t%d", tier),
Name: name,
CR: cr,
HP: hp,
AC: ac,
Attack: atk,
AttackBonus: ab,
Speed: 12,
BlockRate: 0.05,
XPValue: arenaTiers[tier-1].BattleXP * 5,
Notes: "the Siege",
}
}
// ── Lifecycle ────────────────────────────────────────────────────────────────
// spawnWorldBoss mints a fresh Siege and announces it. It refuses if one is
// already camped (only one at a time). eventKey names the boss deterministically
// (the spawn month for the auto path, a stamp for a manual one).
func (p *AdventurePlugin) spawnWorldBoss(eventKey string) (*worldBossState, error) {
if existing, err := loadActiveWorldBoss(); err != nil {
return nil, err
} else if existing != nil {
return existing, fmt.Errorf("a world boss is already active")
}
tier, hpMax, activeN := worldBossSpawnPlan()
name := worldBossNameFor(eventKey)
now := time.Now().UTC()
ends := now.Add(worldBossWindow)
bossID, err := insertWorldBoss(name, tier, hpMax, now, ends)
if err != nil {
return nil, err
}
boss, err := loadWorldBoss(bossID)
if err != nil {
return nil, err
}
p.announceWorldBossSpawn(boss, activeN)
slog.Info("worldboss: spawned", "id", bossID, "name", name, "tier", tier, "hp", hpMax, "activeN", activeN)
return boss, nil
}
// worldBossTick rides the 1-minute event ticker. It auto-spawns a boss on the
// first of each UTC month and resolves a live boss whose window has lapsed. The
// defeat path is not here — a bout crossing the pool to zero resolves inline
// (W2), because the ticker never sees the pool between two 60s reads.
func (p *AdventurePlugin) worldBossTick() {
boss, err := loadActiveWorldBoss()
if err != nil {
slog.Warn("worldboss: tick load failed", "err", err)
return
}
now := time.Now().UTC()
if boss != nil {
// Safety net: a killing blow commits the pool to 0 inline, but if the
// process died (or was redeployed) before the bout resolved the status,
// the boss is left active/0-HP. Resolve it as the defeat it was — never
// let the survive branch below debit the pot for a boss the town killed.
if boss.HPCurrent <= 0 {
p.resolveWorldBossDefeated(boss)
return
}
if now.After(boss.EndsAt) {
p.resolveWorldBossSurvived(boss)
}
return
}
// No boss camped — auto-spawn on the 1st of the month, once.
if now.Day() != 1 {
return
}
monthKey := now.Format("2006-01")
if db.JobCompleted("worldboss_spawn", monthKey) {
return
}
if _, err := p.spawnWorldBoss(monthKey); err != nil {
slog.Warn("worldboss: auto-spawn failed", "err", err)
return
}
db.MarkJobCompleted("worldboss_spawn", monthKey)
}
// ── Resolution ───────────────────────────────────────────────────────────────
// worldBossPayout is one contributor's minted defeat reward.
type worldBossPayout struct {
UserID id.UserID
Fights int
Euro int
}
// computeWorldBossPayouts scales the minted bounty by fights fought (not damage —
// accessibility). Pure so the split is testable without a euro/Matrix stub.
func computeWorldBossPayouts(contribs []worldBossContrib, baseEuro int) []worldBossPayout {
out := make([]worldBossPayout, 0, len(contribs))
for _, c := range contribs {
if c.Fights <= 0 {
continue
}
out = append(out, worldBossPayout{UserID: c.UserID, Fights: c.Fights, Euro: baseEuro * c.Fights})
}
return out
}
// resolveWorldBossDefeated pays every contributor a minted bounty scaled by
// fights, plus a consumable cache and one low-rate treasure roll each, then
// announces the kill. Guarded so it fires once.
func (p *AdventurePlugin) resolveWorldBossDefeated(boss *worldBossState) {
won, err := setWorldBossStatus(boss.ID, "defeated")
if err != nil {
slog.Warn("worldboss: defeat close-out failed", "err", err)
return
}
if !won {
return // someone else already resolved it
}
contribs, err := loadWorldBossContribs(boss.ID)
if err != nil {
slog.Warn("worldboss: contrib load failed", "err", err)
}
payouts := computeWorldBossPayouts(contribs, worldBossDefeatBaseEuro)
treasureZone := worldBossTreasureZone(boss.Tier)
for _, pay := range payouts {
p.euro.Credit(pay.UserID, float64(pay.Euro), "worldboss_bounty")
for _, item := range consumableCache(boss.Tier, worldBossCacheSize) {
it := item
p.grantZoneItem(pay.UserID, &it, "🧪")
}
if treasureZone != "" {
p.rollZoneTreasure(pay.UserID, treasureZone, worldBossTreasureWeight)
}
}
p.announceWorldBossDefeated(boss, payouts)
slog.Info("worldboss: defeated", "id", boss.ID, "contributors", len(payouts))
}
// resolveWorldBossSurvived debits the community pot as a tribute (a sink) and
// announces the town's loss. Guarded so it fires once.
func (p *AdventurePlugin) resolveWorldBossSurvived(boss *worldBossState) {
won, err := setWorldBossStatus(boss.ID, "survived")
if err != nil {
slog.Warn("worldboss: survive close-out failed", "err", err)
return
}
if !won {
return
}
tribute := int(float64(communityPotBalance()) * worldBossTributePct)
paid := 0
if tribute > 0 && communityPotDebit(tribute) {
paid = tribute
}
p.announceWorldBossSurvived(boss, paid)
slog.Info("worldboss: survived", "id", boss.ID, "tribute", paid)
}
// worldBossTreasureZone maps the boss tier to a representative zone whose treasure
// table the defeat roll draws from. "" if no zone of that tier exists.
func worldBossTreasureZone(tier int) ZoneID {
zs := zonesByTier(ZoneTier(tier))
if len(zs) == 0 {
return ""
}
return zs[0].ID
}
// ── Announcements (games room) ───────────────────────────────────────────────
// announceWorldBoss posts to the shared games room. No-op if GAMES_ROOM is unset.
func (p *AdventurePlugin) announceWorldBoss(text string) {
gr := gamesRoom()
if gr == "" {
return
}
if err := p.SendMessage(gr, text); err != nil {
slog.Warn("worldboss: games-room announce failed", "err", err)
}
}
func (p *AdventurePlugin) announceWorldBossSpawn(boss *worldBossState, activeN int) {
p.announceWorldBoss(fmt.Sprintf(
"⚔️ **The Siege has begun!** **%s** (Tier %d) camps outside town with **%s HP**.\n"+
"Everyone gets **one bout per day** for the next 72 hours — `!adventure worldboss fight`. "+
"Every hit chips the shared pool. Fell it together for a bounty; let it stand and it loots the town.",
boss.Name, boss.Tier, groupInt(boss.HPMax)))
}
func (p *AdventurePlugin) announceWorldBossDefeated(boss *worldBossState, payouts []worldBossPayout) {
var top string
if len(payouts) > 0 {
top = fmt.Sprintf(" Top of the muster: %s (%d fights).",
p.DisplayName(payouts[0].UserID), payouts[0].Fights)
}
p.announceWorldBoss(fmt.Sprintf(
"🏆 **%s has fallen!** %d adventurer(s) brought it down.%s\n"+
"Bounties, supply caches, and salvage have been paid out to everyone who fought.",
boss.Name, len(payouts), top))
}
func (p *AdventurePlugin) announceWorldBossSurvived(boss *worldBossState, tribute int) {
msg := fmt.Sprintf("💀 **%s still stands.** The Siege is over — the town could not bring it down.", boss.Name)
if tribute > 0 {
msg += fmt.Sprintf(" It loots **€%s** from the community pot on its way out.", groupInt(tribute))
}
p.announceWorldBoss(msg)
}
// ── Player command (W2) ──────────────────────────────────────────────────────
// handleWorldBossCmd routes `!adventure worldboss [status|fight|spawn]` (alias
// `!adventure siege …`). Bare / "status" shows the board; "fight" takes today's
// bout; "spawn" is an operator override.
func (p *AdventurePlugin) handleWorldBossCmd(ctx MessageContext, args string) error {
switch strings.ToLower(strings.TrimSpace(args)) {
case "", "status":
return p.worldBossStatusDM(ctx)
case "fight":
return p.fightWorldBoss(ctx)
case "spawn":
return p.worldBossOperatorSpawn(ctx)
}
return p.SendDM(ctx.Sender, "Usage: `!adventure worldboss [status|fight]`")
}
// worldBossOperatorSpawn lets an admin start a Siege on demand (the override half
// of the "both" spawn decision). Silent to non-admins, like the other admin
// commands.
func (p *AdventurePlugin) worldBossOperatorSpawn(ctx MessageContext) error {
if !p.IsAdmin(ctx.Sender) {
return nil
}
key := "manual-" + time.Now().UTC().Format("2006-01-02T15:04")
boss, err := p.spawnWorldBoss(key)
if err != nil {
return p.SendDM(ctx.Sender, fmt.Sprintf("Could not spawn the Siege: %v", err))
}
return p.SendDM(ctx.Sender, fmt.Sprintf(
"Spawned **%s** (Tier %d, %s HP). It camps for 72 hours.",
boss.Name, boss.Tier, groupInt(boss.HPMax)))
}
// fightWorldBoss runs one player's daily bout against the Siege: an arena-style
// solo fight whose damage is subtracted from the shared pool win or lose. Real
// HP cost, no death — a loss leaves the fighter battered (floored at 1 HP) but
// standing. The per-user lock serialises a player's own repeat submits, so the
// once-per-day gate can't be raced by a double-tap.
func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
boss, err := loadActiveWorldBoss()
if err != nil {
return p.SendDM(ctx.Sender, "Something went wrong reaching the Siege. Try again in a moment.")
}
if boss == nil {
return p.SendDM(ctx.Sender, "No Siege is camped outside town right now.")
}
char, err := loadAdvCharacter(ctx.Sender)
if err != nil || char == nil {
return p.SendDM(ctx.Sender, "You need an adventurer first — type `!adventure` to begin.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, "You're dead. The Siege will have to wait until you're back on your feet.")
}
today := time.Now().UTC().Format("2006-01-02")
if worldBossBoutUsedToday(boss.ID, ctx.Sender, today) {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You've already taken your bout against **%s** today. Come back tomorrow — one fight per day.", boss.Name))
}
bout, err := p.resolveWorldBossBout(ctx.Sender, boss, today)
if err != nil {
slog.Error("worldboss: bout failed", "user", ctx.Sender, "err", err)
return p.SendDM(ctx.Sender, "The Siege combat hit an error. Try again in a moment.")
}
// Resolve a defeat BEFORE streaming the (multi-second) narration. The pool is
// already committed to 0; flipping the status now closes the window where a
// crash or the ticker's survive path could resolve a killed boss as a
// survival and debit the pot instead of paying bounties.
if bout.Killed {
p.resolveWorldBossDefeated(boss)
}
playerName, _ := loadDisplayName(ctx.Sender)
if playerName == "" {
playerName = "You"
}
phaseMessages := append([]string{
fmt.Sprintf("⚔️ **The Siege — %s** (Tier %d)", boss.Name, boss.Tier),
}, RenderCombatLog(bout.Combat, playerName, boss.Name)...)
var footer string
if bout.Killed {
footer = fmt.Sprintf("💥 You deal **%d** damage — the killing blow! **%s** falls!", bout.Damage, boss.Name)
} else {
footer = fmt.Sprintf("💥 You deal **%d** damage. **%s** has **%s / %s HP** left.",
bout.Damage, boss.Name, groupInt(bout.Remaining), groupInt(boss.HPMax))
}
if bout.Battered {
footer += "\nYou stagger out of the fight at 1 HP — rest up before your next outing."
}
<-p.sendZoneCombatMessages(ctx.Sender, phaseMessages, footer)
return nil
}
// worldBossBoutUsedToday reports whether a player has already spent their daily
// bout against this boss.
func worldBossBoutUsedToday(bossID int64, userID id.UserID, dateKey string) bool {
c, err := loadWorldBossContrib(bossID, userID)
return err == nil && c != nil && c.LastFightDate == dateKey
}
// worldBossBoutResult is the outcome of one resolved bout, before narration.
type worldBossBoutResult struct {
Damage int
Remaining int
Killed bool
Battered bool
Combat CombatResult
}
// resolveWorldBossBout runs the arena-style fight, applies the real HP cost with
// the no-death floor, subtracts the damage dealt from the shared pool, and
// records the contribution. It performs no DM or games-room I/O, so it is the
// testable core of fightWorldBoss.
func (p *AdventurePlugin) resolveWorldBossBout(userID id.UserID, boss *worldBossState, dateKey string) (worldBossBoutResult, error) {
monster := worldBossTemplate(boss.Name, boss.Tier)
result, err := p.runZoneCombat(userID, monster, boss.Tier, bossCombatPhases, 50)
if err != nil {
return worldBossBoutResult{}, err
}
// No death / no hospital: floor the fighter at 1 HP so a loss never reads as
// a corpse. runZoneCombat already persisted the real HP cost.
battered := worldBossFloorHP(userID)
dmg := result.EnemyEntryHP - result.EnemyEndHP
if dmg < 0 {
dmg = 0
}
// Record the contribution (which also sets the once-per-day gate) BEFORE
// draining the shared pool. If this write fails we must not have already
// drained the pool — otherwise the player could refight a pool they emptied
// and lose the credit for it. A hard error here aborts the bout; the HP cost
// was already paid, but the pool is untouched and the player can retry.
if err := upsertWorldBossContrib(boss.ID, userID, dmg, dateKey); err != nil {
return worldBossBoutResult{}, err
}
remaining, killed, err := applyWorldBossDamage(boss.ID, dmg)
if err != nil {
return worldBossBoutResult{}, err
}
return worldBossBoutResult{
Damage: dmg, Remaining: remaining, Killed: killed, Battered: battered, Combat: result,
}, nil
}
// worldBossFloorHP raises a player to 1 HP if the bout left them at zero, and
// reports whether it had to. Keeps "no death" honest without undoing the real
// HP cost of a bout the player mostly survived.
func worldBossFloorHP(userID id.UserID) bool {
cur, _ := dndHPSnapshot(userID)
if cur > 0 {
return false
}
if _, err := db.Get().Exec(
`UPDATE dnd_character SET hp_current = 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?`,
string(userID)); err != nil {
slog.Warn("worldboss: hp floor failed", "user", userID, "err", err)
}
return true
}
// worldBossStatusDM renders the current Siege board to the caller.
func (p *AdventurePlugin) worldBossStatusDM(ctx MessageContext) error {
boss, err := loadActiveWorldBoss()
if err != nil {
return p.SendDM(ctx.Sender, "Something went wrong reaching the Siege. Try again in a moment.")
}
if boss == nil {
return p.SendDM(ctx.Sender,
"No Siege is camped outside town right now. The next one arrives at the start of the month.")
}
var sb strings.Builder
pct := 0
if boss.HPMax > 0 {
pct = boss.HPCurrent * 100 / boss.HPMax
}
fmt.Fprintf(&sb, "⚔️ **The Siege — %s** (Tier %d)\n", boss.Name, boss.Tier)
fmt.Fprintf(&sb, "Pool: **%s / %s HP** (%d%%)\n", groupInt(boss.HPCurrent), groupInt(boss.HPMax), pct)
if left := time.Until(boss.EndsAt); left > 0 {
h := int(left.Hours())
m := int(left.Minutes()) % 60
fmt.Fprintf(&sb, "Time left: **%dh %dm**\n", h, m)
}
today := time.Now().UTC().Format("2006-01-02")
if c, _ := loadWorldBossContrib(boss.ID, ctx.Sender); c != nil {
line := fmt.Sprintf("\nYou: %d fight(s), %s damage dealt.", c.Fights, groupInt(c.Damage))
if c.LastFightDate == today {
line += " Your bout is spent for today."
} else {
line += " Your bout is ready — `!adventure worldboss fight`."
}
sb.WriteString(line + "\n")
} else {
sb.WriteString("\nYou haven't fought yet — `!adventure worldboss fight`.\n")
}
if contribs, _ := loadWorldBossContribs(boss.ID); len(contribs) > 0 {
sb.WriteString("\n**Top of the muster:**\n")
for i, c := range contribs {
if i >= 5 {
break
}
fmt.Fprintf(&sb, "%d. %s — %d fight(s)\n", i+1, p.DisplayName(c.UserID), c.Fights)
}
}
return p.SendDM(ctx.Sender, sb.String())
}

View File

@@ -0,0 +1,417 @@
package plugin
import (
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func newWorldBossTestDB(t *testing.T) {
t.Helper()
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
}
func TestWorldBossName_DeterministicAndInPool(t *testing.T) {
first := worldBossNameFor("2026-07")
if again := worldBossNameFor("2026-07"); again != first {
t.Errorf("name not deterministic: %q vs %q", first, again)
}
inPool := false
for _, n := range worldBossNames {
if n == first {
inPool = true
break
}
}
if !inPool {
t.Errorf("generated name %q not in worldBossNames pool", first)
}
}
func TestMedianInt(t *testing.T) {
cases := []struct {
in []int
want int
}{
{nil, 0},
{[]int{5}, 5},
{[]int{3, 1, 2}, 2}, // odd, unsorted
{[]int{4, 2, 8, 6}, 5}, // even → mean of the two middles (4,6)
{[]int{10, 10, 10, 10}, 10}, // even, all equal
}
for _, c := range cases {
if got := medianInt(c.in); got != c.want {
t.Errorf("medianInt(%v) = %d, want %d", c.in, got, c.want)
}
}
}
func TestTierForCombinedLevel(t *testing.T) {
cases := []struct{ level, want int }{
{0, worldBossMinTier},
{11, worldBossMinTier},
{12, 4},
{20, 4},
{21, 5},
{99, 5},
}
for _, c := range cases {
if got := tierForCombinedLevel(c.level); got != c.want {
t.Errorf("tierForCombinedLevel(%d) = %d, want %d", c.level, got, c.want)
}
}
}
func TestWorldBoss_InsertLoadRoundTrip(t *testing.T) {
newWorldBossTestDB(t)
now := time.Now().UTC().Truncate(time.Second)
id0, err := insertWorldBoss("Gorloth", 5, 2400, now, now.Add(worldBossWindow))
if err != nil {
t.Fatal(err)
}
active, err := loadActiveWorldBoss()
if err != nil || active == nil {
t.Fatalf("loadActiveWorldBoss: %v (nil=%v)", err, active == nil)
}
if active.ID != id0 || active.Name != "Gorloth" || active.Tier != 5 ||
active.HPMax != 2400 || active.HPCurrent != 2400 || active.Status != "active" {
t.Errorf("round-trip mismatch: %+v", active)
}
}
func TestApplyWorldBossDamage_ClampsAndFells(t *testing.T) {
newWorldBossTestDB(t)
now := time.Now().UTC()
bossID, err := insertWorldBoss("Kravok", 3, 100, now, now.Add(worldBossWindow))
if err != nil {
t.Fatal(err)
}
rem, killed, err := applyWorldBossDamage(bossID, 40)
if err != nil || rem != 60 || killed {
t.Fatalf("first hit: rem=%d killed=%v err=%v (want 60,false)", rem, killed, err)
}
// Overkill clamps at 0 and reports the fell.
rem, killed, err = applyWorldBossDamage(bossID, 999)
if err != nil || rem != 0 || !killed {
t.Fatalf("overkill: rem=%d killed=%v err=%v (want 0,true)", rem, killed, err)
}
// Negative damage is treated as zero.
if rem, _, err := applyWorldBossDamage(bossID, -5); err != nil || rem != 0 {
t.Fatalf("negative: rem=%d err=%v", rem, err)
}
}
func TestSetWorldBossStatus_ResolvesOnce(t *testing.T) {
newWorldBossTestDB(t)
now := time.Now().UTC()
bossID, err := insertWorldBoss("Ymirok", 4, 500, now, now.Add(worldBossWindow))
if err != nil {
t.Fatal(err)
}
if ok, err := setWorldBossStatus(bossID, "defeated"); err != nil || !ok {
t.Fatalf("first close-out: ok=%v err=%v (want true)", ok, err)
}
// A second close-out is a no-op — the boss is no longer active.
if ok, err := setWorldBossStatus(bossID, "survived"); err != nil || ok {
t.Fatalf("double close-out: ok=%v err=%v (want false)", ok, err)
}
// And damage no longer lands on a resolved boss.
if rem, killed, _ := applyWorldBossDamage(bossID, 500); rem != 500 || killed {
t.Errorf("damage on resolved boss changed pool: rem=%d killed=%v", rem, killed)
}
}
func TestWorldBossContrib_UpsertAccumulates(t *testing.T) {
newWorldBossTestDB(t)
user := id.UserID("@a:test.invalid")
if err := upsertWorldBossContrib(1, user, 30, "2026-07-01"); err != nil {
t.Fatal(err)
}
if err := upsertWorldBossContrib(1, user, 45, "2026-07-02"); err != nil {
t.Fatal(err)
}
c, err := loadWorldBossContrib(1, user)
if err != nil || c == nil {
t.Fatalf("load: %v (nil=%v)", err, c == nil)
}
if c.Fights != 2 || c.Damage != 75 || c.LastFightDate != "2026-07-02" {
t.Errorf("accumulate mismatch: %+v", c)
}
}
func TestLoadWorldBossContribs_OrderedByFights(t *testing.T) {
newWorldBossTestDB(t)
a := id.UserID("@a:test.invalid")
b := id.UserID("@b:test.invalid")
// a: 1 fight; b: 2 fights → b sorts first.
upsertWorldBossContrib(7, a, 100, "2026-07-01")
upsertWorldBossContrib(7, b, 10, "2026-07-01")
upsertWorldBossContrib(7, b, 10, "2026-07-02")
got, err := loadWorldBossContribs(7)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].UserID != b || got[1].UserID != a {
t.Errorf("order = %+v, want b then a", got)
}
}
func TestComputeWorldBossPayouts_ScalesByFights(t *testing.T) {
contribs := []worldBossContrib{
{UserID: "@a:t", Fights: 3, Damage: 999},
{UserID: "@b:t", Fights: 1, Damage: 10},
{UserID: "@c:t", Fights: 0, Damage: 0}, // no fights → no payout
}
pays := computeWorldBossPayouts(contribs, 1000)
if len(pays) != 2 {
t.Fatalf("got %d payouts, want 2 (zero-fight excluded)", len(pays))
}
if pays[0].Euro != 3000 || pays[1].Euro != 1000 {
t.Errorf("payouts = %d,%d want 3000,1000", pays[0].Euro, pays[1].Euro)
}
}
// TestWorldBossSpawnPlan_SizesToActiveTown seeds three active max-level players
// and checks the boss is T5 with a pool scaled to the turnout.
func TestWorldBossSpawnPlan_SizesToActiveTown(t *testing.T) {
newWorldBossTestDB(t)
today := time.Now().UTC().Format("2006-01-02")
for _, u := range []string{"@a:test.invalid", "@b:test.invalid", "@c:test.invalid"} {
c := &AdventureCharacter{UserID: id.UserID(u), DisplayName: "P", Alive: true,
ForagingSkill: 30, CreatedAt: time.Now().UTC()}
if err := saveAdvCharacter(c); err != nil {
t.Fatal(err)
}
if _, err := db.Get().Exec(
`INSERT INTO daily_activity (user_id, date, message_count) VALUES (?, ?, 1)`,
u, today); err != nil {
t.Fatal(err)
}
}
tier, hpMax, activeN := worldBossSpawnPlan()
if activeN != 3 {
t.Errorf("activeN = %d, want 3", activeN)
}
if tier != 5 {
t.Errorf("tier = %d, want 5 (combined >= 21)", tier)
}
// bouts = 3 × 2.0 = 6; perBout at T5 = 400 → pool 2400.
perBout, _, _, _, _ := arenaTierBaseStats(5)
if want := perBout * 6; hpMax != want {
t.Errorf("hpMax = %d, want %d", hpMax, want)
}
}
// TestWorldBossSpawnPlan_EmptyTownGetsFloorBoss: no active players still yields a
// beatable floor boss so a manual spawn works.
func TestWorldBossSpawnPlan_EmptyTownGetsFloorBoss(t *testing.T) {
newWorldBossTestDB(t)
tier, hpMax, activeN := worldBossSpawnPlan()
if activeN != 0 {
t.Errorf("activeN = %d, want 0", activeN)
}
if tier != worldBossMinTier {
t.Errorf("tier = %d, want floor %d", tier, worldBossMinTier)
}
perBout, _, _, _, _ := arenaTierBaseStats(worldBossMinTier)
if want := perBout * worldBossMinBouts; hpMax != want {
t.Errorf("hpMax = %d, want %d (min bouts floor)", hpMax, want)
}
}
func TestSpawnWorldBoss_RefusesWhenOneIsActive(t *testing.T) {
newWorldBossTestDB(t)
p := &AdventurePlugin{}
first, err := p.spawnWorldBoss("2026-07")
if err != nil || first == nil {
t.Fatalf("first spawn: %v (nil=%v)", err, first == nil)
}
second, err := p.spawnWorldBoss("2026-07")
if err == nil {
t.Error("second spawn should refuse while one is active")
}
if second == nil || second.ID != first.ID {
t.Error("refusal should return the existing active boss")
}
}
// TestResolveWorldBossSurvived_DebitsPot exercises the survive path end to end:
// the boss closes out and the pot loses its tribute.
func TestResolveWorldBossSurvived_DebitsPot(t *testing.T) {
newWorldBossTestDB(t)
communityPotAdd(1000)
now := time.Now().UTC()
bossID, err := insertWorldBoss("Vornath", 5, 2400, now.Add(-worldBossWindow), now.Add(-time.Hour))
if err != nil {
t.Fatal(err)
}
boss, _ := loadWorldBoss(bossID)
p := &AdventurePlugin{}
p.resolveWorldBossSurvived(boss)
after, _ := loadWorldBoss(bossID)
if after.Status != "survived" {
t.Errorf("status = %q, want survived", after.Status)
}
// tribute = 20% of 1000 = 200 → pot left with 800.
if bal := communityPotBalance(); bal != 800 {
t.Errorf("pot = %d, want 800 after 20%% tribute", bal)
}
}
// ── W2: the bout ─────────────────────────────────────────────────────────────
func fightableChar(t *testing.T, uid id.UserID) {
t.Helper()
if err := createAdvCharacter(uid, "boxer"); err != nil {
t.Fatalf("createAdvCharacter: %v", err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 12,
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
HPMax: 120, HPCurrent: 120, ArmorClass: 18,
}
if err := SaveDnDCharacter(c); err != nil {
t.Fatalf("SaveDnDCharacter: %v", err)
}
}
func TestWorldBossFloorHP(t *testing.T) {
newWorldBossTestDB(t)
uid := id.UserID("@floor:test.invalid")
fightableChar(t, uid)
if worldBossFloorHP(uid) {
t.Error("floor should be a no-op above 0 HP")
}
if _, err := db.Get().Exec(`UPDATE dnd_character SET hp_current = 0 WHERE user_id = ?`, string(uid)); err != nil {
t.Fatal(err)
}
if !worldBossFloorHP(uid) {
t.Error("floor should raise a 0-HP fighter")
}
if cur, _ := dndHPSnapshot(uid); cur != 1 {
t.Errorf("hp after floor = %d, want 1", cur)
}
}
func TestResolveWorldBossBout_SubtractsAndRecords(t *testing.T) {
newWorldBossTestDB(t)
uid := id.UserID("@bout:test.invalid")
fightableChar(t, uid)
now := time.Now().UTC()
// Big pool so one bout can only chip it — assert the partial subtract + contrib.
bossID, err := insertWorldBoss("Kravok", 3, 100000, now, now.Add(worldBossWindow))
if err != nil {
t.Fatal(err)
}
boss, _ := loadWorldBoss(bossID)
p := &AdventurePlugin{}
bout, err := p.resolveWorldBossBout(uid, boss, "2026-07-05")
if err != nil {
t.Fatalf("bout: %v", err)
}
if bout.Damage < 0 {
t.Errorf("damage negative: %d", bout.Damage)
}
if bout.Killed {
t.Error("a 100k pool should survive one bout")
}
if bout.Remaining != 100000-bout.Damage {
t.Errorf("remaining %d != max-dmg %d", bout.Remaining, 100000-bout.Damage)
}
c, _ := loadWorldBossContrib(bossID, uid)
if c == nil || c.Fights != 1 || c.Damage != bout.Damage || c.LastFightDate != "2026-07-05" {
t.Errorf("contrib mismatch: %+v (bout dmg %d)", c, bout.Damage)
}
if !worldBossBoutUsedToday(bossID, uid, "2026-07-05") {
t.Error("bout should read as used for that day")
}
if worldBossBoutUsedToday(bossID, uid, "2026-07-06") {
t.Error("a different day should still be available")
}
}
func TestResolveWorldBossBout_FellsTinyPool(t *testing.T) {
newWorldBossTestDB(t)
uid := id.UserID("@kill:test.invalid")
fightableChar(t, uid)
now := time.Now().UTC()
bossID, err := insertWorldBoss("Wisp", 3, 1, now, now.Add(worldBossWindow))
if err != nil {
t.Fatal(err)
}
boss, _ := loadWorldBoss(bossID)
p := &AdventurePlugin{}
bout, err := p.resolveWorldBossBout(uid, boss, "2026-07-05")
if err != nil {
t.Fatalf("bout: %v", err)
}
// A L12 fighter lands at least one blow on a T3 dummy over a full boss fight,
// felling a 1-HP pool.
if !bout.Killed || bout.Remaining != 0 {
t.Errorf("tiny pool not felled: killed=%v remaining=%d dmg=%d", bout.Killed, bout.Remaining, bout.Damage)
}
// Resolution is the caller's job — the bout leaves the boss active.
after, _ := loadWorldBoss(bossID)
if after.Status != "active" {
t.Errorf("bout should not self-resolve; status=%q", after.Status)
}
}
// TestWorldBossTick_ResolvesZeroHPBossAsDefeated: a killing blow that committed
// the pool to 0 but (crash/redeploy) never resolved the status must be resolved
// as DEFEATED by the ticker net — never fall through to the survive/pot-debit
// path. No contributors here, so resolution never touches euro.
func TestWorldBossTick_ResolvesZeroHPBossAsDefeated(t *testing.T) {
newWorldBossTestDB(t)
now := time.Now().UTC()
communityPotAdd(1000)
bossID, err := insertWorldBoss("Ghost", 3, 100, now, now.Add(worldBossWindow))
if err != nil {
t.Fatal(err)
}
if _, err := db.Get().Exec(`UPDATE world_boss SET hp_current = 0 WHERE id = ?`, bossID); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
p.worldBossTick()
after, _ := loadWorldBoss(bossID)
if after.Status != "defeated" {
t.Errorf("status = %q, want defeated (ticker safety net)", after.Status)
}
if bal := communityPotBalance(); bal != 1000 {
t.Errorf("pot = %d, want 1000 — a defeat mints, it must not debit the pot", bal)
}
}
// TestWorldBossTick_ResolvesLapsedBossAsSurvived: a boss whose window lapsed with
// the pool still up survives and loots the pot.
func TestWorldBossTick_ResolvesLapsedBossAsSurvived(t *testing.T) {
newWorldBossTestDB(t)
now := time.Now().UTC()
communityPotAdd(1000)
bossID, err := insertWorldBoss("Titan", 4, 500, now.Add(-worldBossWindow), now.Add(-time.Minute))
if err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
p.worldBossTick()
after, _ := loadWorldBoss(bossID)
if after.Status != "survived" {
t.Errorf("status = %q, want survived", after.Status)
}
if bal := communityPotBalance(); bal != 800 {
t.Errorf("pot = %d, want 800 after 20%% tribute", bal)
}
}

View File

@@ -381,6 +381,9 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
if drop := p.dropZoneLoot(userID, zone.ID, monster, !elite, elite); drop != "" {
b.WriteString(drop + "\n")
}
if !elite {
writeBossEpilogue(&b, zone.ID)
}
if bossOnExpedition {
// The boss is the expedition's climax. Frame the close-out as
// the win rather than a "keep walking" nudge. One more

View File

@@ -118,6 +118,9 @@ func (p *AdventurePlugin) finishPartyWin(
b.WriteString(drop + "\n")
}
}
if !elite {
writeBossEpilogue(&b, zone.ID)
}
switch {
case bossOnExpedition && seat == 0:
b.WriteString("🎉 **Zone cleared — the expedition is won.** `!expedition run` to march out and claim your spoils.")

View File

@@ -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 {

View File

@@ -400,6 +400,9 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
line := pickMorningBriefing(e.CurrentDay)
body := renderMorningBriefing(e, line, burn)
if sl := p.shadowBriefingLine(e); sl != "" {
body += "\n" + sl + "\n"
}
if restSummary != "" {
body += "\n💤 _" + restSummary + "_\n"
}
@@ -513,6 +516,9 @@ func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBrief
line := pickMorningBriefing(e.CurrentDay)
body := renderMorningBriefing(e, line, burn)
if sl := p.shadowBriefingLine(e); sl != "" {
body += "\n" + sl + "\n"
}
if forced {
body += "\n_The autopilot stalled overnight; the day rolled over without rest._\n"
}

View File

@@ -170,7 +170,14 @@ func (p *AdventurePlugin) finalizeExpeditionOnZoneClear(userID id.UserID, runID
exp.Status = ExpeditionStatusComplete
_ = retireAllRegionRuns(exp)
p.rollZoneTreasure(userID, exp.ZoneID, advTreasureWeightZoneClear)
return p.AwardCompletionMilestones(exp, false)
lines := p.AwardCompletionMilestones(exp, false)
// N6/D3: the Shadow's payoff for this zone — a crow (player was first) or a
// waiting journal page (the Shadow cleared it first). Appended after the
// milestone lines so the campaign beat reads last.
if sl := p.shadowOnPlayerZoneClear(userID, exp.ZoneID); sl != "" {
lines = append(lines, sl)
}
return lines
}
// midZoneRegionClear reports whether the just-completed run (runID) is the

View File

@@ -988,6 +988,25 @@ func (p *AdventurePlugin) streamFlowThen(userID id.UserID, phaseMessages []strin
// inter-phase delays — see resolveCombatRoom for the contract. For
// non-combat rooms (entry, trap), phases is nil and outcome carries the
// resolution narration.
// currentSecretNode returns the run's current graph node when it is a secret
// room, so resolveRoom can divert it to the treasure-cache resolver. Returns
// false for a non-secret node, an unknown zone, or a legacy row that never got
// a CurrentNode written (those pre-date branching graphs and have no secrets).
func currentSecretNode(run *DungeonRun) (ZoneNode, bool) {
if run.CurrentNode == "" {
return ZoneNode{}, false
}
g, ok := loadZoneGraph(run.ZoneID)
if !ok {
return ZoneNode{}, false
}
n, ok := g.Nodes[run.CurrentNode]
if !ok || n.Kind != NodeKindSecret {
return ZoneNode{}, false
}
return n, true
}
func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, compact bool) (intro string, phases []string, outcome string, ended bool, err error) {
// Revisit R2 — a cleared room stays cleared. Advancing out of a room the
// player backtracked into must not re-roll its combat or re-arm its trap.
@@ -1004,6 +1023,14 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo
if run.RoomIsCleared(run.CurrentRoom) {
return
}
// N5/D4 — a secret room is a no-combat treasure cache, not the exploration
// fight its RoomType collapses to. Keyed off the graph node (which still
// carries NodeKindSecret) rather than CurrentRoomType (which does not), so
// it must be checked before the RoomType switch below.
if node, ok := currentSecretNode(run); ok {
outcome = p.resolveSecretRoom(userID, run, zone, node)
return
}
switch run.CurrentRoomType() {
case RoomEntry:
return
@@ -1103,6 +1130,13 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
ob.WriteString("\n")
ob.WriteString(line)
}
// D1b campaign capstone. The compact autopilot resolves boss rooms
// itself (the primary long-expedition path), so the epilogue has to
// fire here too — otherwise almost every player clears the boss
// without ever seeing it.
if isBoss {
writeBossEpilogue(&ob, zone.ID)
}
outcome = ob.String()
return
}

View File

@@ -202,6 +202,52 @@ func scanMoodEventsFromEvents(runID string, events []CombatEvent) (nat20s, nat1s
//
// Higher-tier zones currently fall through to the pre-D2a flat-percent
// nick — D3a/D4a will add Tier 2+ trap catalogs.
// resolveSecretRoom resolves a NodeKindSecret room as a no-combat treasure
// cache (N5/D4). Finding the secret — via the perception/stat check on the fork
// that hangs it, or a cross-zone key — is the reward: a guaranteed journal page
// (the Hollow King's fragments gather in hidden rooms), a LootBias-weighted
// treasure roll, a guaranteed zone-tier consumable cache, and, for the two
// key-bearing secrets, a cross-zone key. No enemy, no threat.
//
// LootBias has been authored on every secret node since the branching-zones
// phase and was dead code until now; it finally scales the treasure weight here.
func (p *AdventurePlugin) resolveSecretRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, node ZoneNode) string {
var b strings.Builder
b.WriteString(secretRoomDiscoveryLine(node))
var rewards []string
if line := p.grantJournalPage(userID, nil); line != "" {
rewards = append(rewards, line)
}
if line := p.grantSecretRoomKey(userID, node); line != "" {
rewards = append(rewards, line)
}
// LootBias-weighted treasure roll. Floors at the elite weight so even the
// leanest secret out-rolls a standard kill; the richest (abyss 3.0) still
// rolls below a boss. checkTreasureDrop DMs the discovery itself on a hit,
// exactly as a combat-room drop does, so it isn't folded into the outcome.
weight := node.Content.LootBias
if weight < advTreasureWeightElite {
weight = advTreasureWeightElite
}
p.rollZoneTreasure(userID, zone.ID, weight)
// Guaranteed cache so the room always pays out something visible.
for _, item := range consumableCache(int(zone.Tier), secretRoomCacheCount) {
it := item
if line := p.grantZoneItem(userID, &it, "🧪"); line != "" {
rewards = append(rewards, line)
}
}
if len(rewards) > 0 {
b.WriteString("\n\n")
b.WriteString(strings.Join(rewards, "\n"))
}
return b.String()
}
func (p *AdventurePlugin) resolveTrapRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (damage int, narration string) {
dndChar, _ := LoadDnDCharacter(userID)
if dndChar == nil {

View File

@@ -517,6 +517,13 @@ func (p *AdventurePlugin) dropZoneLoot(userID id.UserID, zoneID ZoneID, monster
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")
entry, tier, ok := rollZoneLoot(zoneID, monster.CR, isBoss, nil)

View File

@@ -44,11 +44,14 @@ func renderEndOfDayDigest(expID string, prevDay int) string {
threatLines []string
milestoneLine []string
narrativeBits []string
journalPages int
)
for _, e := range entries {
switch e.Type {
case "walk":
walks++
case "journal":
journalPages++
case "harvest":
// Only count successful gathers — failed rolls / errors are noise.
if strings.Contains(e.Summary, "success") {
@@ -115,6 +118,12 @@ func renderEndOfDayDigest(expID string, prevDay int) string {
b.WriteString("\n")
bulleted = true
}
if react := twinBeeJournalReaction(prevDay, journalPages); react != "" {
b.WriteString("• ")
b.WriteString(react)
b.WriteString("\n")
bulleted = true
}
if !bulleted {
// All entries were filtered out — fall back to the bare camp block.
return ""

View File

@@ -386,6 +386,68 @@ func upsertPlayerMetaPet2State(userID id.UserID, s PetState) error {
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
}
// 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,
@@ -1281,6 +1343,12 @@ func applyPlayerMetaOverlay(c *AdventureCharacter) {
c.Pet2ChasedAway = s.ChasedAway
c.Pet2Reactivated = s.Reactivated
}
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

View File

@@ -120,6 +120,23 @@ var ThomRateDecreasePet = []string{
"You can draw your own conclusions.",
}
// ── FINAL PAYOFF — THE LAST HOUSE (D2 NPC ARC) ────────────────────────────────
//
// Sent once, when the Tier-4 "Established" mortgage hits zero — the last house
// Thom has to sell. There is no next tier, so this is the last time Thom has
// business with the player. A letter, and (if they keep a pet) a treat left in
// the bag. Substitute {pet} with strings.ReplaceAll at the call site.
var thomFinalLetterNoPet = "Paid off. All of it. There is no next tier -- you've bought the last house I had to sell you.\n\n" +
"I've closed a great many mortgages. Thirty-one years, Krooke Realty. Most people I stop thinking about the hour the balance hits zero, and I have never once considered that a loss. I am writing to tell you that you appear to be an exception. I have not decided how I feel about that. I am telling you anyway, which should indicate something.\n\n" +
"The door is yours, the roof is yours, the ground it stands on is yours. Nobody sends you a bill for it ever again. Walk through it whenever you like.\n\n" +
"-- Thom Krooke, Krooke Realty"
var thomFinalLetterPet = "Paid off. All of it. There is no next tier -- you own it outright now, walls and roof and the ground it sits on.\n\n" +
"I've enclosed something for {pet}. A treat. The good kind -- not the sort they sell by the sack, I had it sent from a place I trust. Give it to {pet} tonight and don't you dare ration it, and don't 'save it for a special occasion' either. Tonight IS the occasion. {pet} has spent this entire mortgage living in a house that wasn't finished being paid for, and now it is, and {pet} should know the difference even if {pet} can't be told in words.\n\n" +
"You did a fine thing, getting {pet} a home that is truly yours. I won't say it a second time.\n\n" +
"-- Thom Krooke, Krooke Realty"
// ── NO CHANGE (NEVER SENT) ────────────────────────────────────────────────────
// Thom does not send neutral news.
// If rate is unchanged week over week: no DM. No notification. Nothing.

View File

@@ -34,6 +34,12 @@ package plugin
// hangs off the open spoke of upper_hall (cheapest gate, hardest fight);
// the SECRET sits behind the highest-DC Perception with its loot bias
// preserved.
//
// N5/D4 adds a fourth upper_hall spoke: the Sealed Reliquary, a cross-zone
// key vault (LockKey "sunken sigil", earned in the Sunken Temple's Coral
// Reliquary). A one-node loot shortcut back to spire_corridor — reachable
// only by a player who found the sigil, so it never shows on the longest
// path and the 23-node band is unchanged.
func zoneManorBlackspireGraph() ZoneGraph {
nodes := []ZoneNode{
@@ -108,6 +114,12 @@ func zoneManorBlackspireGraph() ZoneGraph {
{NodeID: "manor_blackspire.reliquary_dust", Kind: NodeKindExploration,
Label: "Reliquary Dust", PosX: 18, PosY: 2},
// upper_hall — cross-zone key spoke (N5/D4). A Sunken Temple sigil
// opens this vault; a loot-rich shortcut back to the spire corridor.
{NodeID: "manor_blackspire.sealed_reliquary", Kind: NodeKindSecret,
Label: "Sealed Reliquary", PosX: 16, PosY: -2,
Content: ZoneNodeContent{LootBias: 2.2}},
// upper_hall — level-min spoke (tower).
{NodeID: "manor_blackspire.tower_observatory", Kind: NodeKindExploration,
Label: "Tower Observatory", PosX: 16, PosY: 4},
@@ -174,6 +186,9 @@ func zoneManorBlackspireGraph() ZoneGraph {
{From: "manor_blackspire.upper_hall", To: "manor_blackspire.tower_observatory",
Lock: LockLevelMin, LockData: map[string]any{"min_level": 7},
Hint: "a spiral stair that creaks ominously — climb only if you trust your footing", Weight: 3},
{From: "manor_blackspire.upper_hall", To: "manor_blackspire.sealed_reliquary",
Lock: LockKey, LockData: map[string]any{"key_id": "sunken sigil"},
Hint: "a panel scored with a drowned god's mark — you'd need its twin to open it", Weight: 4},
// Master Bedroom spoke (elite).
{From: "manor_blackspire.master_bedroom", To: "manor_blackspire.grim_balcony", Lock: LockNone},
@@ -185,6 +200,9 @@ func zoneManorBlackspireGraph() ZoneGraph {
{From: "manor_blackspire.choir_balcony", To: "manor_blackspire.reliquary_dust", Lock: LockNone},
{From: "manor_blackspire.reliquary_dust", To: "manor_blackspire.spire_corridor", Lock: LockNone},
// Sealed Reliquary spoke (cross-zone key — Sunken Sigil).
{From: "manor_blackspire.sealed_reliquary", To: "manor_blackspire.spire_corridor", Lock: LockNone},
// Tower spoke.
{From: "manor_blackspire.tower_observatory", To: "manor_blackspire.spiral_ascent", Lock: LockNone},
{From: "manor_blackspire.spiral_ascent", To: "manor_blackspire.telescope_attic", Lock: LockNone},

View File

@@ -12,22 +12,24 @@ func TestManorBlackspireGraph_Registered(t *testing.T) {
}
// Long-expedition D1-c widened this zone from 11 → 35 nodes so the
// longest entry→boss walk lands in the T3 [22,26] traversal band.
if len(g.Nodes) != 35 {
t.Errorf("nodes = %d, want 35", len(g.Nodes))
// N5/D4 added the Sealed Reliquary cross-zone vault → 36.
if len(g.Nodes) != 36 {
t.Errorf("nodes = %d, want 36", len(g.Nodes))
}
}
// TestManorBlackspireGraph_TwoStackedThreeWayForks captures the design
// shape: both great_hall and upper_hall expose three options.
func TestManorBlackspireGraph_TwoStackedThreeWayForks(t *testing.T) {
// TestManorBlackspireGraph_StackedForks captures the design shape: great_hall
// exposes three options; upper_hall exposes four since N5/D4 added the Sealed
// Reliquary key spoke (the other three remain the elite/secret/tower gates).
func TestManorBlackspireGraph_StackedForks(t *testing.T) {
g := zoneManorBlackspireGraph()
for _, hub := range []string{
"manor_blackspire.great_hall",
"manor_blackspire.upper_hall",
} {
outs := g.outgoingEdges(hub)
if len(outs) != 3 {
t.Errorf("%s outgoing = %d, want 3", hub, len(outs))
want := map[string]int{
"manor_blackspire.great_hall": 3,
"manor_blackspire.upper_hall": 4,
}
for hub, n := range want {
if outs := g.outgoingEdges(hub); len(outs) != n {
t.Errorf("%s outgoing = %d, want %d", hub, len(outs), n)
}
}
}
@@ -43,6 +45,7 @@ func TestManorBlackspireGraph_AllSpokesReachBoss(t *testing.T) {
"manor_blackspire.master_bedroom",
"manor_blackspire.hidden_oratory",
"manor_blackspire.tower_observatory",
"manor_blackspire.sealed_reliquary",
} {
if !reachable(g, leaf, "manor_blackspire.boss") {
t.Errorf("%s unreachable to boss", leaf)

View File

@@ -61,6 +61,15 @@ package plugin
// Garrison) and the multi-region transition a teeth-y interrupt.
// The deep_chasm spur stays anchor-light (harvest, no second trap/elite)
// so the CON-gated climb keeps its "denser loot, less fighting" identity.
//
// N5/D4 turns throne_gallery (R4 tail, which every arm passes through) into
// a fork with two secret spokes, both merging to throne_steps so either is
// length-neutral vs the inner-hall path:
// - Lost Reliquary — the Underdark's own perception-gated secret (DC 17).
// - Sealed Vault — the cross-zone key vault opened by an Underforge Seal
// (earned in the Underforge's Forge Vault). LockKey "underforge seal".
// Placed on the universal R4 tail, not in an arm, so the secrets are
// reachable no matter which region the walk took.
func zoneUnderdarkGraph() ZoneGraph {
r1 := "underdark_surface_tunnels"
@@ -163,10 +172,22 @@ func zoneUnderdarkGraph() ZoneGraph {
Label: "Guard Post", PosX: 24, PosY: 2},
{NodeID: "underdark.throne_doors", Kind: NodeKindExploration, RegionID: r4,
Label: "Riven Doors", PosX: 25, PosY: 2},
{NodeID: "underdark.throne_gallery", Kind: NodeKindExploration, RegionID: r4,
{NodeID: "underdark.throne_gallery", Kind: NodeKindFork, RegionID: r4,
Label: "Throne Gallery", PosX: 26, PosY: 2},
{NodeID: "underdark.throne_inner_hall", Kind: NodeKindExploration, RegionID: r4,
Label: "Inner Hall", PosX: 27, PosY: 2},
// N5/D4 secret spokes off the throne gallery (both merge to
// throne_steps, so either detour is length-neutral vs the inner-hall
// path). lost_reliquary is the Underdark's own perception-gated secret;
// sealed_forge_vault is the cross-zone key vault opened by an Underforge
// Seal (earned in the Underforge's Forge Vault).
{NodeID: "underdark.lost_reliquary", Kind: NodeKindSecret, RegionID: r4,
Label: "Lost Reliquary", PosX: 27, PosY: 0,
Content: ZoneNodeContent{LootBias: 2.0}},
{NodeID: "underdark.sealed_forge_vault", Kind: NodeKindSecret, RegionID: r4,
Label: "Sealed Vault", PosX: 27, PosY: 4,
Content: ZoneNodeContent{LootBias: 2.2}},
{NodeID: "underdark.throne_steps", Kind: NodeKindExploration, RegionID: r4,
Label: "Throne Steps", PosX: 28, PosY: 2},
{NodeID: "underdark.boss", Kind: NodeKindBoss, IsBoss: true, RegionID: r4,
@@ -232,7 +253,15 @@ func zoneUnderdarkGraph() ZoneGraph {
{From: "underdark.throne_antechamber", To: "underdark.throne_guard_post", Lock: LockNone},
{From: "underdark.throne_guard_post", To: "underdark.throne_doors", Lock: LockNone},
{From: "underdark.throne_doors", To: "underdark.throne_gallery", Lock: LockNone},
{From: "underdark.throne_gallery", To: "underdark.throne_inner_hall", Lock: LockNone},
{From: "underdark.throne_gallery", To: "underdark.throne_inner_hall", Lock: LockNone, Weight: 1},
{From: "underdark.throne_gallery", To: "underdark.lost_reliquary",
Lock: LockPerception, LockData: map[string]any{"dc": 17},
Hint: "a side-shrine still lit, off the gallery's blind end", Weight: 2},
{From: "underdark.throne_gallery", To: "underdark.sealed_forge_vault",
Lock: LockKey, LockData: map[string]any{"key_id": "underforge seal"},
Hint: "a deep-road door branded by a forge you may have passed", Weight: 3},
{From: "underdark.lost_reliquary", To: "underdark.throne_steps", Lock: LockNone},
{From: "underdark.sealed_forge_vault", To: "underdark.throne_steps", Lock: LockNone},
{From: "underdark.throne_inner_hall", To: "underdark.throne_steps", Lock: LockNone},
{From: "underdark.throne_steps", To: "underdark.boss", Lock: LockNone},
}

View File

@@ -9,8 +9,10 @@ func TestUnderdarkGraph_Registered(t *testing.T) {
}
// Long-expedition D1-d widened this zone from 10 → 46 nodes so the
// longest entry→boss walk lands in the T4 [28,34] traversal band.
if len(g.Nodes) != 46 {
t.Errorf("nodes = %d, want 46", len(g.Nodes))
// N5/D4 added two throne-gallery secret spokes (Lost Reliquary +
// Sealed Vault) → 48.
if len(g.Nodes) != 48 {
t.Errorf("nodes = %d, want 48", len(g.Nodes))
}
}