mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Compare commits
18 Commits
57a0ea90f2
...
59319ede9d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59319ede9d | ||
|
|
3f4b4ece5c | ||
|
|
a6f1de4e74 | ||
|
|
1a47a2fdee | ||
|
|
aaa45eab14 | ||
|
|
38a3693832 | ||
|
|
401b17c3bb | ||
|
|
3026fe6012 | ||
|
|
e2c0c3359b | ||
|
|
127d252982 | ||
|
|
c3e122694b | ||
|
|
cc165bed1f | ||
|
|
81b2359109 | ||
|
|
3103b519fb | ||
|
|
c37c95a3e3 | ||
|
|
9b6c1ff9a4 | ||
|
|
aab7a7bad0 | ||
|
|
fd7803b13c |
@@ -46,6 +46,8 @@ func main() {
|
||||
days = flag.Int("days", 0, "stop after N synthetic day rollovers (0 = unbounded; the -cap safety net still applies)")
|
||||
dataDir = flag.String("data", "", "data dir for the temp sqlite db (default: OS tempdir; ignored in matrix mode)")
|
||||
userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)")
|
||||
|
||||
realUser = flag.String("real-user", "", "run an EXISTING character loaded from -data's DB instead of building a synthetic one. Pass the real mxid (e.g. @holymachina:parodia.dev). Requires -data to point at a (copy of a) populated gogobee.db dir. Heals the char to full + tops up the bankroll; leaves race/class/level/gear/spells as-is.")
|
||||
logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)")
|
||||
|
||||
matrix = flag.Bool("matrix", false, "matrix mode — sweep over classes × levels × zones × runs")
|
||||
@@ -90,9 +92,49 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
if *realUser != "" {
|
||||
if *dataDir == "" {
|
||||
fail("-real-user requires -data pointing at a dir containing a populated gogobee.db")
|
||||
}
|
||||
if *party != 1 {
|
||||
fail("-real-user does not support -party yet (would need every seat to be a real, tier-eligible char)")
|
||||
}
|
||||
runReal(*realUser, *zone, *dataDir, *bank, *cap, *days, *logFlag)
|
||||
return
|
||||
}
|
||||
|
||||
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag, followers)
|
||||
}
|
||||
|
||||
// runReal drives an existing character loaded from dataDir's gogobee.db through
|
||||
// a real expedition. dataDir should be a COPY of prod — db.Init runs additive
|
||||
// migrations against it, and the run mutates HP/coin/inventory. Never point this
|
||||
// at the live prod file or at ./data.
|
||||
func runReal(userTag, zone, dataDir string, bank float64, cap, days int, includeLog bool) {
|
||||
runner, err := plugin.NewSimRunner(dataDir)
|
||||
if err != nil {
|
||||
fail("init runner:", err)
|
||||
}
|
||||
defer runner.Close()
|
||||
|
||||
uid := id.UserID(userTag)
|
||||
c, err := runner.PrepareRealCharacter(uid, bank)
|
||||
if err != nil {
|
||||
fail("prepare real character:", err)
|
||||
}
|
||||
res, err := runner.RunExpedition(uid, plugin.ZoneID(zone), cap, days)
|
||||
if res != nil {
|
||||
res.Class = string(c.Class) // real subclass/race aren't in SimResult; class is the useful key
|
||||
if !includeLog {
|
||||
res.Log = nil
|
||||
}
|
||||
emitIndented(res)
|
||||
}
|
||||
if err != nil {
|
||||
fail("run:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// followerClasses expands -party / -party-classes into the class of each
|
||||
// follower seat. An explicit list must name every seat: a party of 3 whose
|
||||
// second follower silently defaulted to the leader's class would quietly
|
||||
|
||||
@@ -378,6 +378,29 @@ 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`,
|
||||
// N7/B2 Renown (gogobee_engagement_plan.md §B2). At the L20 cap, overflow
|
||||
// XP that grantDnDXP used to drop instead accumulates here as cumulative
|
||||
// prestige XP. renown_xp is monotonic and written by an atomic += (the
|
||||
// journal_pages pattern), never the bulk character save; renown_level is
|
||||
// DERIVED from it (renown_xp / renownXPPerLevel) rather than stored, so
|
||||
// there is no read-modify-write race and no second field to disagree.
|
||||
// DEFAULT 0 == "no renown", correct for every pre-existing row, no bootstrap.
|
||||
`ALTER TABLE player_meta ADD COLUMN renown_xp INTEGER NOT NULL DEFAULT 0`,
|
||||
}
|
||||
for _, stmt := range columnMigrations {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
@@ -1595,6 +1618,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 +2237,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.
|
||||
|
||||
@@ -1206,9 +1206,39 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
||||
return false
|
||||
},
|
||||
},
|
||||
// N7/B4 — the Renown wing (gogobee_engagement_plan.md §B4). Passive checks
|
||||
// against the derived Renown level (N7/B2); no event hook needed.
|
||||
{
|
||||
ID: "renown_1", Name: "Beyond the Cap", Description: "Earned your first level of Renown. The story was supposed to be over.",
|
||||
Emoji: "✦",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 1) },
|
||||
},
|
||||
{
|
||||
ID: "renown_5", Name: "Storied", Description: "Reached Renown 5. They tell versions of your runs that never happened.",
|
||||
Emoji: "✦",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 5) },
|
||||
},
|
||||
{
|
||||
ID: "renown_10", Name: "Fabled", Description: "Reached Renown 10. Somewhere a bard is getting rich off your name.",
|
||||
Emoji: "✦",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 10) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// renownAtLeast reports whether the player's derived Renown level (N7/B2) has
|
||||
// reached level, reading player_meta.renown_xp through the passed handle.
|
||||
func renownAtLeast(d *sql.DB, userID id.UserID, level int) bool {
|
||||
var xp int
|
||||
if err := d.QueryRow(
|
||||
`SELECT renown_xp FROM player_meta WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&xp); err != nil {
|
||||
return false
|
||||
}
|
||||
return renownLevelFor(xp) >= level
|
||||
}
|
||||
|
||||
// ── Expedition achievement helpers ──────────────────────────────────────────
|
||||
|
||||
// clearedZoneIDs returns the zones this player has beaten outright. Boss-
|
||||
|
||||
@@ -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
|
||||
@@ -628,6 +646,7 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
|
||||
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||
applyRenownBonuses(bonuses, char.RenownLevel()) // N7/B2 — activity path only, never combat
|
||||
balance := p.euro.GetBalance(char.UserID)
|
||||
|
||||
text := renderAdvMorningDM(char.UserID, equip, balance, bonuses, holName)
|
||||
@@ -778,6 +797,7 @@ func (p *AdventurePlugin) handleLeaderboard(ctx MessageContext) error {
|
||||
ForagingSkill: c.ForagingSkill,
|
||||
FishingSkill: c.FishingSkill,
|
||||
CurrentStreak: c.CurrentStreak,
|
||||
Renown: renownLevelForUser(c.UserID),
|
||||
})
|
||||
}
|
||||
text := renderAdvLeaderboard(entries)
|
||||
|
||||
@@ -531,6 +531,12 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
|
||||
}
|
||||
totalXP := int(float64(run.XPAccumulated) * xpMult)
|
||||
|
||||
// N7/B3 the Omen — a payout-boosting week scales gross earnings before the
|
||||
// pot tax, so both the player's cut and the pot's rake grow proportionally.
|
||||
if m := activeOmen().ArenaPayoutMult; m > 1.0 {
|
||||
run.Earnings = int64(float64(run.Earnings) * m)
|
||||
}
|
||||
|
||||
// Arena tax: 10% of earnings to community pot.
|
||||
arenaTax := int64(math.Round(float64(run.Earnings) * 0.1))
|
||||
arenaNet := run.Earnings - arenaTax
|
||||
|
||||
@@ -111,6 +111,25 @@ 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
|
||||
// N7/B2 Renown — cumulative overflow XP earned past the L20 cap. Overlay-read
|
||||
// from player_meta.renown_xp, written by the atomic addRenownXP, never the
|
||||
// bulk character save. RenownLevel() derives the prestige level from it.
|
||||
RenownXP int
|
||||
}
|
||||
|
||||
// RenownLevel is the derived prestige level (renown_xp / renownXPPerLevel).
|
||||
func (c *AdventureCharacter) RenownLevel() int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return renownLevelFor(c.RenownXP)
|
||||
}
|
||||
|
||||
type AdvEquipment struct {
|
||||
|
||||
701
internal/plugin/adventure_duel.go
Normal file
701
internal/plugin/adventure_duel.go
Normal 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))]
|
||||
}
|
||||
274
internal/plugin/adventure_duel_test.go
Normal file
274
internal/plugin/adventure_duel_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
519
internal/plugin/adventure_flavor_campaign.go
Normal file
519
internal/plugin/adventure_flavor_campaign.go
Normal 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",
|
||||
}
|
||||
300
internal/plugin/adventure_flavor_campaign_test.go
Normal file
300
internal/plugin/adventure_flavor_campaign_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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. " +
|
||||
|
||||
@@ -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
|
||||
|
||||
91
internal/plugin/adventure_npc_arcs_test.go
Normal file
91
internal/plugin/adventure_npc_arcs_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
101
internal/plugin/adventure_omen.go
Normal file
101
internal/plugin/adventure_omen.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// N7/B3 — the Omen: one rotating world modifier per ISO week
|
||||
// (gogobee_engagement_plan.md §B3).
|
||||
//
|
||||
// The active omen is a pure function of the ISO (year, week): omenTable indexed
|
||||
// by (year*53 + week) % len, so it advances by exactly one entry each week and
|
||||
// needs no schema, no ticker state, and no persistence. Every seam reads
|
||||
// activeOmen() the same way isHolidayToday() is read, and a zero-valued effect
|
||||
// field means "this omen doesn't touch that seam."
|
||||
//
|
||||
// Launch-set rule (§B3): every omen is a buff-with-texture on a NON-combat
|
||||
// lever — harvest, supplies, expedition mood/threat, arena payout, ingredient
|
||||
// drops. Nothing touches SimulateCombat or the turn engine: the omen is keyed
|
||||
// on the real clock, so a combat-affecting omen would make the combat golden
|
||||
// and the balance corpus week-dependent. The plan's "elites +2 ATK" mutator is
|
||||
// deliberately omitted for exactly that reason.
|
||||
|
||||
type omen struct {
|
||||
Key string // stable id (tests, logs)
|
||||
Name string // player-facing name, e.g. "Bountiful Harvest"
|
||||
TwinBee string // first-person announce blurb (TwinBee voice)
|
||||
|
||||
// Effect fields — zero == no effect at that seam.
|
||||
HarvestYieldBonus int // + units per harvest grant
|
||||
SupplyFreebiePacks int // + complimentary standard packs at outfitting
|
||||
StartMoodBonus int // + starting DM mood on a new expedition
|
||||
ArenaPayoutMult float64 // >1 scales arena net earnings
|
||||
ConsumableChanceMult float64 // >1 scales the per-win ingredient drop chance
|
||||
ThreatDriftReduce int // subtract from the daily threat *rise* (floored at hold-steady)
|
||||
}
|
||||
|
||||
// simOmenDisabled neutralizes the weekly Omen for the balance sim, so a corpus
|
||||
// sweep's results never depend on which wall-clock week it was run in. Set true
|
||||
// by NewSimRunner (mirrors simAutoArmEnabled). Every seam reads activeOmen(),
|
||||
// which returns the no-effect omen while this is set.
|
||||
var simOmenDisabled bool
|
||||
|
||||
// omenTable is the weekly rotation. Keep it a set of distinct non-combat levers;
|
||||
// order is the rotation order. Adding an entry reshuffles the schedule but never
|
||||
// breaks determinism (still a pure function of the week).
|
||||
var omenTable = []omen{
|
||||
{
|
||||
Key: "bountiful_harvest", Name: "Bountiful Harvest",
|
||||
TwinBee: "The land's feeling generous this week — every gather I make comes up with a little extra in hand.",
|
||||
HarvestYieldBonus: 1,
|
||||
},
|
||||
{
|
||||
Key: "quartermasters_blessing", Name: "Quartermaster's Blessing",
|
||||
TwinBee: "Somebody left the storerooms unlocked. Outfitting an expedition this week? There's a free pack in it, and I'm setting out in good spirits.",
|
||||
SupplyFreebiePacks: 1,
|
||||
StartMoodBonus: 5,
|
||||
},
|
||||
{
|
||||
Key: "golden_purse", Name: "Golden Purse",
|
||||
TwinBee: "The arena crowd's flush this week — purses are paying out fat. Twenty percent over the odds, if you can win it.",
|
||||
ArenaPayoutMult: 1.20,
|
||||
},
|
||||
{
|
||||
Key: "overflowing_satchels", Name: "Overflowing Satchels",
|
||||
TwinBee: "Reagents are turning up everywhere I look — twice as often as usual. Good week to stock the crafting shelf.",
|
||||
ConsumableChanceMult: 2.0,
|
||||
},
|
||||
{
|
||||
Key: "still_waters", Name: "Still Waters",
|
||||
TwinBee: "It's quiet out there. Whatever's hunting us is slow to rouse this week — the daily dread holds steady instead of creeping up.",
|
||||
ThreatDriftReduce: 1,
|
||||
},
|
||||
}
|
||||
|
||||
// omenForWeek returns the omen for an ISO (year, week). Pure and total.
|
||||
func omenForWeek(year, week int) omen {
|
||||
idx := ((year*53)+week)%len(omenTable) + len(omenTable)
|
||||
return omenTable[idx%len(omenTable)]
|
||||
}
|
||||
|
||||
// activeOmen returns this week's omen (UTC ISO week), or a no-effect omen when
|
||||
// the balance sim has disabled it.
|
||||
func activeOmen() omen {
|
||||
if simOmenDisabled {
|
||||
return omen{Key: "none", Name: "None"}
|
||||
}
|
||||
// N7/E4 — a live season's themed omen overrides the weekly rotation. Kept
|
||||
// behind the simOmenDisabled guard above so the balance sim never sees it.
|
||||
if s, ok := activeSeason(); ok {
|
||||
return s.Omen
|
||||
}
|
||||
y, w := time.Now().UTC().ISOWeek()
|
||||
return omenForWeek(y, w)
|
||||
}
|
||||
|
||||
// omenMorningLine is the compact one-liner surfaced in the morning DM (the §B3
|
||||
// announce seam). Empty is never returned — an omen is always active — but the
|
||||
// caller may still choose when to show it.
|
||||
func omenMorningLine(o omen) string {
|
||||
return "🔮 **The Omen — " + o.Name + ".** _" + o.TwinBee + "_"
|
||||
}
|
||||
86
internal/plugin/adventure_omen_test.go
Normal file
86
internal/plugin/adventure_omen_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestOmenForWeek_Deterministic — the same ISO week always yields the same omen.
|
||||
func TestOmenForWeek_Deterministic(t *testing.T) {
|
||||
a := omenForWeek(2026, 28)
|
||||
b := omenForWeek(2026, 28)
|
||||
if a.Key != b.Key {
|
||||
t.Fatalf("omenForWeek not deterministic: %q vs %q", a.Key, b.Key)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmenForWeek_AdvancesEachWeek — consecutive weeks step by exactly one table
|
||||
// entry, so the schedule rotates rather than sticking or skipping.
|
||||
func TestOmenForWeek_AdvancesEachWeek(t *testing.T) {
|
||||
n := len(omenTable)
|
||||
for w := 1; w <= n; w++ {
|
||||
cur := omenForWeek(2026, w)
|
||||
next := omenForWeek(2026, w+1)
|
||||
wantNext := omenTable[((2026*53)+w+1)%n]
|
||||
if next.Key != wantNext.Key {
|
||||
t.Errorf("week %d→%d: got %q, want %q", w, w+1, next.Key, wantNext.Key)
|
||||
}
|
||||
if cur.Key == next.Key {
|
||||
t.Errorf("week %d and %d produced the same omen %q (should advance)", w, w+1, cur.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmenForWeek_TotalOverYearBoundary — never panics across week/year edges,
|
||||
// including ISO week 53.
|
||||
func TestOmenForWeek_TotalOverYearBoundary(t *testing.T) {
|
||||
for y := 2020; y <= 2030; y++ {
|
||||
for w := 1; w <= 53; w++ {
|
||||
o := omenForWeek(y, w)
|
||||
if o.Key == "" {
|
||||
t.Fatalf("omenForWeek(%d,%d) returned zero omen", y, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmenTable_NonCombatOnly — every omen carries at least one effect and the
|
||||
// struct has no combat lever, so the launch set can never move the golden or the
|
||||
// balance corpus. §B3.
|
||||
func TestOmenTable_NonCombatOnly(t *testing.T) {
|
||||
for _, o := range omenTable {
|
||||
hasEffect := o.HarvestYieldBonus > 0 || o.SupplyFreebiePacks > 0 ||
|
||||
o.StartMoodBonus > 0 || o.ArenaPayoutMult > 1.0 ||
|
||||
o.ConsumableChanceMult > 1.0 || o.ThreatDriftReduce > 0
|
||||
if !hasEffect {
|
||||
t.Errorf("omen %q has no active effect", o.Key)
|
||||
}
|
||||
if o.Name == "" || o.TwinBee == "" {
|
||||
t.Errorf("omen %q missing player-facing copy", o.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestActiveOmen_SimDisabled — the balance sim neutralizes the omen so corpus
|
||||
// results are wall-clock-independent. §B3.
|
||||
func TestActiveOmen_SimDisabled(t *testing.T) {
|
||||
defer func() { simOmenDisabled = false }()
|
||||
simOmenDisabled = true
|
||||
o := activeOmen()
|
||||
if o.Key != "none" {
|
||||
t.Errorf("sim-disabled omen = %q, want none", o.Key)
|
||||
}
|
||||
if o.HarvestYieldBonus != 0 || o.SupplyFreebiePacks != 0 || o.StartMoodBonus != 0 ||
|
||||
o.ArenaPayoutMult != 0 || o.ConsumableChanceMult != 0 || o.ThreatDriftReduce != 0 {
|
||||
t.Errorf("sim-disabled omen must have no effect, got %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmenKeysUnique — no duplicate keys (a dup would make the rotation land on
|
||||
// the same omen two of every len(omenTable) weeks).
|
||||
func TestOmenKeysUnique(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, o := range omenTable {
|
||||
if seen[o.Key] {
|
||||
t.Errorf("duplicate omen key %q", o.Key)
|
||||
}
|
||||
seen[o.Key] = true
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,20 @@ func renderAdvMorningDM(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment,
|
||||
sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, today's adventures come with TwinBee's blessing — new zone & expedition runs start at +5 mood, expedition outfitting includes a complimentary standard pack, and every harvest yields one extra unit.\n\n", holidayName, holidayName))
|
||||
}
|
||||
|
||||
// N7/B3 the Omen — this week's world modifier, in TwinBee's voice. Rides the
|
||||
// existing morning DM (no net-new scheduled message); persistent one-liner so
|
||||
// a mid-week arrival still learns the active omen.
|
||||
sb.WriteString(omenMorningLine(activeOmen()))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// N7/E4 — a live season adds a "what's live this week" banner under the Omen
|
||||
// line (its themed omen already rode the line above). Same morning DM, no
|
||||
// net-new scheduled message.
|
||||
if s, ok := activeSeason(); ok {
|
||||
sb.WriteString(seasonBannerLine(s))
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Pick a morning greeting
|
||||
greeting, _ := advPickFlavor(MorningDM, userID, "morning_dm")
|
||||
displayName, _ := loadDisplayName(userID)
|
||||
@@ -974,6 +988,7 @@ type AdvLeaderboardEntry struct {
|
||||
ForagingSkill int
|
||||
FishingSkill int
|
||||
CurrentStreak int
|
||||
Renown int // N7/B2 — prestige level, cosmetic marker only
|
||||
}
|
||||
|
||||
func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
@@ -987,16 +1002,21 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
Score int
|
||||
Levels string
|
||||
Streak int
|
||||
Renown int
|
||||
}
|
||||
var entries []entry
|
||||
for _, c := range chars {
|
||||
// Renown adds to the ranking score so a capped, prestigious player still
|
||||
// climbs — it's the only progression they have left past L20.
|
||||
score := (c.Level + c.MiningSkill + c.ForagingSkill + c.FishingSkill) * 10
|
||||
score += c.Renown * 10
|
||||
name, _ := loadDisplayName(c.UserID)
|
||||
entries = append(entries, entry{
|
||||
Name: name,
|
||||
Score: score,
|
||||
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d 🎣%d", c.Level, c.MiningSkill, c.ForagingSkill, c.FishingSkill),
|
||||
Streak: c.CurrentStreak,
|
||||
Renown: c.Renown,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1028,7 +1048,11 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
if e.Streak >= 7 {
|
||||
streak = fmt.Sprintf(" 🔥%d", e.Streak)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s **%s** — %s (score: %d%s)\n", prefix, e.Name, e.Levels, e.Score, streak))
|
||||
renownBadge := ""
|
||||
if m := renownMarker(e.Renown); m != "" {
|
||||
renownBadge = " " + m
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s **%s**%s — %s (score: %d%s)\n", prefix, e.Name, renownBadge, e.Levels, e.Score, streak))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
|
||||
249
internal/plugin/adventure_renown.go
Normal file
249
internal/plugin/adventure_renown.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// N7/B2 — Renown: prestige past the level cap (gogobee_engagement_plan.md §B2).
|
||||
//
|
||||
// A confirmed D&D character caps at L20 (dndMaxLevel). Before N7, grantDnDXP
|
||||
// silently dropped any XP earned past the cap. Renown reclaims that overflow as
|
||||
// a prestige track: every renownXPPerLevel of overflow is one Renown level.
|
||||
//
|
||||
// Storage is a single cumulative column, player_meta.renown_xp, written by an
|
||||
// atomic INSERT…ON CONFLICT … += (the journal_pages pattern) so there is no
|
||||
// read-modify-write race. renown_level is DERIVED (renownLevelFor) rather than
|
||||
// stored, so nothing can disagree with the XP total.
|
||||
//
|
||||
// The reward is deliberately prestige-only — a derived rank title, a cosmetic
|
||||
// marker on the sheet/leaderboard, and a small capped bundle of *activity*
|
||||
// bonuses (loot quality, XP, death avoidance). It NEVER grants combat stats:
|
||||
// the balance corpus (SimulateCombat / the golden) must stay valid, so renown
|
||||
// touches only the AdvBonusSummary activity levers and only at the activity
|
||||
// call sites, never loadCombatBonuses.
|
||||
|
||||
// renownXPPerLevel is the overflow XP that buys one Renown level. Steep by
|
||||
// design (§B2): a capped L20 player earns ~750 XP per T5 dungeon win, so a
|
||||
// Renown level is dozens of endgame clears.
|
||||
const renownXPPerLevel = 25000
|
||||
|
||||
// Renown perk ladder. One small step every renownPerkStepLevels Renown levels,
|
||||
// capped at renownPerkMaxSteps steps. At the cap the perks total +20% XP / +15%
|
||||
// loot, matching a streak-30 grant's economic half — §B2's ceiling in total
|
||||
// power.
|
||||
//
|
||||
// The perks are deliberately ONLY the two combat-neutral levers of the
|
||||
// AdvBonusSummary: combat_stats.go maps DeathModifier→Defense,
|
||||
// SuccessBonus→Attack and ExceptionalBonus→CritRate, so those *are* combat
|
||||
// stats and are off-limits (§B2: never combat-stat inflation, the balance
|
||||
// corpus must stay valid). LootQuality and XPMultiplier are read only by the
|
||||
// loot/XP economy, never by combat stat derivation — so renown can pay them out
|
||||
// even through loadCombatBonuses without moving the golden. That is why §B2's
|
||||
// suggested "−death penalty" perk is intentionally NOT granted: it would inflate
|
||||
// Defense.
|
||||
const (
|
||||
renownPerkStepLevels = 3
|
||||
renownPerkMaxSteps = 10
|
||||
)
|
||||
|
||||
// renownLevelFor derives the Renown level from cumulative overflow XP.
|
||||
func renownLevelFor(renownXP int) int {
|
||||
if renownXP <= 0 {
|
||||
return 0
|
||||
}
|
||||
return renownXP / renownXPPerLevel
|
||||
}
|
||||
|
||||
// renownXPIntoLevel returns (progress, cost) toward the next Renown level, for
|
||||
// display. cost is always renownXPPerLevel.
|
||||
func renownXPIntoLevel(renownXP int) (int, int) {
|
||||
if renownXP < 0 {
|
||||
renownXP = 0
|
||||
}
|
||||
return renownXP % renownXPPerLevel, renownXPPerLevel
|
||||
}
|
||||
|
||||
// renownRank is one rung of the derived title ladder: the first Renown level at
|
||||
// which the rank applies, and its name. A rank promotion (crossing into a new
|
||||
// rung) is what gets announced in the games room; plain level-ups only DM.
|
||||
type renownRank struct {
|
||||
MinLevel int
|
||||
Name string
|
||||
}
|
||||
|
||||
var renownRanks = []renownRank{
|
||||
{1, "Renowned"},
|
||||
{3, "Storied"},
|
||||
{5, "Illustrious"},
|
||||
{10, "Fabled"},
|
||||
{15, "Mythic"},
|
||||
{20, "Ascendant"},
|
||||
{30, "Eternal"},
|
||||
}
|
||||
|
||||
// renownRankFor returns the rank name for a Renown level, or "" below level 1.
|
||||
func renownRankFor(level int) string {
|
||||
name := ""
|
||||
for _, r := range renownRanks {
|
||||
if level >= r.MinLevel {
|
||||
name = r.Name
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// renownMarker is the cosmetic prestige badge shown next to a name on the sheet
|
||||
// and leaderboard. Empty below Renown 1.
|
||||
func renownMarker(level int) string {
|
||||
if level < 1 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("✦%d", level)
|
||||
}
|
||||
|
||||
// applyRenownBonuses folds the capped renown perks into an AdvBonusSummary. It
|
||||
// touches ONLY LootQuality and XPMultiplier — the combat-neutral economy levers
|
||||
// — so it is safe to call anywhere the summary is built, including
|
||||
// loadCombatBonuses, without changing any combat stat (see the const block).
|
||||
func applyRenownBonuses(b *AdvBonusSummary, renownLevel int) {
|
||||
if b == nil || renownLevel < renownPerkStepLevels {
|
||||
return
|
||||
}
|
||||
steps := renownLevel / renownPerkStepLevels
|
||||
if steps > renownPerkMaxSteps {
|
||||
steps = renownPerkMaxSteps
|
||||
}
|
||||
b.XPMultiplier += float64(steps) * 2 // → +20% at the cap
|
||||
b.LootQuality += float64(steps) * 1.5 // → +15% at the cap
|
||||
}
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────────
|
||||
|
||||
// renownAccrueSQL is the atomic cumulative += on player_meta.renown_xp (the
|
||||
// journal_pages pattern), returning the post-update total. Shared by the
|
||||
// standalone and transactional accrual paths so the SQL lives in one place.
|
||||
const renownAccrueSQL = `INSERT INTO player_meta (user_id, renown_xp) VALUES (?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET renown_xp = renown_xp + excluded.renown_xp
|
||||
RETURNING renown_xp`
|
||||
|
||||
// sqlQueryer is the subset of *sql.DB / *sql.Tx that accrueRenownXP needs, so
|
||||
// the accrual can run standalone or inside grantDnDXP's save transaction.
|
||||
type sqlQueryer interface {
|
||||
QueryRow(query string, args ...any) *sql.Row
|
||||
}
|
||||
|
||||
// accrueRenownXP adds delta (> 0) to renown_xp against any queryer and returns
|
||||
// the cumulative totals before and after. A single statement, safe against
|
||||
// concurrent grants — no lost update.
|
||||
func accrueRenownXP(q sqlQueryer, userID id.UserID, delta int) (before, after int, err error) {
|
||||
if err = q.QueryRow(renownAccrueSQL, string(userID), delta).Scan(&after); err != nil {
|
||||
return 0, 0, fmt.Errorf("accrue renown_xp: %w", err)
|
||||
}
|
||||
return after - delta, after, nil
|
||||
}
|
||||
|
||||
// addRenownXP is the standalone accrual (its own DB write). delta <= 0 is a
|
||||
// no-op read. grantDnDXP uses the transactional saveDnDCharacterWithOverflow
|
||||
// instead, so this is the API for callers not already saving a character.
|
||||
func addRenownXP(userID id.UserID, delta int) (before, after int, err error) {
|
||||
if delta <= 0 {
|
||||
cur, e := loadRenownXP(userID)
|
||||
return cur, cur, e
|
||||
}
|
||||
return accrueRenownXP(db.Get(), userID, delta)
|
||||
}
|
||||
|
||||
// saveDnDCharacterWithOverflow persists the character and converts overflow XP
|
||||
// (> 0) to Renown in one transaction, so a crash can neither drop the overflow
|
||||
// nor double-credit it. Returns the cumulative renown totals before/after.
|
||||
func saveDnDCharacterWithOverflow(c *DnDCharacter, overflow int) (before, after int, err error) {
|
||||
tx, err := db.Get().Begin()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = saveDnDCharacterExec(tx, c); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if before, after, err = accrueRenownXP(tx, c.UserID, overflow); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return before, after, nil
|
||||
}
|
||||
|
||||
// loadRenownXP reads the cumulative overflow XP. Absent row == 0.
|
||||
func loadRenownXP(userID id.UserID) (int, error) {
|
||||
var xp int
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT renown_xp FROM player_meta WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&xp)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
return xp, err
|
||||
}
|
||||
|
||||
// renownLevelForUser is the per-user Renown level, for callers (leaderboard)
|
||||
// that don't already hold the overlay. Errors resolve to 0.
|
||||
func renownLevelForUser(userID id.UserID) int {
|
||||
xp, err := loadRenownXP(userID)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return renownLevelFor(xp)
|
||||
}
|
||||
|
||||
// ── Announce ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// announceRenown notifies the player of Renown level-ups (DM) and, when the
|
||||
// gain crossed into a new rank rung, the games room. Event-driven off a grant,
|
||||
// not a scheduled recap. from/to are Renown levels before/after the grant.
|
||||
func (p *AdventurePlugin) announceRenown(userID id.UserID, from, to int) {
|
||||
if p == nil || to <= from {
|
||||
return
|
||||
}
|
||||
gained := to - from
|
||||
rank := renownRankFor(to)
|
||||
if p.Client != nil {
|
||||
msg := fmt.Sprintf("✦ **Renown %d** ✦\n\nYou've earned %s past the level cap — your legend grows.",
|
||||
to, pluralLevels(gained))
|
||||
if rank != "" {
|
||||
msg += fmt.Sprintf("\nRank: **%s**.", rank)
|
||||
}
|
||||
if err := p.SendDM(userID, msg); err != nil {
|
||||
slog.Error("renown: level-up DM failed", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
// Games-room shout only on a rank promotion, to keep the room quiet.
|
||||
if renownRankFor(from) == rank {
|
||||
return
|
||||
}
|
||||
gr := gamesRoom()
|
||||
if gr == "" {
|
||||
return
|
||||
}
|
||||
name, _ := loadDisplayName(userID)
|
||||
if name == "" {
|
||||
name = string(userID)
|
||||
}
|
||||
p.SendMessage(id.RoomID(gr), fmt.Sprintf(
|
||||
"✦ **%s** reached **Renown %d — %s.** A legend of the realm.", name, to, rank))
|
||||
}
|
||||
|
||||
func pluralLevels(n int) string {
|
||||
if n == 1 {
|
||||
return "a Renown level"
|
||||
}
|
||||
return fmt.Sprintf("%d Renown levels", n)
|
||||
}
|
||||
238
internal/plugin/adventure_renown_test.go
Normal file
238
internal/plugin/adventure_renown_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func newRenownTestDB(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 TestRenownLevelFor(t *testing.T) {
|
||||
cases := []struct{ xp, want int }{
|
||||
{0, 0},
|
||||
{-100, 0},
|
||||
{renownXPPerLevel - 1, 0},
|
||||
{renownXPPerLevel, 1},
|
||||
{renownXPPerLevel*3 + 12, 3},
|
||||
{renownXPPerLevel * 30, 30},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := renownLevelFor(c.xp); got != c.want {
|
||||
t.Errorf("renownLevelFor(%d) = %d, want %d", c.xp, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenownXPIntoLevel(t *testing.T) {
|
||||
into, cost := renownXPIntoLevel(renownXPPerLevel + 500)
|
||||
if cost != renownXPPerLevel {
|
||||
t.Errorf("cost = %d, want %d", cost, renownXPPerLevel)
|
||||
}
|
||||
if into != 500 {
|
||||
t.Errorf("into = %d, want 500", into)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenownRankLadderMonotonic(t *testing.T) {
|
||||
// Rank thresholds must strictly increase so renownRankFor is well-defined.
|
||||
for i := 1; i < len(renownRanks); i++ {
|
||||
if renownRanks[i].MinLevel <= renownRanks[i-1].MinLevel {
|
||||
t.Errorf("rank %d threshold %d not > %d", i, renownRanks[i].MinLevel, renownRanks[i-1].MinLevel)
|
||||
}
|
||||
}
|
||||
if renownRankFor(0) != "" {
|
||||
t.Errorf("rank at 0 should be empty")
|
||||
}
|
||||
if renownRankFor(1) != "Renowned" {
|
||||
t.Errorf("rank at 1 = %q, want Renowned", renownRankFor(1))
|
||||
}
|
||||
if renownRankFor(4) != "Storied" {
|
||||
t.Errorf("rank at 4 = %q, want Storied (threshold 3)", renownRankFor(4))
|
||||
}
|
||||
if renownRankFor(1000) != "Eternal" {
|
||||
t.Errorf("top rank = %q, want Eternal", renownRankFor(1000))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenownMarker(t *testing.T) {
|
||||
if renownMarker(0) != "" {
|
||||
t.Errorf("marker at 0 should be empty")
|
||||
}
|
||||
if got := renownMarker(7); got != "✦7" {
|
||||
t.Errorf("marker(7) = %q, want ✦7", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRenownBonuses_CombatNeutralAndCapped — renown grants only the two
|
||||
// combat-neutral economy levers (loot/XP), capped at +15%/+20%, and must never
|
||||
// touch a lever that combat_stats.go maps to a combat stat. §B2.
|
||||
func TestApplyRenownBonuses_CombatNeutralAndCapped(t *testing.T) {
|
||||
// Below the first step: no effect.
|
||||
b := &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, 2)
|
||||
if b.XPMultiplier != 0 || b.LootQuality != 0 {
|
||||
t.Errorf("renown < step should be inert, got %+v", b)
|
||||
}
|
||||
|
||||
// One step at renown 3.
|
||||
b = &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, 3)
|
||||
if b.XPMultiplier != 2 || b.LootQuality != 1.5 {
|
||||
t.Errorf("one step wrong: %+v", b)
|
||||
}
|
||||
|
||||
// At renown 30 and beyond, capped at +20% XP / +15% loot.
|
||||
for _, lvl := range []int{30, 45, 300} {
|
||||
b = &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, lvl)
|
||||
if b.XPMultiplier != 20 || b.LootQuality != 15 {
|
||||
t.Errorf("renown %d not capped: %+v", lvl, b)
|
||||
}
|
||||
}
|
||||
|
||||
// Never combat-stat inflation: the levers combat_stats.go reads
|
||||
// (DeathModifier→Defense, SuccessBonus→Attack, ExceptionalBonus→CritRate)
|
||||
// and the skill/combat levers must all stay at zero at any renown level.
|
||||
b = &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, 300)
|
||||
if b.DeathModifier != 0 || b.SuccessBonus != 0 || b.ExceptionalBonus != 0 {
|
||||
t.Errorf("renown leaked into a combat-stat lever: %+v", b)
|
||||
}
|
||||
if b.CombatBonus != 0 || b.MiningBonus != 0 || b.ForagingBonus != 0 || b.FishingBonus != 0 {
|
||||
t.Errorf("renown leaked into combat/skill levers: %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddRenownXP_Accumulates — cumulative += with correct before/after.
|
||||
func TestAddRenownXP_Accumulates(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_add:example")
|
||||
if err := createAdvCharacter(uid, "Renowner"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
before, after, err := addRenownXP(uid, 10000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before != 0 || after != 10000 {
|
||||
t.Errorf("first add: before=%d after=%d, want 0/10000", before, after)
|
||||
}
|
||||
|
||||
before, after, err = addRenownXP(uid, 20000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before != 10000 || after != 30000 {
|
||||
t.Errorf("second add: before=%d after=%d, want 10000/30000", before, after)
|
||||
}
|
||||
|
||||
got, err := loadRenownXP(uid)
|
||||
if err != nil || got != 30000 {
|
||||
t.Errorf("loadRenownXP = %d (err %v), want 30000", got, err)
|
||||
}
|
||||
if renownLevelForUser(uid) != 1 {
|
||||
t.Errorf("renownLevelForUser = %d, want 1", renownLevelForUser(uid))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenownAtLeast — the B4 achievement gate reads the derived level correctly.
|
||||
func TestRenownAtLeast(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_ach:example")
|
||||
if err := createAdvCharacter(uid, "Achiever"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := db.Get()
|
||||
if renownAtLeast(d, uid, 1) {
|
||||
t.Errorf("no renown yet, should not meet level 1")
|
||||
}
|
||||
if _, _, err := addRenownXP(uid, renownXPPerLevel*5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !renownAtLeast(d, uid, 5) {
|
||||
t.Errorf("renown 5 should meet level 5")
|
||||
}
|
||||
if renownAtLeast(d, uid, 6) {
|
||||
t.Errorf("renown 5 should not meet level 6")
|
||||
}
|
||||
// Absent player → false, not a crash.
|
||||
if renownAtLeast(d, id.UserID("@nobody:nowhere.invalid"), 1) {
|
||||
t.Errorf("absent player should not meet any renown level")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenownOverlay — applyPlayerMetaOverlay populates RenownXP and RenownLevel.
|
||||
func TestRenownOverlay(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_overlay:example")
|
||||
if err := createAdvCharacter(uid, "Overlaid"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := addRenownXP(uid, renownXPPerLevel*5+7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := loadAdvCharacter(uid)
|
||||
if err != nil || c == nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if c.RenownXP != renownXPPerLevel*5+7 {
|
||||
t.Errorf("overlay RenownXP = %d, want %d", c.RenownXP, renownXPPerLevel*5+7)
|
||||
}
|
||||
if c.RenownLevel() != 5 {
|
||||
t.Errorf("RenownLevel() = %d, want 5", c.RenownLevel())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantDnDXP_OverflowBecomesRenown — at the cap, grantDnDXP routes overflow
|
||||
// into renown_xp and zeroes dnd_xp (the pre-N7 cap invariant is preserved).
|
||||
func TestGrantDnDXP_OverflowBecomesRenown(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_grant:example")
|
||||
if err := createAdvCharacter(uid, "Capped"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: dndMaxLevel,
|
||||
STR: 16, DEX: 13, CON: 14, INT: 8, WIS: 10, CHA: 12, ArmorClass: 16,
|
||||
}
|
||||
c.HPMax = 100
|
||||
c.HPCurrent = 100
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
events, err := p.grantDnDXP(uid, renownXPPerLevel+5000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Errorf("got %d level-up events at cap, want 0", len(events))
|
||||
}
|
||||
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.Level != dndMaxLevel {
|
||||
t.Errorf("level = %d, want %d", got.Level, dndMaxLevel)
|
||||
}
|
||||
if got.XP != 0 {
|
||||
t.Errorf("dnd_xp = %d, want 0 (overflow moved to renown)", got.XP)
|
||||
}
|
||||
if xp, _ := loadRenownXP(uid); xp != renownXPPerLevel+5000 {
|
||||
t.Errorf("renown_xp = %d, want %d", xp, renownXPPerLevel+5000)
|
||||
}
|
||||
if renownLevelForUser(uid) != 1 {
|
||||
t.Errorf("renown level = %d, want 1", renownLevelForUser(uid))
|
||||
}
|
||||
}
|
||||
@@ -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 1–3 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 (1–3 / 4–7 / 8–12 / 13–17 / 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)
|
||||
|
||||
@@ -163,6 +163,7 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
||||
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||
applyRenownBonuses(bonuses, char.RenownLevel()) // N7/B2 — activity path only, never combat
|
||||
balance := p.euro.GetBalance(char.UserID)
|
||||
|
||||
holidayLabel := ""
|
||||
@@ -420,6 +421,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).
|
||||
|
||||
199
internal/plugin/adventure_season.go
Normal file
199
internal/plugin/adventure_season.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// N7/E4 — Seasonal events (gogobee_engagement_plan.md §E4).
|
||||
//
|
||||
// A season is a 1-week skin anchored to a real holiday. Unlike isHolidayToday()
|
||||
// (a single UTC day, worth a double-action) and unlike the Omen (an ISO-week
|
||||
// rotation), a season spans a window around its anchor date and layers three
|
||||
// things on top of the world for that week:
|
||||
//
|
||||
// 1. A themed world modifier — a reskinned Omen that OVERRIDES the weekly
|
||||
// rotation (see activeOmen). It reuses the omen effect fields, so the same
|
||||
// non-combat rule holds: a season never touches SimulateCombat or the turn
|
||||
// engine. That is enforced structurally — activeSeason() honours
|
||||
// simOmenDisabled exactly as activeOmen() does, so the balance sim never
|
||||
// sees a season through any path, and the season Omen only reaches the sim's
|
||||
// omen seams behind activeOmen's own simOmenDisabled guard.
|
||||
// 2. A limited-time curio shelf at Luigi's — a curated selection of existing
|
||||
// registry items (dailyCuriosStock). No new power enters the game; the shelf
|
||||
// just changes which items rotate in for the week.
|
||||
// 3. A themed visitor on the road — a season-gated ambient event that leaves a
|
||||
// small keepsake and a coin gift (expedition_ambient.go). No combat: the
|
||||
// ambient seam has never opened a fight and this does not change that.
|
||||
//
|
||||
// A season is a pure function of the UTC date and the anchor calendar, so it
|
||||
// needs no schema, no ticker state, and no persistence — the same discipline as
|
||||
// the Omen and the holiday calendar.
|
||||
|
||||
// seasonHalfWidth is the number of days on each side of the anchor date that the
|
||||
// season is live. 3 → a 7-day window (anchor ± 3).
|
||||
const seasonHalfWidth = 3
|
||||
|
||||
// seasonVisitor is the themed ambient event a season adds to the road. It never
|
||||
// resolves combat — it leaves a sellable keepsake and a small coin gift.
|
||||
type seasonVisitor struct {
|
||||
Weight int // relative weight in the ambient pick
|
||||
FlavorPool []string // scene narration lines for the visit
|
||||
Keepsake string // sellable trophy left behind
|
||||
KeepsakeValue int64 // coin baseline of the keepsake
|
||||
Coins int // flat coin gift
|
||||
}
|
||||
|
||||
// season is one holiday skin.
|
||||
type season struct {
|
||||
Key string // stable id (tests, logs)
|
||||
Name string // player-facing, e.g. "Hallowtide"
|
||||
Emoji string // banner emoji
|
||||
Anchor func(year int) time.Time // the anchor date for a given year (UTC)
|
||||
Blurb string // one-line "what's live this week" banner copy
|
||||
Omen omen // themed override omen (non-combat fields only)
|
||||
CurioIDs []string // curated registry IDs for Luigi's shelf
|
||||
Visitor seasonVisitor // the road visitor
|
||||
}
|
||||
|
||||
// fixedAnchor builds an Anchor for a fixed month/day holiday.
|
||||
func fixedAnchor(month time.Month, day int) func(int) time.Time {
|
||||
return func(year int) time.Time {
|
||||
return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
}
|
||||
|
||||
// seasonTable is the set of anchor holidays that get a skin. Order is match
|
||||
// order; the windows are disjoint so it never matters, but keep them disjoint.
|
||||
var seasonTable = []season{
|
||||
{
|
||||
Key: "hallowtide", Name: "Hallowtide", Emoji: "🎃",
|
||||
Anchor: fixedAnchor(time.October, 31),
|
||||
Blurb: "the veil's thin — spooky curios at Luigi's and things going bump on the road, all week",
|
||||
Omen: omen{
|
||||
Key: "hallowtide", Name: "Hallowtide",
|
||||
TwinBee: "The veil's gone thin for Hallowtide — reagents and oddments are spilling through from somewhere I'd rather not name. I'm grabbing them while they're here.",
|
||||
ConsumableChanceMult: 2.0,
|
||||
},
|
||||
CurioIDs: []string{
|
||||
"cloak_of_arachnida", "demon_armor", "goggles_of_night",
|
||||
"slippers_of_spider_climbing", "staff_of_swarming_insects",
|
||||
"sword_of_life_stealing", "dagger_of_venom", "cloak_of_the_bat",
|
||||
},
|
||||
Visitor: seasonVisitor{
|
||||
Weight: 12,
|
||||
FlavorPool: []string{
|
||||
"A gourd-headed thing shuffles out of the dark, sets a little carved lantern on your bedroll, and shuffles right back into it.",
|
||||
"Something small and many-legged skitters past, drops a trinket at your feet as if in tribute, and is gone before you can look twice.",
|
||||
"A cold draft carries a whisper and a gift — a carved gourd, still faintly warm, left where you'll find it come morning.",
|
||||
},
|
||||
Keepsake: "Carved Gourd Lantern", KeepsakeValue: 60, Coins: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "midwinter", Name: "Midwinter Feast", Emoji: "❄️",
|
||||
Anchor: fixedAnchor(time.December, 25),
|
||||
Blurb: "gifts by every door — a free pack for anyone heading out, warm curios at Luigi's, all week",
|
||||
Omen: omen{
|
||||
Key: "midwinter", Name: "Midwinter Feast",
|
||||
TwinBee: "It's Midwinter, and someone's been leaving gifts by the outfitter's door. There's a free pack in it for anyone heading out — and I set off in high spirits.",
|
||||
SupplyFreebiePacks: 1,
|
||||
StartMoodBonus: 5,
|
||||
},
|
||||
CurioIDs: []string{
|
||||
"boots_of_the_winterlands", "frost_brand", "ring_of_warmth",
|
||||
"staff_of_frost", "potion_of_healing", "staff_of_healing",
|
||||
},
|
||||
Visitor: seasonVisitor{
|
||||
Weight: 12,
|
||||
FlavorPool: []string{
|
||||
"A bundled figure passes your camp without a word, leaves a frost-glass bauble hanging where the firelight catches it, and trudges on into the snow.",
|
||||
"You wake to find your pack a little heavier — a wrapped bauble and a handful of coin, and a single set of bootprints leading away.",
|
||||
"Bells, faint and far off. By the time they fade there's a bauble on your bedroll that wasn't there before.",
|
||||
},
|
||||
Keepsake: "Frost-Glass Bauble", KeepsakeValue: 60, Coins: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "sweethearts", Name: "Sweethearts' Revel", Emoji: "💗",
|
||||
Anchor: fixedAnchor(time.February, 14),
|
||||
Blurb: "the arena crowd's throwing coin — fat purses, charming curios at Luigi's, all week",
|
||||
Omen: omen{
|
||||
Key: "sweethearts", Name: "Sweethearts' Revel",
|
||||
TwinBee: "The whole town's giddy for Sweethearts' week — the arena crowd's throwing coin like confetti. Win pretty and the purse pays twenty over the odds.",
|
||||
ArenaPayoutMult: 1.20,
|
||||
},
|
||||
CurioIDs: []string{
|
||||
"eyes_of_charming", "philter_of_love", "staff_of_charming",
|
||||
"luck_blade", "stone_of_good_luck_luckstone", "pearl_of_power",
|
||||
"glamoured_studded_leather",
|
||||
},
|
||||
Visitor: seasonVisitor{
|
||||
Weight: 12,
|
||||
FlavorPool: []string{
|
||||
"A courier in festival colours finds you even out here, presses a ribbon-wrapped token into your hand with a wink, and hurries back the way they came.",
|
||||
"A paper heart, pinned to a token and left on your pack — 'from an admirer,' it says, and nothing else.",
|
||||
"Someone's left a ribbon-wrapped keepsake by the trail marker, addressed to no one and everyone. You pocket it.",
|
||||
},
|
||||
Keepsake: "Ribbon-Wrapped Token", KeepsakeValue: 55, Coins: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "first_bloom", Name: "First Bloom", Emoji: "🌷",
|
||||
Anchor: func(year int) time.Time { return easterDate(year) },
|
||||
Blurb: "everything's growing eager — fuller harvests, green curios at Luigi's, all week",
|
||||
Omen: omen{
|
||||
Key: "first_bloom", Name: "First Bloom",
|
||||
TwinBee: "First Bloom's on us — everything's growing twice as eager and the woods feel calm with it. Every gather comes up fuller, and the dread's slow to rise.",
|
||||
HarvestYieldBonus: 1,
|
||||
ThreatDriftReduce: 1,
|
||||
},
|
||||
CurioIDs: []string{
|
||||
"bag_of_beans", "potion_of_growth", "potion_of_animal_friendship",
|
||||
"ring_of_animal_influence", "horn_of_valhalla", "sword_of_life_stealing",
|
||||
},
|
||||
Visitor: seasonVisitor{
|
||||
Weight: 12,
|
||||
FlavorPool: []string{
|
||||
"A hare the size of a hound lopes up, regards you with unsettling calm, and leaves a pressed blossom on the ground before bounding off.",
|
||||
"New vines have crept over your gear in the night — and tucked among them, a perfect pressed blossom and a little coin, as if the woods were paying rent.",
|
||||
"The undergrowth parts on its own, sets a spring blossom at your feet, and closes again. You decide not to question it.",
|
||||
},
|
||||
Keepsake: "Pressed Spring Blossom", KeepsakeValue: 50, Coins: 8,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// activeSeason returns the season whose window contains today (UTC), or false.
|
||||
// Honours simOmenDisabled so the balance sim never observes a season through any
|
||||
// path — the same neutralisation activeOmen applies to the weekly rotation.
|
||||
func activeSeason() (season, bool) {
|
||||
if simOmenDisabled {
|
||||
return season{}, false
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
return seasonForDate(today)
|
||||
}
|
||||
|
||||
// seasonForDate is the pure core of activeSeason, split out for tests. The
|
||||
// anchor is checked against the target year and its neighbours so a window that
|
||||
// straddles a year boundary still resolves.
|
||||
func seasonForDate(today time.Time) (season, bool) {
|
||||
for _, s := range seasonTable {
|
||||
for _, yr := range []int{today.Year() - 1, today.Year(), today.Year() + 1} {
|
||||
a := s.Anchor(yr)
|
||||
lo := a.AddDate(0, 0, -seasonHalfWidth)
|
||||
hi := a.AddDate(0, 0, seasonHalfWidth)
|
||||
if !today.Before(lo) && !today.After(hi) {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return season{}, false
|
||||
}
|
||||
|
||||
// seasonBannerLine is the "what's live this week" one-liner surfaced under the
|
||||
// Omen line in the morning DM. Empty when no season is active.
|
||||
func seasonBannerLine(s season) string {
|
||||
return s.Emoji + " **" + s.Name + "** is here — " + s.Blurb + "."
|
||||
}
|
||||
120
internal/plugin/adventure_season_test.go
Normal file
120
internal/plugin/adventure_season_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func date(y int, m time.Month, d int) time.Time {
|
||||
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// TestSeasonForDate_ActiveOnAnchor — every season resolves on its own anchor day
|
||||
// and both window edges (±seasonHalfWidth), and is dark one day past each edge.
|
||||
func TestSeasonForDate_ActiveOnAnchor(t *testing.T) {
|
||||
for _, s := range seasonTable {
|
||||
a := s.Anchor(2026)
|
||||
for _, off := range []int{-seasonHalfWidth, 0, seasonHalfWidth} {
|
||||
got, ok := seasonForDate(a.AddDate(0, 0, off))
|
||||
if !ok || got.Key != s.Key {
|
||||
t.Errorf("%s: day offset %d → (%v, %q), want (true, %q)",
|
||||
s.Key, off, ok, got.Key, s.Key)
|
||||
}
|
||||
}
|
||||
for _, off := range []int{-seasonHalfWidth - 1, seasonHalfWidth + 1} {
|
||||
if got, ok := seasonForDate(a.AddDate(0, 0, off)); ok {
|
||||
t.Errorf("%s: day offset %d should be dark, got %q", s.Key, off, got.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeasonForDate_WindowsDisjoint — no calendar day resolves to two seasons, so
|
||||
// the first-match order in seasonForDate never hides overlapping content.
|
||||
func TestSeasonForDate_WindowsDisjoint(t *testing.T) {
|
||||
for y := 2024; y <= 2030; y++ {
|
||||
day := date(y, time.January, 1)
|
||||
end := date(y+1, time.January, 1)
|
||||
for day.Before(end) {
|
||||
matches := 0
|
||||
for _, s := range seasonTable {
|
||||
for _, yr := range []int{day.Year() - 1, day.Year(), day.Year() + 1} {
|
||||
a := s.Anchor(yr)
|
||||
if !day.Before(a.AddDate(0, 0, -seasonHalfWidth)) &&
|
||||
!day.After(a.AddDate(0, 0, seasonHalfWidth)) {
|
||||
matches++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches > 1 {
|
||||
t.Fatalf("%s resolves to %d seasons (windows must be disjoint)",
|
||||
day.Format("2006-01-02"), matches)
|
||||
}
|
||||
day = day.AddDate(0, 0, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeasonTable_CuratedItemsExist — every curated curio ID is a real registry
|
||||
// item, so a season's shelf never lists a phantom the buyer can't purchase. Guards
|
||||
// against typos and registry edits (mirrors TestTemperMaterialsAreObtainable).
|
||||
func TestSeasonTable_CuratedItemsExist(t *testing.T) {
|
||||
for _, s := range seasonTable {
|
||||
if len(s.CurioIDs) == 0 {
|
||||
t.Errorf("%s has no curated curios", s.Key)
|
||||
}
|
||||
for _, id := range s.CurioIDs {
|
||||
if _, ok := magicItemRegistry[id]; !ok {
|
||||
t.Errorf("%s curio %q is not in magicItemRegistry", s.Key, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeasonTable_OmenIsNonCombat — a season's themed omen carries an effect and
|
||||
// only touches the non-combat omen levers, so overriding the weekly rotation with
|
||||
// it can never move the combat golden or the balance corpus. §E4/§B3.
|
||||
func TestSeasonTable_OmenIsNonCombat(t *testing.T) {
|
||||
for _, s := range seasonTable {
|
||||
o := s.Omen
|
||||
hasEffect := o.HarvestYieldBonus > 0 || o.SupplyFreebiePacks > 0 ||
|
||||
o.StartMoodBonus > 0 || o.ArenaPayoutMult > 1.0 ||
|
||||
o.ConsumableChanceMult > 1.0 || o.ThreatDriftReduce > 0
|
||||
if !hasEffect {
|
||||
t.Errorf("%s omen has no active effect", s.Key)
|
||||
}
|
||||
if o.Name == "" || o.TwinBee == "" {
|
||||
t.Errorf("%s omen missing player-facing copy", s.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeasonTable_VisitorRewardComplete — every season's road visitor has flavor,
|
||||
// a named keepsake, and a positive keepsake value, so applyAmbientEffect always
|
||||
// has a real reward to grant.
|
||||
func TestSeasonTable_VisitorRewardComplete(t *testing.T) {
|
||||
for _, s := range seasonTable {
|
||||
v := s.Visitor
|
||||
if len(v.FlavorPool) == 0 {
|
||||
t.Errorf("%s visitor has no flavor", s.Key)
|
||||
}
|
||||
if v.Keepsake == "" || v.KeepsakeValue <= 0 {
|
||||
t.Errorf("%s visitor keepsake incomplete: %q value %d", s.Key, v.Keepsake, v.KeepsakeValue)
|
||||
}
|
||||
if v.Weight <= 0 {
|
||||
t.Errorf("%s visitor has non-positive weight %d", s.Key, v.Weight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestActiveSeason_NeutralizedInSim — the balance sim must never observe a season
|
||||
// through any path; activeSeason honours simOmenDisabled exactly as activeOmen does.
|
||||
func TestActiveSeason_NeutralizedInSim(t *testing.T) {
|
||||
prev := simOmenDisabled
|
||||
simOmenDisabled = true
|
||||
defer func() { simOmenDisabled = prev }()
|
||||
if s, ok := activeSeason(); ok {
|
||||
t.Fatalf("activeSeason returned %q while simOmenDisabled", s.Key)
|
||||
}
|
||||
}
|
||||
216
internal/plugin/adventure_secret_room_test.go
Normal file
216
internal/plugin/adventure_secret_room_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
432
internal/plugin/adventure_shadow.go
Normal file
432
internal/plugin/adventure_shadow.go
Normal 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())
|
||||
}
|
||||
360
internal/plugin/adventure_shadow_test.go
Normal file
360
internal/plugin/adventure_shadow_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1146,13 +1146,38 @@ func dailyCuriosStock() []MagicItem {
|
||||
rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
|
||||
rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] })
|
||||
|
||||
n := curiosStockSize
|
||||
if n > len(ids) {
|
||||
n = len(ids)
|
||||
target := curiosStockSize
|
||||
if target > len(ids) {
|
||||
target = len(ids)
|
||||
}
|
||||
stock := make([]MagicItem, 0, n)
|
||||
for _, id := range ids[:n] {
|
||||
stock := make([]MagicItem, 0, target)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
// N7/E4 — a live season features its curated items at the front of the shelf,
|
||||
// displacing the day's rotation. They are existing registry items, so no
|
||||
// net-new power enters the economy for the week; only which items rotate in.
|
||||
if s, ok := activeSeason(); ok {
|
||||
for _, id := range s.CurioIDs {
|
||||
if mi, ok := magicItemRegistry[id]; ok && !seen[id] {
|
||||
stock = append(stock, mi)
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
if len(stock) > target {
|
||||
target = len(stock)
|
||||
}
|
||||
}
|
||||
|
||||
// Fill the remaining slots from the day's deterministic rotation.
|
||||
for _, id := range ids {
|
||||
if len(stock) >= target {
|
||||
break
|
||||
}
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
stock = append(stock, magicItemRegistry[id])
|
||||
seen[id] = true
|
||||
}
|
||||
// Stable display order: rarity ascending, then name.
|
||||
sort.Slice(stock, func(i, j int) bool {
|
||||
@@ -1167,7 +1192,11 @@ func dailyCuriosStock() []MagicItem {
|
||||
|
||||
func (p *AdventurePlugin) luigiCuriosView(userID id.UserID, balance float64) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🔮 **Curios** — fresh stock daily at dawn\n")
|
||||
if s, ok := activeSeason(); ok {
|
||||
sb.WriteString(fmt.Sprintf("%s **Curios** — %s stock, this week only\n", s.Emoji, s.Name))
|
||||
} else {
|
||||
sb.WriteString("🔮 **Curios** — fresh stock daily at dawn\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
|
||||
|
||||
factor := p.shopSessionPriceFactor(userID)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
784
internal/plugin/adventure_worldboss.go
Normal file
784
internal/plugin/adventure_worldboss.go
Normal 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())
|
||||
}
|
||||
417
internal/plugin/adventure_worldboss_test.go
Normal file
417
internal/plugin/adventure_worldboss_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,12 @@ func (p *AdventurePlugin) loadCombatBonuses(userID id.UserID, char *AdventureCha
|
||||
treasures, _ := loadAdvTreasureBonuses(userID)
|
||||
buffs, _ := loadAdvActiveBuffs(userID)
|
||||
hasGrudge := char.GrudgeLocation != ""
|
||||
return computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
|
||||
b := computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
|
||||
// N7/B2 — renown pays out on loot/XP here too. Safe despite this feeding
|
||||
// combat_stats: applyRenownBonuses touches only LootQuality/XPMultiplier,
|
||||
// which combat stat derivation never reads (see adventure_renown.go).
|
||||
applyRenownBonuses(b, char.RenownLevel())
|
||||
return b
|
||||
}
|
||||
|
||||
// loadConsumableInventory scans inventory for items matching consumable definitions.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -332,8 +332,22 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// sqlExecer is the subset of *sql.DB / *sql.Tx that saveDnDCharacterExec needs,
|
||||
// so the upsert can run either standalone or inside a caller's transaction.
|
||||
type sqlExecer interface {
|
||||
Exec(query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
// SaveDnDCharacter upserts the row.
|
||||
func SaveDnDCharacter(c *DnDCharacter) error {
|
||||
return saveDnDCharacterExec(db.Get(), c)
|
||||
}
|
||||
|
||||
// saveDnDCharacterExec runs the dnd_character upsert against any executor. Used
|
||||
// directly by SaveDnDCharacter and inside grantDnDXP's overflow→renown
|
||||
// transaction (adventure_renown.go), so the character save and the renown
|
||||
// credit commit atomically.
|
||||
func saveDnDCharacterExec(ex sqlExecer, c *DnDCharacter) error {
|
||||
pending := 0
|
||||
if c.PendingSetup {
|
||||
pending = 1
|
||||
@@ -346,7 +360,7 @@ func SaveDnDCharacter(c *DnDCharacter) error {
|
||||
if c.OnboardingSent {
|
||||
onboard = 1
|
||||
}
|
||||
_, err := db.Get().Exec(`
|
||||
_, err := ex.Exec(`
|
||||
INSERT INTO dnd_character (user_id, race, class, dnd_level, dnd_xp,
|
||||
str_score, dex_score, con_score, int_score, wis_score, cha_score,
|
||||
hp_current, hp_max, temp_hp, armor_class,
|
||||
|
||||
@@ -160,6 +160,9 @@ func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies Exp
|
||||
if isHol, _ := isHolidayToday(); isHol {
|
||||
startMood = 55
|
||||
}
|
||||
if b := activeOmen().StartMoodBonus; b > 0 { // N7/B3 the Omen
|
||||
startMood += b
|
||||
}
|
||||
exp := &Expedition{
|
||||
ID: newExpeditionID(),
|
||||
UserID: string(userID),
|
||||
|
||||
@@ -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 {
|
||||
@@ -378,6 +384,9 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
if isHol, _ := isHolidayToday(); isHol {
|
||||
suppliesPurchase.StandardPacks++
|
||||
}
|
||||
if pk := activeOmen().SupplyFreebiePacks; pk > 0 { // N7/B3 the Omen
|
||||
suppliesPurchase.StandardPacks += pk
|
||||
}
|
||||
supplies := makeSupplies(zone.Tier, suppliesPurchase)
|
||||
|
||||
// Debit coins; bail on debit failure (race / cap).
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -463,6 +463,9 @@ func grantHarvestYield(userID id.UserID, res ZoneResource, qty int) error {
|
||||
if isHol, _ := isHolidayToday(); isHol {
|
||||
qty++
|
||||
}
|
||||
if b := activeOmen().HarvestYieldBonus; b > 0 { // N7/B3 the Omen
|
||||
qty += b
|
||||
}
|
||||
tier := zoneTierFromID(res.ZoneID)
|
||||
for i := 0; i < qty; i++ {
|
||||
item := AdvItem{
|
||||
|
||||
@@ -117,6 +117,14 @@ func applyDailyThreatDrift(e *Expedition) (int, string, error) {
|
||||
return 0, "", nil
|
||||
}
|
||||
delta, reason := dailyThreatDrift(e.DMMood)
|
||||
// N7/B3 the Omen — a "still waters" week subtracts from the daily threat
|
||||
// *rise* only (guarded on delta > 0), floored so the worst case is threat
|
||||
// holding steady for the day; it never turns a rise into active decay.
|
||||
if r := activeOmen().ThreatDriftReduce; r > 0 && delta > 0 {
|
||||
if delta -= r; delta < 0 {
|
||||
delta = 0
|
||||
}
|
||||
}
|
||||
if delta == 0 {
|
||||
return 0, reason, nil
|
||||
}
|
||||
|
||||
@@ -161,6 +161,16 @@ func (p *AdventurePlugin) handleDnDLevelCmd(ctx MessageContext) error {
|
||||
b.WriteString(fmt.Sprintf("⚔️ Level **%d** %s %s\n", c.Level, ri.Display, ci.Display))
|
||||
if c.Level >= dndMaxLevel {
|
||||
b.WriteString("XP: capped at L20.")
|
||||
if rl := advChar.RenownLevel(); rl > 0 {
|
||||
into, cost := renownXPIntoLevel(advChar.RenownXP)
|
||||
b.WriteString(fmt.Sprintf("\n✦ **Renown %d**", rl))
|
||||
if rank := renownRankFor(rl); rank != "" {
|
||||
b.WriteString(fmt.Sprintf(" — _%s_", rank))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\nRenown XP: %d / %d to Renown %d", into, cost, rl+1))
|
||||
} else {
|
||||
b.WriteString("\n_Overflow XP now earns **Renown** — prestige past the cap._")
|
||||
}
|
||||
} else {
|
||||
next := dndXPToNextLevel(c.Level)
|
||||
pct := int(100.0 * float64(c.XP) / float64(next))
|
||||
|
||||
@@ -79,7 +79,20 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta,
|
||||
if adv != nil && adv.DisplayName != "" {
|
||||
name = adv.DisplayName
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", name, c.Level, ri.Display, ci.Display))
|
||||
renownLevel := 0
|
||||
if adv != nil {
|
||||
renownLevel = adv.RenownLevel()
|
||||
}
|
||||
nameLine := name
|
||||
if m := renownMarker(renownLevel); m != "" {
|
||||
nameLine = fmt.Sprintf("%s %s", name, m)
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", nameLine, c.Level, ri.Display, ci.Display))
|
||||
if renownLevel > 0 {
|
||||
if rank := renownRankFor(renownLevel); rank != "" {
|
||||
b.WriteString(fmt.Sprintf(" _Renown %d — %s_\n", renownLevel, rank))
|
||||
}
|
||||
}
|
||||
if c.Subclass != "" {
|
||||
if si, ok := subclassInfo(c.Subclass); ok {
|
||||
b.WriteString(fmt.Sprintf(" _%s_\n", si.Display))
|
||||
|
||||
@@ -127,15 +127,26 @@ func (p *AdventurePlugin) grantDnDXP(userID id.UserID, amount int) ([]LevelUpEve
|
||||
events = append(events, LevelUpEvent{NewLevel: c.Level, HPGain: gain})
|
||||
}
|
||||
|
||||
// Cap at L20 — overflow XP is silently dropped.
|
||||
if c.Level >= dndMaxLevel {
|
||||
// N7/B2 Renown — persist the character; at the cap, overflow XP that used to
|
||||
// be dropped converts to Renown in the SAME transaction as the save, so a
|
||||
// crash can neither lose the overflow nor double-credit it on the next grant.
|
||||
// Renown is title + cosmetic + capped loot/XP perks, never combat stats, so
|
||||
// the balance corpus stays valid.
|
||||
var renownFrom, renownTo int
|
||||
if c.Level >= dndMaxLevel && c.XP > 0 {
|
||||
overflow := c.XP
|
||||
c.XP = 0
|
||||
}
|
||||
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
from, to, err := saveDnDCharacterWithOverflow(c, overflow)
|
||||
if err != nil {
|
||||
return events, err
|
||||
}
|
||||
renownFrom, renownTo = renownLevelFor(from), renownLevelFor(to)
|
||||
} else if err := SaveDnDCharacter(c); err != nil {
|
||||
return events, err
|
||||
}
|
||||
|
||||
p.announceRenown(userID, renownFrom, renownTo)
|
||||
|
||||
if len(events) > 0 {
|
||||
// Phase 10 SUB3a-ii — Battle Master Relentless (L15) raises the
|
||||
// superiority cap from 4 → 5; reconcile any level-gated subclass pool
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -454,7 +454,11 @@ var advIngredientActivities = []AdvActivityType{
|
||||
// rollZoneIngredient draws one crafting ingredient from a random legacy
|
||||
// gathering table at the zone's tier. Returns nil on the common no-drop path.
|
||||
func rollZoneIngredient(zoneTier int) *AdvItem {
|
||||
if rand.Float64() >= advIngredientDropChance {
|
||||
chance := advIngredientDropChance
|
||||
if m := activeOmen().ConsumableChanceMult; m > 1.0 { // N7/B3 the Omen
|
||||
chance *= m
|
||||
}
|
||||
if rand.Float64() >= chance {
|
||||
return nil
|
||||
}
|
||||
act := advIngredientActivities[rand.IntN(len(advIngredientActivities))]
|
||||
@@ -517,6 +521,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)
|
||||
|
||||
290
internal/plugin/exercise_prod_test.go
Normal file
290
internal/plugin/exercise_prod_test.go
Normal file
@@ -0,0 +1,290 @@
|
||||
//go:build prodexercise
|
||||
|
||||
package plugin
|
||||
|
||||
// Headless smoke-exercise of the N-series features (parties aside — those have
|
||||
// their own sim path) against a COPY of the prod DB. Nothing here touches the
|
||||
// live prod file: point GOGOBEE_PROD_DB_DIR at a directory holding a *copy* of
|
||||
// gogobee.db (+ optional -wal/-shm) and the test re-copies that into t.TempDir()
|
||||
// before opening it, so even the copy is left pristine.
|
||||
//
|
||||
// GOGOBEE_PROD_DB_DIR=/path/to/dbcopy \
|
||||
// go test -tags prodexercise -run TestExerciseNewFeaturesProd -v ./internal/plugin/
|
||||
//
|
||||
// Every outbound DM / room message a handler would have sent to Matrix is
|
||||
// captured via the existing MessageSink seam and dumped with t.Log, so the run
|
||||
// shows exactly what a player would see. Each feature is wrapped so a panic or
|
||||
// error in one is reported and the battery continues.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func TestExerciseNewFeaturesProd(t *testing.T) {
|
||||
srcDir := os.Getenv("GOGOBEE_PROD_DB_DIR")
|
||||
if srcDir == "" {
|
||||
t.Skip("set GOGOBEE_PROD_DB_DIR to a dir holding a COPY of gogobee.db")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(srcDir, "gogobee.db")); err != nil {
|
||||
t.Skipf("no gogobee.db in %s", srcDir)
|
||||
}
|
||||
|
||||
tmp := t.TempDir()
|
||||
for _, f := range []string{"gogobee.db", "gogobee.db-wal", "gogobee.db-shm"} {
|
||||
copyIfPresent(t, filepath.Join(srcDir, f), filepath.Join(tmp, f))
|
||||
}
|
||||
|
||||
db.Close()
|
||||
if err := db.Init(tmp); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
// Reproducibility: the weekly Omen keys off wall-clock time.Now(); pin it off
|
||||
// so the same DB copy exercises identically regardless of which week we run.
|
||||
simOmenDisabled = true
|
||||
simAutoArmEnabled = true
|
||||
|
||||
euro := NewEuroPlugin(nil)
|
||||
xp := NewXPPlugin(nil)
|
||||
p := NewAdventurePlugin(nil, euro, xp)
|
||||
sink := &captureSink{}
|
||||
p.Sink = sink
|
||||
mark := 0
|
||||
drain := func() {
|
||||
for _, m := range sink.msgs[mark:] {
|
||||
who := string(m.ToUser)
|
||||
if who == "" {
|
||||
who = "room:" + string(m.ToRoom)
|
||||
}
|
||||
t.Logf(" → [%s]\n%s", who, indent(m.Text))
|
||||
}
|
||||
mark = len(sink.msgs)
|
||||
}
|
||||
exercise := func(name string, fn func()) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Logf(" !! %s PANICKED: %v", name, r)
|
||||
mark = len(sink.msgs)
|
||||
}
|
||||
}()
|
||||
t.Logf("──────── %s ────────", name)
|
||||
fn()
|
||||
drain()
|
||||
}
|
||||
|
||||
chars := runnableChars(t)
|
||||
if len(chars) == 0 {
|
||||
t.Fatal("no runnable characters (dnd_character with class + adventure_characters row) in the DB copy")
|
||||
}
|
||||
t.Logf("runnable characters: %d", len(chars))
|
||||
for _, c := range chars {
|
||||
healToFull(t, c.uid)
|
||||
euro.Credit(c.uid, 5000, "exercise bankroll")
|
||||
t.Logf(" • %-28s %s L%d", c.uid, c.class, c.level)
|
||||
}
|
||||
ctxFor := func(uid id.UserID, body string) MessageContext {
|
||||
return MessageContext{Sender: uid, RoomID: id.RoomID("!exercise:sim"), EventID: "$ex", Body: body}
|
||||
}
|
||||
|
||||
// ── N7/B2 Renown & prestige ─────────────────────────────────────────────
|
||||
exercise("N7/B2 Renown — standing + prestige announce", func() {
|
||||
for _, c := range chars {
|
||||
xp, _ := loadRenownXP(c.uid)
|
||||
lvl := renownLevelForUser(c.uid)
|
||||
t.Logf(" %s: renownXP=%d renownLevel=%d rank=%q", c.uid, xp, lvl, renownRankFor(lvl))
|
||||
}
|
||||
// Push the first character up a renown level to fire the announce path.
|
||||
c0 := chars[0]
|
||||
before, after, err := addRenownXP(c0.uid, 500)
|
||||
if err != nil {
|
||||
t.Logf(" addRenownXP: %v", err)
|
||||
return
|
||||
}
|
||||
t.Logf(" %s renownXP %d→%d", c0.uid, before, after)
|
||||
p.announceRenown(c0.uid, renownLevelFor(before), renownLevelFor(after))
|
||||
})
|
||||
|
||||
// ── N6/C3 World Boss / Siege raid ───────────────────────────────────────
|
||||
exercise("N6/C3 World Boss — spawn, status, each fighter's bout", func() {
|
||||
boss, err := p.spawnWorldBoss("exercise-siege")
|
||||
if err != nil {
|
||||
t.Logf(" spawnWorldBoss: %v", err)
|
||||
}
|
||||
if boss != nil {
|
||||
t.Logf(" spawned %q tier=%d hp=%d", boss.Name, boss.Tier, boss.HPMax)
|
||||
}
|
||||
p.handleWorldBossCmd(ctxFor(chars[0].uid, ""), "status")
|
||||
for _, c := range chars {
|
||||
t.Logf(" -- %s takes a bout --", c.uid)
|
||||
p.handleWorldBossCmd(ctxFor(c.uid, ""), "fight")
|
||||
}
|
||||
t.Logf(" -- pool after the raid --")
|
||||
p.handleWorldBossCmd(ctxFor(chars[0].uid, ""), "status")
|
||||
})
|
||||
|
||||
// ── N6/C2 Duel (needs two eligible players) ─────────────────────────────
|
||||
exercise("N6/C2 Duel — challenge, accept, resolve", func() {
|
||||
if len(chars) < 2 {
|
||||
t.Log(" need 2 characters for a duel; skipping")
|
||||
return
|
||||
}
|
||||
a, b := chars[0], chars[1]
|
||||
p.handleDuelCmd(ctxFor(a.uid, ""), fmt.Sprintf("%s 100", b.uid))
|
||||
p.handleDuelCmd(ctxFor(b.uid, ""), "accept")
|
||||
})
|
||||
|
||||
// ── N6/D3 The Shadow ────────────────────────────────────────────────────
|
||||
exercise("N6/D3 Shadow — standing vs the rival", func() {
|
||||
for _, c := range chars {
|
||||
p.handleShadowCmd(ctxFor(c.uid, ""))
|
||||
}
|
||||
})
|
||||
|
||||
// ── N5/D1a Campaign journal ─────────────────────────────────────────────
|
||||
exercise("N5/D1a Journal — collected campaign pages", func() {
|
||||
for _, c := range chars {
|
||||
p.handleJournalCmd(ctxFor(c.uid, ""))
|
||||
}
|
||||
})
|
||||
|
||||
// ── N4/E3 Town registries ───────────────────────────────────────────────
|
||||
exercise("N4/E3 Town — !town / !graveyard / !rivals", func() {
|
||||
p.handleTownCmd(ctxFor(chars[0].uid, ""))
|
||||
p.handleGraveyardCmd(ctxFor(chars[0].uid, ""))
|
||||
p.handleRivalsCmd(ctxFor(chars[0].uid, ""))
|
||||
p.handleRivalsTopCmd(ctxFor(chars[0].uid, ""), "")
|
||||
})
|
||||
|
||||
// ── N4/E1 Estate vault ──────────────────────────────────────────────────
|
||||
exercise("N4/E1 Vault — list (gated on T4 estate)", func() {
|
||||
for _, c := range chars {
|
||||
p.handleVaultCmd(ctxFor(c.uid, ""), "")
|
||||
}
|
||||
})
|
||||
|
||||
// ── N4/E2 Item gifting ──────────────────────────────────────────────────
|
||||
exercise("N4/E2 Gifting — !give <item> @user", func() {
|
||||
if len(chars) < 2 {
|
||||
t.Log(" need 2 characters to gift; skipping")
|
||||
return
|
||||
}
|
||||
giver, receiver := chars[0], chars[1]
|
||||
item := firstInventoryItem(giver.uid)
|
||||
if item == "" {
|
||||
t.Logf(" %s has no inventory items to gift", giver.uid)
|
||||
return
|
||||
}
|
||||
body := fmt.Sprintf("!give %s %s", item, receiver.uid)
|
||||
t.Logf(" %s gifts %q to %s", giver.uid, item, receiver.uid)
|
||||
p.handleGiveCmd(ctxFor(giver.uid, body))
|
||||
})
|
||||
|
||||
// ── N7/B4 Achievements wing (best-effort; needs a registry) ─────────────
|
||||
exercise("N7/B4 Achievements — !achievements", func() {
|
||||
ach := NewAchievementsPlugin(nil, nil)
|
||||
ach.Sink = sink
|
||||
p.SetAchievements(ach)
|
||||
for _, c := range chars {
|
||||
ach.handleAchievements(ctxFor(c.uid, ""))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
type exChar struct {
|
||||
uid id.UserID
|
||||
class string
|
||||
level int
|
||||
}
|
||||
|
||||
// runnableChars lists characters that have both a completed dnd_character (class
|
||||
// set) and the legacy adventure_characters row the expedition/feature paths load.
|
||||
func runnableChars(t *testing.T) []exChar {
|
||||
t.Helper()
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`
|
||||
SELECT d.user_id, d.class, d.dnd_level
|
||||
FROM dnd_character d
|
||||
JOIN adventure_characters a ON a.user_id = d.user_id
|
||||
WHERE d.class != ''`)
|
||||
if err != nil {
|
||||
t.Fatalf("query chars: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []exChar
|
||||
for rows.Next() {
|
||||
var c exChar
|
||||
var uid string
|
||||
if err := rows.Scan(&uid, &c.class, &c.level); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.uid = id.UserID(uid)
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].level > out[j].level })
|
||||
return out
|
||||
}
|
||||
|
||||
func healToFull(t *testing.T, uid id.UserID) {
|
||||
t.Helper()
|
||||
c, err := LoadDnDCharacter(uid)
|
||||
if err != nil || c == nil {
|
||||
t.Logf(" healToFull: load %s: %v", uid, err)
|
||||
return
|
||||
}
|
||||
c.HPCurrent = c.HPMax
|
||||
c.TempHP = 0
|
||||
c.Exhaustion = 0
|
||||
c.ShortRestCharges = c.Level
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Logf(" healToFull: save %s: %v", uid, err)
|
||||
}
|
||||
}
|
||||
|
||||
func firstInventoryItem(uid id.UserID) string {
|
||||
d := db.Get()
|
||||
var name string
|
||||
// adventure_inventory holds the player's consumables/misc, one row per item.
|
||||
_ = d.QueryRow(
|
||||
`SELECT name FROM adventure_inventory WHERE user_id=? LIMIT 1`,
|
||||
string(uid),
|
||||
).Scan(&name)
|
||||
return name
|
||||
}
|
||||
|
||||
func copyIfPresent(t *testing.T, src, dst string) {
|
||||
t.Helper()
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return // wal/shm may be absent — fine
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func indent(s string) string {
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
for i, l := range lines {
|
||||
lines[i] = " " + l
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -230,7 +230,7 @@ func ambientEventMonologue() ambientEvent {
|
||||
// it's cheap to test in isolation and free of package-level state.
|
||||
func ambientEvents() []ambientEvent {
|
||||
always := func(*Expedition) bool { return true }
|
||||
return []ambientEvent{
|
||||
events := []ambientEvent{
|
||||
ambientEventMonologue(),
|
||||
{
|
||||
Kind: "small_find",
|
||||
@@ -280,6 +280,19 @@ func ambientEvents() []ambientEvent {
|
||||
},
|
||||
},
|
||||
}
|
||||
// N7/E4 — a live season adds a themed road visitor. Non-combat: it leaves a
|
||||
// keepsake and a coin gift, resolved in applyAmbientEffect. The pool and
|
||||
// reward come from activeSeason(); the ambient seam is production-only, so
|
||||
// this never reaches the balance sim.
|
||||
if s, ok := activeSeason(); ok && len(s.Visitor.FlavorPool) > 0 {
|
||||
events = append(events, ambientEvent{
|
||||
Kind: "season_visitor",
|
||||
Pool: s.Visitor.FlavorPool,
|
||||
Weight: s.Visitor.Weight,
|
||||
Eligible: func(*Expedition) bool { return true },
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// pickAmbientEvent runs a weighted pick over eligible events. avoidKind
|
||||
@@ -395,6 +408,39 @@ func (p *AdventurePlugin) applyAmbientEffect(e *Expedition, ev ambientEvent) str
|
||||
return ""
|
||||
}
|
||||
return "+1 HP"
|
||||
case "season_visitor":
|
||||
// N7/E4 — the visitor leaves a sellable keepsake and a coin gift. Re-read
|
||||
// the season so the reward matches the current window even if it turned
|
||||
// over between pick and apply; bail quietly if it just ended.
|
||||
s, ok := activeSeason()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
v := s.Visitor
|
||||
uid := id.UserID(e.UserID)
|
||||
if err := addAdvInventoryItem(uid, AdvItem{
|
||||
Name: v.Keepsake,
|
||||
Type: "trophy",
|
||||
Tier: 1,
|
||||
Value: v.KeepsakeValue,
|
||||
}); err != nil {
|
||||
slog.Warn("expedition: ambient season keepsake", "user", uid, "err", err)
|
||||
return ""
|
||||
}
|
||||
if v.Coins > 0 {
|
||||
if p.euro != nil {
|
||||
p.euro.Credit(uid, float64(v.Coins), "expedition ambient: "+s.Key+" visitor")
|
||||
}
|
||||
if _, err := db.Get().Exec(`
|
||||
UPDATE dnd_expedition
|
||||
SET coins_earned = coins_earned + ?,
|
||||
last_activity = CURRENT_TIMESTAMP
|
||||
WHERE expedition_id = ?`, v.Coins, e.ID); err != nil {
|
||||
slog.Warn("expedition: ambient season coin tally", "expedition", e.ID, "err", err)
|
||||
}
|
||||
return fmt.Sprintf("You keep the %s. +%d coins", v.Keepsake, v.Coins)
|
||||
}
|
||||
return fmt.Sprintf("You keep the %s.", v.Keepsake)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -49,6 +49,10 @@ func NewSimRunner(dataDir string) (*SimRunner, error) {
|
||||
// etc. fire the way a competent prod player would set them up.
|
||||
// Without this the sim under-counts class survival.
|
||||
simAutoArmEnabled = true
|
||||
// N7/B3 — neutralize the weekly Omen so corpus results don't depend on which
|
||||
// wall-clock week the sweep runs in (the sim's synthetic clock never reaches
|
||||
// activeOmen's time.Now()).
|
||||
simOmenDisabled = true
|
||||
euro := &EuroPlugin{}
|
||||
p := &AdventurePlugin{euro: euro}
|
||||
return &SimRunner{P: p, Euro: euro}, nil
|
||||
@@ -127,6 +131,38 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// PrepareRealCharacter readies an already-persisted character (loaded from a
|
||||
// copy of the prod DB) for a headless run. Unlike BuildCharacter it fabricates
|
||||
// nothing: the character keeps its real race/class/subclass/level, ability
|
||||
// scores, equipment, spellbook and inventory. It only (a) heals to full — a
|
||||
// player rests before heading out, so we don't handicap a wounded prod snapshot
|
||||
// — and (b) tops the bankroll up to `bank` so the "heavy" supply preset always
|
||||
// affords itself regardless of the player's live coin balance. Returns the
|
||||
// loaded character. The DB copy is disposable; the live prod file is untouched.
|
||||
func (s *SimRunner) PrepareRealCharacter(uid id.UserID, bank float64) (*DnDCharacter, error) {
|
||||
c, err := LoadDnDCharacter(uid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LoadDnDCharacter: %w", err)
|
||||
}
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("no character for %s in db copy", uid)
|
||||
}
|
||||
if c.Class == "" {
|
||||
return nil, fmt.Errorf("%s has no class (setup incomplete) — nothing to simulate", uid)
|
||||
}
|
||||
c.HPCurrent = c.HPMax
|
||||
c.TempHP = 0
|
||||
c.Exhaustion = 0
|
||||
c.ShortRestCharges = c.Level
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
return nil, fmt.Errorf("SaveDnDCharacter: %w", err)
|
||||
}
|
||||
if bal := s.Euro.GetBalance(uid); bal < bank {
|
||||
s.Euro.Credit(uid, bank-bal, "expedition-sim real-char bankroll top-up")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// stockSimConsumables drops a small tier-appropriate bundle of potions
|
||||
// + a couple offensive items into the synthetic player's inventory so
|
||||
// SelectConsumables / setupAutoHealFromInventory have something to fire
|
||||
|
||||
@@ -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,15 @@ 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 xp, err := loadRenownXP(uid); err == nil {
|
||||
c.RenownXP = xp
|
||||
}
|
||||
if s, err := loadHouseState(uid); err == nil {
|
||||
c.HouseTier = s.Tier
|
||||
c.HouseLoanBalance = s.LoanBalance
|
||||
|
||||
@@ -638,6 +638,14 @@ func (b *Base) SendNotice(roomID id.RoomID, text string) error {
|
||||
|
||||
// SendReply sends a reply to a specific event.
|
||||
func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error {
|
||||
// The sink is the headless capture seam: a reply is an outbound room
|
||||
// message, so — like SendMessage/SendDM — it diverts here when installed and
|
||||
// the live client is never touched. Without this branch, reply-only handlers
|
||||
// (duels, !town, !rivals, !achievements) hit a nil client under the sink.
|
||||
if b != nil && b.Sink != nil {
|
||||
_, err := b.Sink.Capture(outboundMessage{ToRoom: roomID, Text: text})
|
||||
return err
|
||||
}
|
||||
content := textContent(text)
|
||||
if eventID != "" {
|
||||
content.RelatesTo = &event.RelatesTo{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user