mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Compare commits
1 Commits
fix-automi
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b4e6833b0 |
@@ -46,8 +46,6 @@ 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")
|
||||
@@ -92,49 +90,9 @@ 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
|
||||
|
||||
4
go.mod
4
go.mod
@@ -15,7 +15,7 @@ require (
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
golang.org/x/image v0.40.0
|
||||
maunium.net/go/mautrix v0.28.1
|
||||
modernc.org/sqlite v1.50.1
|
||||
modernc.org/sqlite v1.53.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -43,7 +43,7 @@ require (
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/libc v1.73.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
20
go.sum
20
go.sum
@@ -166,20 +166,20 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maunium.net/go/mautrix v0.28.1 h1:Hic3oDMPbLbQu1fhboTRAKZcORMjzzkjxsa+SGk60b0=
|
||||
maunium.net/go/mautrix v0.28.1/go.mod h1:mWXQNmOlrq4VTDU9f1HO03BSIswdUIyyY4wUKHqwzzY=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -188,8 +188,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -357,50 +357,6 @@ func runMigrations(d *sql.DB) error {
|
||||
// row and no bootstrap backfill is needed. The column is a read guard:
|
||||
// loadCombatParticipants is skipped entirely when it reads 1.
|
||||
`ALTER TABLE combat_session ADD COLUMN roster_size INTEGER NOT NULL DEFAULT 1`,
|
||||
// N4/E1 housing vault (gogobee_engagement_plan.md §E1). A Tier-4 Estate
|
||||
// unlocks a 10-slot vault that shelters items from sale/use. Rather than a
|
||||
// parallel table, a stowed item keeps its identity (id, temper, everything)
|
||||
// and flips in_vault=1; loadAdvInventory filters it back out, so a vaulted
|
||||
// item drops out of sell/craft/combat readers as one flag change. DEFAULT 0
|
||||
// = "in play", correct for every pre-existing row, so no bootstrap backfill.
|
||||
`ALTER TABLE adventure_inventory ADD COLUMN in_vault INTEGER NOT NULL DEFAULT 0`,
|
||||
// N4/E1 second pet slot (gogobee_engagement_plan.md §E1). A Tier-4 Estate
|
||||
// unlocks a second companion. It lives in a parallel pet2_* column set
|
||||
// rather than a rows table so the single-pet path (and its combat golden)
|
||||
// is untouched: absent pet2_type == "no second pet", DEFAULT '' is correct
|
||||
// for every pre-existing row, so no bootstrap backfill. Pet 2 carries only
|
||||
// what it needs — identity, level, barding — and deliberately skips the
|
||||
// supply-shop unlock (a pet-1 mechanic) and morning-defense flag.
|
||||
`ALTER TABLE player_meta ADD COLUMN pet2_type TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet2_name TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet2_xp INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet2_level INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet2_armor_tier INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet2_flags_json TEXT NOT NULL DEFAULT '{}'`,
|
||||
`ALTER TABLE player_meta ADD COLUMN pet2_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 {
|
||||
@@ -1420,21 +1376,6 @@ CREATE TABLE IF NOT EXISTS adventure_activity_log (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_adv_log_user ON adventure_activity_log(user_id, logged_at);
|
||||
|
||||
-- N4/E2 item gifting. One row per gift; the sender's row count for the current
|
||||
-- UTC day enforces the daily cap (twink-funnel guard), and the full log is an
|
||||
-- audit trail. gift_day is the UTC date string the cap counts against, kept
|
||||
-- alongside given_at so the count is a plain equality match, not a range scan.
|
||||
CREATE TABLE IF NOT EXISTS adventure_gift_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sender TEXT NOT NULL,
|
||||
recipient TEXT NOT NULL,
|
||||
item_name TEXT NOT NULL,
|
||||
value INTEGER NOT NULL DEFAULT 0,
|
||||
gift_day TEXT NOT NULL,
|
||||
given_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_adv_gift_sender_day ON adventure_gift_log(sender, gift_day);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS adventure_treasures (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -1618,20 +1559,6 @@ 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,
|
||||
@@ -2237,73 +2164,6 @@ 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,39 +1206,9 @@ 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,7 +169,6 @@ 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"},
|
||||
@@ -180,10 +179,6 @@ func (p *AdventurePlugin) Commands() []CommandDef {
|
||||
{Name: "sell", Description: "Sell your hauled materials, fish, and items to Thom Krooke after an expedition (a smooth pitch can land a better price)", Usage: "!sell [list|all|<item>]", Category: "Games"},
|
||||
{Name: "craft", Description: "Craft a discovered recipe at Thom Krooke (consumes ingredients)", Usage: "!craft [list|<recipe>]", Category: "Games"},
|
||||
{Name: "lore", Description: "Dig through Thom Krooke's lore stacks for a new recipe (sharp minds turn up more)", Usage: "!lore", Category: "Games"},
|
||||
{Name: "give", Description: "Gift a consumable or non-magic item to another adventurer (5% handling fee to the community pot; 3/day)", Usage: "!give <item> @user", Category: "Games"},
|
||||
{Name: "town", Description: "Town registry — civic pride, housing street, and the pet showcase", Usage: "!town", Category: "Games"},
|
||||
{Name: "graveyard", Description: "Recent deaths across the guild", Usage: "!graveyard", Category: "Games"},
|
||||
{Name: "rivals", Description: "Your rival duel record, or `!rivals board` for room-wide standings", Usage: "!rivals [board]", Category: "Games"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,13 +270,9 @@ func (p *AdventurePlugin) Init() error {
|
||||
go p.expeditionRecapTicker()
|
||||
go p.expeditionAmbientTicker()
|
||||
go p.expeditionAutoRunTicker()
|
||||
go p.expeditionExtractionSweepTicker()
|
||||
|
||||
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
||||
p.arenaCleanupStaleRuns()
|
||||
// Extractions that lapsed while the bot was down: the roster they hold is
|
||||
// blocking those members right now, not an hour from now.
|
||||
p.sweepLapsedExtractions(time.Now().UTC())
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -338,9 +329,6 @@ 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"))
|
||||
}
|
||||
@@ -424,20 +412,6 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "lore") {
|
||||
return p.handleLoreCmd(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "give") {
|
||||
return p.handleGiveCmd(ctx)
|
||||
}
|
||||
|
||||
// 0b. Town registries (E3) — read-only social boards (rooms and DMs)
|
||||
if p.IsCommand(ctx.Body, "town") {
|
||||
return p.handleTownCmd(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "graveyard") {
|
||||
return p.handleGraveyardCmd(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "rivals") {
|
||||
return p.handleRivalsTopCmd(ctx, p.GetArgs(ctx.Body, "rivals"))
|
||||
}
|
||||
|
||||
// 1. Arena commands (work in rooms and DMs)
|
||||
if p.IsCommand(ctx.Body, "bail") {
|
||||
@@ -517,8 +491,6 @@ 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":
|
||||
@@ -537,16 +509,6 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
||||
return p.handleMasteryCmd(ctx)
|
||||
case lower == "treasures" || strings.HasPrefix(lower, "treasures "):
|
||||
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.")
|
||||
@@ -564,11 +526,9 @@ const advHelpText = `**Adventure Commands**
|
||||
` + "`!adventure equip-magic`" + ` — Equip magic items (curios) into your D&D slots
|
||||
` + "`!adventure sell <item>`" + ` — Sell an inventory item (or ` + "`sell all`" + `)
|
||||
` + "`!adventure inventory`" + ` — View your inventory
|
||||
` + "`!adventure vault`" + ` — Store items safely (Tier-4 Established home; ` + "`vault store/take <item>`" + `)
|
||||
` + "`!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)
|
||||
@@ -579,9 +539,6 @@ 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
|
||||
@@ -646,7 +603,6 @@ 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)
|
||||
@@ -797,7 +753,6 @@ func (p *AdventurePlugin) handleLeaderboard(ctx MessageContext) error {
|
||||
ForagingSkill: c.ForagingSkill,
|
||||
FishingSkill: c.FishingSkill,
|
||||
CurrentStreak: c.CurrentStreak,
|
||||
Renown: renownLevelForUser(c.UserID),
|
||||
})
|
||||
}
|
||||
text := renderAdvLeaderboard(entries)
|
||||
@@ -935,9 +890,9 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
|
||||
case "npc_encounter":
|
||||
return p.resolveNPCEncounter(ctx, interaction)
|
||||
case "pet_arrival":
|
||||
return p.resolvePetArrival(ctx, interaction)
|
||||
return p.resolvePetArrival(ctx)
|
||||
case "pet_type":
|
||||
return p.resolvePetType(ctx, interaction)
|
||||
return p.resolvePetType(ctx)
|
||||
case "pet_name":
|
||||
return p.resolvePetName(ctx, interaction)
|
||||
}
|
||||
@@ -1167,7 +1122,7 @@ func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCha
|
||||
return
|
||||
}
|
||||
|
||||
if count < maxTreasuresForTier(char.HouseTier) {
|
||||
if count < advMaxTreasures {
|
||||
// Directly save
|
||||
if err := advSaveTreasure(userID, drop.Def); err != nil {
|
||||
slog.Error("adventure: failed to save treasure", "user", userID, "err", err)
|
||||
@@ -1552,7 +1507,7 @@ func (p *AdventurePlugin) handleRecipesCmd(ctx MessageContext) error {
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, renderRecipesKnown(char.ForagingSkill, homeWorkshopCraftBonus(char.HouseTier)))
|
||||
return p.SendDM(ctx.Sender, renderRecipesKnown(char.ForagingSkill))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleTreasuresCmd(ctx MessageContext, arg string) error {
|
||||
|
||||
@@ -531,12 +531,6 @@ 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
|
||||
|
||||
@@ -36,15 +36,22 @@ func (p *AdventurePlugin) runBabysitDailyTrickle(char *AdventureCharacter) {
|
||||
if !char.BabysitActive {
|
||||
return
|
||||
}
|
||||
// Both companions share the sitter's attention and gain the flat trickle.
|
||||
// (Combat only ever reads their *averaged* procs, so leveling both is not a
|
||||
// power spike.)
|
||||
leveled := false
|
||||
if char.HasPet() {
|
||||
leveled = advancePetLevelsFromXP(&char.PetXP, &char.PetLevel, &char.PetLevel10Date, petXPPerBabysitDay*100) || leveled
|
||||
}
|
||||
if char.HasPet2() {
|
||||
leveled = advancePetLevelsFromXP(&char.Pet2XP, &char.Pet2Level, &char.Pet2Level10Date, petXPPerBabysitDay*100) || leveled
|
||||
if char.HasPet() && char.PetLevel < 10 {
|
||||
// Bypass petGrantXP's per-action constant — we want a flat trickle.
|
||||
char.PetXP += petXPPerBabysitDay * 100
|
||||
for char.PetLevel < 10 {
|
||||
needed := petXPToNextLevel(char.PetLevel) * 100
|
||||
if char.PetXP < needed {
|
||||
break
|
||||
}
|
||||
char.PetXP -= needed
|
||||
char.PetLevel++
|
||||
leveled = true
|
||||
}
|
||||
if char.PetLevel >= 10 && char.PetLevel10Date == "" {
|
||||
char.PetLevel10Date = time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
outcome := "pet_care"
|
||||
if leveled {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
@@ -95,44 +92,13 @@ type AdventureCharacter struct {
|
||||
PetSupplyShopUnlocked bool
|
||||
PetLevel10Date string
|
||||
PetMorningDefense bool
|
||||
// Second pet (N4/E1, Tier-4 Estate). A parallel slot, not a rewrite of the
|
||||
// pet-1 fields above — see HasPet2. Carries only identity/level/barding; the
|
||||
// morning-defense, ditch-recovery and supply-shop mechanics stay pet-1-only.
|
||||
Pet2Type string
|
||||
Pet2Name string
|
||||
Pet2XP int
|
||||
Pet2Level int
|
||||
Pet2ArmorTier int
|
||||
Pet2ChasedAway bool
|
||||
Pet2Reactivated bool
|
||||
Pet2Arrived bool
|
||||
Pet2Level10Date string
|
||||
AutoBabysit bool
|
||||
AutoBabysitFocus string // mining|fishing|foraging — preferred skill for auto-babysit; "" defaults to weakest
|
||||
TreasuresLocked bool // when true, treasure drops at cap are refused instead of auto-swapping
|
||||
StreakDecayed bool
|
||||
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)
|
||||
AutoBabysit bool
|
||||
AutoBabysitFocus string // mining|fishing|foraging — preferred skill for auto-babysit; "" defaults to weakest
|
||||
TreasuresLocked bool // when true, treasure drops at cap are refused instead of auto-swapping
|
||||
StreakDecayed bool
|
||||
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
|
||||
}
|
||||
|
||||
type AdvEquipment struct {
|
||||
@@ -304,11 +270,6 @@ func (c *AdventureCharacter) HasPet() bool {
|
||||
return c.PetType != "" && c.PetArrived && !c.PetChasedAway
|
||||
}
|
||||
|
||||
// HasPet2 returns true if the player has an active second pet.
|
||||
func (c *AdventureCharacter) HasPet2() bool {
|
||||
return c.Pet2Type != "" && c.Pet2Arrived && !c.Pet2ChasedAway
|
||||
}
|
||||
|
||||
// HasHouse returns true if the player has purchased at least a base house.
|
||||
func (c *AdventureCharacter) HasHouse() bool {
|
||||
return c.HouseTier > 0 || c.HouseLoanBalance > 0
|
||||
@@ -539,41 +500,6 @@ func createAdvCharacter(userID id.UserID, displayName string) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ensurePlayerMetaSeed guarantees the canonical player_meta seed row (and tier-0
|
||||
// equipment) exists for userID, creating it only when absent. The auto-migration
|
||||
// path (ensureDnDCharacterForCombat) writes a confirmed dnd_character without
|
||||
// touching the legacy layer; without this, a brand-new player whose first-ever
|
||||
// action auto-migrates — e.g. !rest or !cast before !setup — ends up with a
|
||||
// player_meta-less character that fails every legacy-layer command with
|
||||
// "sql: no rows" (the camcast straggler). Conditional on absence and thus
|
||||
// idempotent: legacy players who already have player_meta keep their equipment
|
||||
// untouched — createAdvCharacter's tier-0 equipment insert has no conflict guard
|
||||
// and would otherwise duplicate their gear.
|
||||
func ensurePlayerMetaSeed(userID id.UserID) error {
|
||||
d := db.Get()
|
||||
var one int
|
||||
err := d.QueryRow(`SELECT 1 FROM player_meta WHERE user_id = ?`, string(userID)).Scan(&one)
|
||||
if err == nil {
|
||||
return nil // already seeded
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
return createAdvCharacter(userID, localpartOf(userID))
|
||||
}
|
||||
|
||||
// localpartOf returns the mxid localpart (between @ and :) as a display-name
|
||||
// fallback — matches Base.DisplayName's offline behavior. The seed's display
|
||||
// name is overlaid by later player_meta upserts; this is just a sane default
|
||||
// for a character born without a Matrix client in reach.
|
||||
func localpartOf(userID id.UserID) string {
|
||||
s := string(userID)
|
||||
if i := strings.Index(s, ":"); i > 0 {
|
||||
return s[1:i]
|
||||
}
|
||||
return strings.TrimPrefix(s, "@")
|
||||
}
|
||||
|
||||
// saveAdvCharacter persists every mutable AdventureCharacter field to
|
||||
// player_meta. Phase L5h: the legacy adventure_characters UPDATE has been
|
||||
// retired — the row is now read-only after createAdvCharacter seeds it,
|
||||
@@ -636,7 +562,7 @@ func loadAdvInventory(userID id.UserID) ([]AdvItem, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`
|
||||
SELECT id, name, item_type, tier, value, slot, skill_source, temper
|
||||
FROM adventure_inventory WHERE user_id = ? AND in_vault = 0
|
||||
FROM adventure_inventory WHERE user_id = ?
|
||||
ORDER BY tier DESC, value DESC`, string(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -687,10 +613,7 @@ func clearAdvInventory(userID id.UserID) ([]AdvItem, error) {
|
||||
return nil, err
|
||||
}
|
||||
d := db.Get()
|
||||
// Delete only what loadAdvInventory returned — vaulted rows are out of play
|
||||
// and must survive a bulk clear, or the vault's whole promise (safe from
|
||||
// !sell all) breaks the moment any caller routes sell-all through here.
|
||||
_, err = d.Exec(`DELETE FROM adventure_inventory WHERE user_id = ? AND in_vault = 0`, string(userID))
|
||||
_, err = d.Exec(`DELETE FROM adventure_inventory WHERE user_id = ?`, string(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -700,62 +623,10 @@ func clearAdvInventory(userID id.UserID) ([]AdvItem, error) {
|
||||
func advInventoryCount(userID id.UserID) int {
|
||||
d := db.Get()
|
||||
var count int
|
||||
_ = d.QueryRow(`SELECT COUNT(*) FROM adventure_inventory WHERE user_id = ? AND in_vault = 0`, string(userID)).Scan(&count)
|
||||
_ = d.QueryRow(`SELECT COUNT(*) FROM adventure_inventory WHERE user_id = ?`, string(userID)).Scan(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// loadAdvVault returns the items the player has stowed in their housing vault
|
||||
// (N4/E1). These are excluded from loadAdvInventory — a vaulted item is out of
|
||||
// play (unsellable, uncraftable, unusable in combat) until it is taken back.
|
||||
func loadAdvVault(userID id.UserID) ([]AdvItem, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`
|
||||
SELECT id, name, item_type, tier, value, slot, skill_source, temper
|
||||
FROM adventure_inventory WHERE user_id = ? AND in_vault = 1
|
||||
ORDER BY tier DESC, value DESC`, string(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []AdvItem
|
||||
for rows.Next() {
|
||||
var it AdvItem
|
||||
var slot string
|
||||
if err := rows.Scan(&it.ID, &it.Name, &it.Type, &it.Tier, &it.Value, &slot, &it.SkillSource, &it.Temper); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.Slot = EquipmentSlot(slot)
|
||||
items = append(items, it)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// advVaultCount returns how many of a user's items are stowed in the vault.
|
||||
func advVaultCount(userID id.UserID) int {
|
||||
d := db.Get()
|
||||
var count int
|
||||
_ = d.QueryRow(`SELECT COUNT(*) FROM adventure_inventory WHERE user_id = ? AND in_vault = 1`, string(userID)).Scan(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// setItemVaulted flips a single inventory row's vault flag. Scoped to the
|
||||
// owning user so a mistyped id can never move another player's item.
|
||||
func setItemVaulted(userID id.UserID, itemID int64, vaulted bool) (bool, error) {
|
||||
v := 0
|
||||
if vaulted {
|
||||
v = 1
|
||||
}
|
||||
res, err := db.Get().Exec(
|
||||
`UPDATE adventure_inventory SET in_vault = ? WHERE id = ? AND user_id = ?`,
|
||||
v, itemID, string(userID))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
func loadAllAdvCharacters() ([]AdventureCharacter, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT user_id FROM player_meta`)
|
||||
|
||||
@@ -346,34 +346,15 @@ var craftingRecipes = []CraftingRecipe{
|
||||
{Result: "Voidstone Shard", Ingredients: []string{"Voidstone", "Mythril Ore"}, MinForaging: 30, Tier: 5},
|
||||
}
|
||||
|
||||
// houseTierWorkshop is the T3 "Comfortable" house that unlocks the workshop —
|
||||
// a flat craft-success bonus while crafting at home.
|
||||
const houseTierWorkshop = 3
|
||||
|
||||
// homeWorkshopCraftBonus returns the additive craft-success bonus from a T3+
|
||||
// home workshop. Tier 1/2 and no house grant none.
|
||||
func homeWorkshopCraftBonus(houseTier int) float64 {
|
||||
if houseTier >= houseTierWorkshop {
|
||||
return 0.05
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// craftingSuccessRate returns the success chance for a recipe given foraging
|
||||
// level. Base rate is 50% at minimum level, +3% per 5 levels above minimum,
|
||||
// capped at 95%. A T3+ home workshop adds workshopBonus on top and lifts the
|
||||
// cap to 98%, so a maxed forager still gains a little from the bench.
|
||||
func craftingSuccessRate(foragingLevel, minForaging int, workshopBonus float64) float64 {
|
||||
// craftingSuccessRate returns the success chance for a recipe given foraging level.
|
||||
// Base rate is 50% at minimum level, +3% per 5 levels above minimum, capped at 95%.
|
||||
func craftingSuccessRate(foragingLevel, minForaging int) float64 {
|
||||
base := 0.50
|
||||
levelsAbove := foragingLevel - minForaging
|
||||
bonus := float64(levelsAbove) / 5.0 * 0.03
|
||||
rate := base + bonus + workshopBonus
|
||||
cap := 0.95
|
||||
if workshopBonus > 0 {
|
||||
cap = 0.98
|
||||
}
|
||||
if rate > cap {
|
||||
return cap
|
||||
rate := base + bonus
|
||||
if rate > 0.95 {
|
||||
return 0.95
|
||||
}
|
||||
return rate
|
||||
}
|
||||
@@ -388,7 +369,7 @@ type CraftResult struct {
|
||||
// their current foraging level, plus a teaser line for the next unlock
|
||||
// threshold. Hides exact ingredient lists for recipes the player hasn't
|
||||
// unlocked — only the count of locked recipes is shown.
|
||||
func renderRecipesKnown(foragingLevel int, workshopBonus float64) string {
|
||||
func renderRecipesKnown(foragingLevel int) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🧪 **Crafting Recipes** — Foraging Lv.%d\n\n", foragingLevel))
|
||||
|
||||
@@ -396,9 +377,6 @@ func renderRecipesKnown(foragingLevel int, workshopBonus float64) string {
|
||||
sb.WriteString("_Auto-crafting unlocks at Foraging Lv.10. Keep gathering._")
|
||||
return sb.String()
|
||||
}
|
||||
if workshopBonus > 0 {
|
||||
sb.WriteString(fmt.Sprintf("_🔨 Home workshop: +%.0f%% craft success._\n\n", workshopBonus*100))
|
||||
}
|
||||
|
||||
// Group recipes by tier, list those known.
|
||||
known := map[int][]CraftingRecipe{}
|
||||
@@ -424,7 +402,7 @@ func renderRecipesKnown(foragingLevel int, workshopBonus float64) string {
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("**Tier %d** (Foraging %d+)\n", tier, recipes[0].MinForaging))
|
||||
for _, r := range recipes {
|
||||
rate := craftingSuccessRate(foragingLevel, r.MinForaging, workshopBonus) * 100
|
||||
rate := craftingSuccessRate(foragingLevel, r.MinForaging) * 100
|
||||
sb.WriteString(fmt.Sprintf(" • %s — %s (%.0f%% success)\n",
|
||||
r.Result, strings.Join(r.Ingredients, " + "), rate))
|
||||
}
|
||||
@@ -455,7 +433,7 @@ var craftXPFailure = map[int]int{1: 3, 2: 5, 3: 8, 4: 12, 5: 18}
|
||||
//
|
||||
// Side effects: grants foraging XP per attempt (success > failure) and bumps
|
||||
// the player's CraftsSucceeded counter on each success.
|
||||
func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int, workshopBonus float64) ([]CraftResult, []AdvItem) {
|
||||
func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int) ([]CraftResult, []AdvItem) {
|
||||
if foragingLevel < 10 {
|
||||
return nil, items
|
||||
}
|
||||
@@ -474,7 +452,7 @@ func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int,
|
||||
break
|
||||
}
|
||||
|
||||
rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging, workshopBonus)
|
||||
rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging)
|
||||
success := rand.Float64() < rate
|
||||
|
||||
if success {
|
||||
|
||||
@@ -1,701 +0,0 @@
|
||||
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))]
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -32,23 +32,10 @@ type advActiveEvent struct {
|
||||
//
|
||||
// The per-anchor chances below put a player who hits all three at ~1 event
|
||||
// per week; see TestAnchoredEventWeeklyRate.
|
||||
//
|
||||
// K — the three social/economic anchors miss the pure grind loop: a player who
|
||||
// only mines/forages/fishes (now automatic, done by walking) and clears
|
||||
// single-day dungeons, but never sells, never enters the arena, and never runs
|
||||
// a multi-day expedition to a Night camp, would roll for zero events. The
|
||||
// fourth anchor, ZoneClear, fires on a foreground single-day zone clear — the
|
||||
// climax DM that player *is* reading — so they get the same rough cadence.
|
||||
// Autopilot walks and mid-zone region clears deliberately do not fire it (see
|
||||
// zoneCmdAdvance / the full-clear branch of advanceOnceWithOpts). The per-day
|
||||
// slot still caps everyone at one event, so a player who happens to hit two
|
||||
// anchors in a day can't double up. This rate is the tuning knob for item K —
|
||||
// bump it if telemetry shows the grind-loop player still sees too few.
|
||||
const (
|
||||
advEventChanceDigest = 0.08
|
||||
advEventChanceSell = 0.05
|
||||
advEventChanceArena = 0.05
|
||||
advEventChanceZoneClear = 0.08
|
||||
advEventChanceDigest = 0.08
|
||||
advEventChanceSell = 0.05
|
||||
advEventChanceArena = 0.05
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -120,14 +107,6 @@ 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,43 +44,6 @@ func TestAnchoredEventWeeklyRate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestZoneClearAnchorRate pins item K's fourth anchor for the population the
|
||||
// other three miss: a grind-loop player who never sells, never enters the
|
||||
// arena, and never runs a multi-day expedition, but clears single-day zones.
|
||||
// Modelled at two foreground clears per active day (a plausible dungeon
|
||||
// session) they should land in the same ~1 event/week band as the fully-
|
||||
// engaged player — that is the whole point of adding the anchor. The clears/day
|
||||
// assumption and advEventChanceZoneClear are the two tuning knobs; see the K
|
||||
// note in adventure_events.go.
|
||||
func TestZoneClearAnchorRate(t *testing.T) {
|
||||
const clearsPerDay = 2
|
||||
|
||||
const weeks = 20000
|
||||
rng := rand.New(rand.NewPCG(0x5EED, 0x2C)) // "ZC"
|
||||
events := 0
|
||||
for range weeks {
|
||||
for range 7 {
|
||||
firedToday := false
|
||||
for range clearsPerDay {
|
||||
if firedToday {
|
||||
break // one event per player per UTC day
|
||||
}
|
||||
if rng.Float64() < advEventChanceZoneClear {
|
||||
firedToday = true
|
||||
}
|
||||
}
|
||||
if firedToday {
|
||||
events++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
perWeek := float64(events) / weeks
|
||||
if perWeek < 0.8 || perWeek > 1.5 {
|
||||
t.Errorf("zone-clear anchor = %.3f/week, want ~1 (0.8–1.5)", perWeek)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimDailyEventSlot_OnePerDay guards the cap the rate test assumes.
|
||||
func TestClaimDailyEventSlot_OnePerDay(t *testing.T) {
|
||||
uid := id.UserID("@events-slot:example")
|
||||
|
||||
@@ -1,519 +0,0 @@
|
||||
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",
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
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,31 +24,6 @@ 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,11 +40,6 @@ 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. " +
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// N4/E2 item gifting. `!give <item> @user` hands a consumable or non-magic gear
|
||||
// item to another adventurer. The sender pays a 5% handling fee (of item value)
|
||||
// to the community pot — the same civic tax every payout pays — and is capped at
|
||||
// a few gifts a day so the feature can't become a twink-funnel for a fresh alt.
|
||||
// Magic items are deliberately non-giftable: attunement and the BiS economy stay
|
||||
// personal (gogobee_engagement_plan.md §E2).
|
||||
const (
|
||||
giftDailyCap = 3
|
||||
giftTaxRate = 0.05
|
||||
)
|
||||
|
||||
// giftableTypes is the allowlist of AdvItem.Type values a player may gift.
|
||||
// Everything in adventure_inventory is unequipped by definition (equipped gear
|
||||
// lives in adventure_equipment / magic_item_equipped), so "unequipped" needs no
|
||||
// separate check. Magic items ("magic_item") and raw materials are excluded.
|
||||
var giftableTypes = map[string]bool{
|
||||
"consumable": true,
|
||||
"MasterworkGear": true,
|
||||
"ArenaGear": true,
|
||||
}
|
||||
|
||||
// isGiftableItem reports whether an inventory item may be gifted, and a reason
|
||||
// when it may not.
|
||||
func isGiftableItem(it AdvItem) (bool, string) {
|
||||
if it.Type == "magic_item" {
|
||||
return false, "Magic items stay personal — attunement doesn't transfer. Try a consumable or a piece of gear."
|
||||
}
|
||||
if !giftableTypes[it.Type] {
|
||||
return false, "You can only gift consumables and non-magic gear. Raw materials and treasures aren't giftable."
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// pickGiftableItem resolves the item a `!give` query names, preferring a giftable
|
||||
// match so a non-giftable item (a magic item, raw materials) that also matches
|
||||
// can't shadow a giftable one and block the gift — the way plain findInventoryMatch
|
||||
// would, since it returns the first match across the whole inventory. Returns
|
||||
// (item, "") on success; (nil, "") when nothing matches at all; and (nil, reason)
|
||||
// when only non-giftable items match, so the caller can explain why.
|
||||
func pickGiftableItem(inv []AdvItem, query string) (*AdvItem, string) {
|
||||
match := findInventoryMatch(inv, query)
|
||||
if match == nil {
|
||||
return nil, ""
|
||||
}
|
||||
if ok, _ := isGiftableItem(*match); ok {
|
||||
return match, ""
|
||||
}
|
||||
giftables := make([]AdvItem, 0, len(inv))
|
||||
for _, it := range inv {
|
||||
if ok, _ := isGiftableItem(it); ok {
|
||||
giftables = append(giftables, it)
|
||||
}
|
||||
}
|
||||
if alt := findInventoryMatch(giftables, query); alt != nil {
|
||||
return alt, ""
|
||||
}
|
||||
_, reason := isGiftableItem(*match)
|
||||
return nil, reason
|
||||
}
|
||||
|
||||
// giftCountToday returns how many gifts the sender has already sent on the given
|
||||
// UTC day. The cap is enforced against the persisted log, so a restart can't
|
||||
// reset someone's daily allowance.
|
||||
func giftCountToday(sender id.UserID, day string) int {
|
||||
var n int
|
||||
_ = db.Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM adventure_gift_log WHERE sender = ? AND gift_day = ?`,
|
||||
string(sender), day).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
// logGift records a completed gift for the daily cap and the audit trail.
|
||||
func logGift(sender, recipient id.UserID, itemName string, value int64, day string) error {
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO adventure_gift_log (sender, recipient, item_name, value, gift_day)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
string(sender), string(recipient), itemName, value, day)
|
||||
return err
|
||||
}
|
||||
|
||||
// transferInventoryItem moves one inventory row from one owner to another. Scoped
|
||||
// to the current owner so a stale id can't move a row that has already changed
|
||||
// hands, and forces in_vault=0 so a gift always lands in the recipient's active
|
||||
// pack rather than a phantom vault slot.
|
||||
func transferInventoryItem(itemID int64, from, to id.UserID) (bool, error) {
|
||||
res, err := db.Get().Exec(
|
||||
`UPDATE adventure_inventory SET user_id = ?, in_vault = 0 WHERE id = ? AND user_id = ?`,
|
||||
string(to), itemID, string(from))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// handleGiveCmd routes `!give <item> @user`.
|
||||
func (p *AdventurePlugin) handleGiveCmd(ctx MessageContext) error {
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "give"))
|
||||
|
||||
// Serialize a sender's own gifts so two near-simultaneous !give commands
|
||||
// can't both clear the daily cap or double-spend the same item row.
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
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.")
|
||||
}
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "You're dead. Your things stay where they are for now.")
|
||||
}
|
||||
|
||||
// The target is the trailing token; everything before it is the item name.
|
||||
fields := strings.Fields(args)
|
||||
if len(fields) < 2 {
|
||||
return p.SendDM(ctx.Sender, "Usage: `!give <item> @user` — e.g. `!give Health Potion @alex`.")
|
||||
}
|
||||
targetRaw := fields[len(fields)-1]
|
||||
itemQuery := strings.TrimSpace(strings.TrimSuffix(args, targetRaw))
|
||||
|
||||
target, ok := p.ResolveUser(targetRaw, ctx.RoomID)
|
||||
if !ok {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Couldn't find a user matching %q. Mention them or use their exact name.", targetRaw))
|
||||
}
|
||||
if target == ctx.Sender {
|
||||
return p.SendDM(ctx.Sender, "Regifting to yourself? That's just moving things around.")
|
||||
}
|
||||
|
||||
// Recipient must have completed !setup.
|
||||
rc, rerr := LoadDnDCharacter(target)
|
||||
if rerr != nil || rc == nil || rc.PendingSetup {
|
||||
name := p.DisplayName(target)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("%s hasn't set up an adventurer yet — they need to run `!setup` before they can receive gifts.", name))
|
||||
}
|
||||
|
||||
day := time.Now().UTC().Format("2006-01-02")
|
||||
if sent := giftCountToday(ctx.Sender, day); sent >= giftDailyCap {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You've already sent %d gifts today (the daily limit). Try again tomorrow.", sent))
|
||||
}
|
||||
|
||||
inv, err := loadAdvInventory(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't reach your inventory. Try again in a moment.")
|
||||
}
|
||||
match, reason := pickGiftableItem(inv, itemQuery)
|
||||
if match == nil {
|
||||
if reason == "" {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("No inventory item matches %q. Check `!adventure inventory`.", itemQuery))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, reason)
|
||||
}
|
||||
|
||||
tax := int(math.Round(float64(match.Value) * giftTaxRate))
|
||||
if tax > 0 {
|
||||
if bal := p.euro.GetBalance(ctx.Sender); bal < float64(tax) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Gifting **%s** carries a €%d handling fee (5%% of its value), and you only have €%.0f.", match.Name, tax, bal))
|
||||
}
|
||||
if !p.euro.Debit(ctx.Sender, float64(tax), "adventure_gift_tax") {
|
||||
return p.SendDM(ctx.Sender, "Transaction failed. The economy is having a moment.")
|
||||
}
|
||||
}
|
||||
|
||||
moved, err := transferInventoryItem(match.ID, ctx.Sender, target)
|
||||
if err != nil || !moved {
|
||||
if tax > 0 {
|
||||
p.euro.Credit(ctx.Sender, float64(tax), "adventure_gift_refund")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Couldn't hand the item over. Nothing changed — try again in a moment.")
|
||||
}
|
||||
if tax > 0 {
|
||||
communityPotAdd(tax)
|
||||
trackTaxPaid(ctx.Sender, tax)
|
||||
}
|
||||
_ = logGift(ctx.Sender, target, match.Name, match.Value, day)
|
||||
|
||||
senderName := p.DisplayName(ctx.Sender)
|
||||
recipName := p.DisplayName(target)
|
||||
_ = p.SendDM(target, fmt.Sprintf("🎁 **%s** sent you **%s**! It's in your inventory now — `!adventure inventory` to take a look.", senderName, match.Name))
|
||||
|
||||
remaining := giftDailyCap - giftCountToday(ctx.Sender, day)
|
||||
feeNote := ""
|
||||
if tax > 0 {
|
||||
feeNote = fmt.Sprintf(" (€%d handling fee to the community pot)", tax)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🎁 Sent **%s** to %s%s. %d gift(s) left today.", match.Name, recipName, feeNote, remaining))
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func TestIsGiftableItem(t *testing.T) {
|
||||
cases := []struct {
|
||||
typ string
|
||||
want bool
|
||||
}{
|
||||
{"consumable", true},
|
||||
{"MasterworkGear", true},
|
||||
{"ArenaGear", true},
|
||||
{"magic_item", false},
|
||||
{"treasure", false},
|
||||
{"ore", false},
|
||||
{"card", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, reason := isGiftableItem(AdvItem{Type: c.typ, Name: "X"})
|
||||
if got != c.want {
|
||||
t.Errorf("isGiftableItem(%q) = %v, want %v (reason %q)", c.typ, got, c.want, reason)
|
||||
}
|
||||
if !got && reason == "" {
|
||||
t.Errorf("isGiftableItem(%q) rejected with no reason", c.typ)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickGiftableItem: a non-giftable item must not shadow a giftable one that
|
||||
// also matches the query.
|
||||
func TestPickGiftableItem(t *testing.T) {
|
||||
inv := []AdvItem{
|
||||
{ID: 1, Name: "Dragon Scale", Type: "material", Tier: 5, Value: 9000},
|
||||
{ID: 2, Name: "Dragon Draught", Type: "consumable", Tier: 1, Value: 200},
|
||||
}
|
||||
// "dragon" matches the Scale first (higher tier), but the Draught is the
|
||||
// giftable one — pick it, don't refuse.
|
||||
got, reason := pickGiftableItem(inv, "dragon")
|
||||
if got == nil {
|
||||
t.Fatalf("pickGiftableItem returned nil (reason %q), want the giftable Draught", reason)
|
||||
}
|
||||
if got.Name != "Dragon Draught" {
|
||||
t.Errorf("picked %q, want Dragon Draught", got.Name)
|
||||
}
|
||||
|
||||
// Only a non-giftable match → refuse with a reason.
|
||||
onlyMagic := []AdvItem{{ID: 3, Name: "Ring of Power", Type: "magic_item", Value: 5000}}
|
||||
got, reason = pickGiftableItem(onlyMagic, "ring")
|
||||
if got != nil || reason == "" {
|
||||
t.Errorf("magic-only match: got=%v reason=%q, want nil with a refusal reason", got, reason)
|
||||
}
|
||||
|
||||
// No match at all → nil, empty reason.
|
||||
got, reason = pickGiftableItem(inv, "nonexistent")
|
||||
if got != nil || reason != "" {
|
||||
t.Errorf("no match: got=%v reason=%q, want nil/empty", got, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransferInventoryItem: an item moves owners exactly, and a wrong-owner
|
||||
// transfer is a no-op.
|
||||
func TestTransferInventoryItem(t *testing.T) {
|
||||
vaultTestDB(t)
|
||||
alice := id.UserID("@alice:test.invalid")
|
||||
bob := id.UserID("@bob:test.invalid")
|
||||
if err := addAdvInventoryItem(alice, AdvItem{Name: "Health Potion", Type: "consumable", Tier: 1, Value: 200}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inv, _ := loadAdvInventory(alice)
|
||||
if len(inv) != 1 {
|
||||
t.Fatalf("setup: alice has %d items, want 1", len(inv))
|
||||
}
|
||||
itemID := inv[0].ID
|
||||
|
||||
// Wrong owner can't move it.
|
||||
if moved, err := transferInventoryItem(itemID, bob, alice); err != nil || moved {
|
||||
t.Fatalf("wrong-owner transfer moved=%v err=%v, want no-op", moved, err)
|
||||
}
|
||||
|
||||
// Real owner moves it to bob.
|
||||
moved, err := transferInventoryItem(itemID, alice, bob)
|
||||
if err != nil || !moved {
|
||||
t.Fatalf("transfer moved=%v err=%v, want success", moved, err)
|
||||
}
|
||||
if a, _ := loadAdvInventory(alice); len(a) != 0 {
|
||||
t.Errorf("alice still has %d items after gifting", len(a))
|
||||
}
|
||||
b, _ := loadAdvInventory(bob)
|
||||
if len(b) != 1 || b[0].Name != "Health Potion" {
|
||||
t.Errorf("bob's inventory = %+v, want the potion", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransferUnvaultsItem: gifting a currently-vaulted item lands it in the
|
||||
// recipient's active pack, not a phantom vault slot.
|
||||
func TestTransferUnvaultsItem(t *testing.T) {
|
||||
vaultTestDB(t)
|
||||
alice := id.UserID("@alice2:test.invalid")
|
||||
bob := id.UserID("@bob2:test.invalid")
|
||||
if err := addAdvInventoryItem(alice, AdvItem{Name: "Elixir", Type: "consumable", Tier: 1, Value: 100}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inv, _ := loadAdvInventory(alice)
|
||||
if _, err := setItemVaulted(alice, inv[0].ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := transferInventoryItem(inv[0].ID, alice, bob); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Bob sees it in active inventory (not vault).
|
||||
if b, _ := loadAdvInventory(bob); len(b) != 1 {
|
||||
t.Errorf("bob active inventory = %d, want 1 (item un-vaulted on gift)", len(b))
|
||||
}
|
||||
if bv, _ := loadAdvVault(bob); len(bv) != 0 {
|
||||
t.Errorf("bob vault = %d, want 0", len(bv))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGiftDailyCapCounting: logGift increments the day's count and the count is
|
||||
// day-scoped.
|
||||
func TestGiftDailyCapCounting(t *testing.T) {
|
||||
vaultTestDB(t)
|
||||
sender := id.UserID("@giver:test.invalid")
|
||||
recip := id.UserID("@taker:test.invalid")
|
||||
|
||||
if n := giftCountToday(sender, "2026-07-10"); n != 0 {
|
||||
t.Fatalf("initial count = %d, want 0", n)
|
||||
}
|
||||
for i := 0; i < giftDailyCap; i++ {
|
||||
if err := logGift(sender, recip, "Potion", 100, "2026-07-10"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if n := giftCountToday(sender, "2026-07-10"); n != giftDailyCap {
|
||||
t.Errorf("count = %d, want %d", n, giftDailyCap)
|
||||
}
|
||||
// A different day is a fresh allowance.
|
||||
if n := giftCountToday(sender, "2026-07-11"); n != 0 {
|
||||
t.Errorf("next-day count = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
@@ -99,16 +99,12 @@ type advPendingHouseDownPayment struct {
|
||||
|
||||
type advPendingHouseAutopay struct{}
|
||||
|
||||
// Slot is the pet slot the arrival conversation is filling: 1 (the first pet)
|
||||
// or 2 (the Tier-4 Estate's second companion). Zero-value 1 keeps every
|
||||
// existing single-pet interaction unchanged.
|
||||
type advPendingPetArrival struct{ Slot int }
|
||||
type advPendingPetArrival struct{}
|
||||
|
||||
type advPendingPetType struct{ Slot int }
|
||||
type advPendingPetType struct{}
|
||||
|
||||
type advPendingPetName struct {
|
||||
PetType string
|
||||
Slot int
|
||||
}
|
||||
|
||||
// ── Thom Krooke Greeting ───────────────────────────────────────────────────
|
||||
@@ -146,18 +142,6 @@ 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 {
|
||||
@@ -237,19 +221,11 @@ func thomShopView(char *AdventureCharacter, balance float64) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Pet supply shop (unlocks 1 week after pet level 10). The unlock is a
|
||||
// pet-1 mechanic but covers barding for both companions. Each pet's block is
|
||||
// gated on its own presence so a chased-away pet-1 doesn't hide pet-2's shop.
|
||||
if char.PetSupplyShopUnlocked && (char.HasPet() || char.HasPet2()) {
|
||||
// Pet supply shop (unlocks 1 week after pet level 10)
|
||||
if char.PetSupplyShopUnlocked && char.HasPet() {
|
||||
sb.WriteString("\n---\n")
|
||||
if char.HasPet() {
|
||||
sb.WriteString(fmt.Sprintf("🐾 **Pet Supplies** — for %s\n\n", char.PetName))
|
||||
sb.WriteString(petArmorShopView(char.PetType, char.PetArmorTier, "petbuy"))
|
||||
}
|
||||
if char.HasPet2() {
|
||||
sb.WriteString(fmt.Sprintf("\n🐾 **Pet Supplies** — for %s\n\n", char.Pet2Name))
|
||||
sb.WriteString(petArmorShopView(char.Pet2Type, char.Pet2ArmorTier, "pet2buy"))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("🐾 **Pet Supplies** — for %s\n\n", char.PetName))
|
||||
sb.WriteString(petArmorShopView(char))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
@@ -293,11 +269,8 @@ func (p *AdventurePlugin) handleThomCmd(ctx MessageContext) error {
|
||||
case lower == "buy" || strings.HasPrefix(lower, "buy "):
|
||||
return p.handleThomBuy(ctx, char, balance, strings.TrimSpace(strings.TrimPrefix(lower, "buy")))
|
||||
|
||||
case strings.HasPrefix(lower, "pet2buy "):
|
||||
return p.petArmorBuyForSlot(ctx, strings.TrimSpace(args[8:]), 2)
|
||||
|
||||
case strings.HasPrefix(lower, "petbuy "):
|
||||
return p.petArmorBuyForSlot(ctx, strings.TrimSpace(args[7:]), 1)
|
||||
return p.handlePetArmorBuy(ctx, char, strings.TrimSpace(args[7:]))
|
||||
|
||||
default:
|
||||
return p.SendDM(ctx.Sender, "Unknown command. Type `!thom` to visit Krooke Realty.")
|
||||
@@ -565,22 +538,11 @@ 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()
|
||||
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():
|
||||
// Paid off!
|
||||
if freshChar.HasPet() {
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomPaidOffPet, freshChar.PetName)))
|
||||
default:
|
||||
} else {
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", thomPaidOffNoPet))
|
||||
}
|
||||
continue
|
||||
@@ -711,18 +673,18 @@ func petArmorDefs(petType string) []PetArmorDef {
|
||||
return petCatArmor
|
||||
}
|
||||
|
||||
func petArmorShopView(petType string, armorTier int, buyCmd string) string {
|
||||
func petArmorShopView(char *AdventureCharacter) string {
|
||||
var sb strings.Builder
|
||||
defs := petArmorDefs(petType)
|
||||
defs := petArmorDefs(char.PetType)
|
||||
|
||||
if armorTier >= 5 {
|
||||
if char.PetArmorTier >= 5 {
|
||||
sb.WriteString("✨ Max pet armor tier reached. There is nothing left to buy.\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
for _, def := range defs {
|
||||
if def.Tier <= armorTier {
|
||||
if def.Tier == armorTier {
|
||||
if def.Tier <= char.PetArmorTier {
|
||||
if def.Tier == char.PetArmorTier {
|
||||
sb.WriteString(fmt.Sprintf("🟢 %s (T%d) — Currently equipped\n", def.Name, def.Tier))
|
||||
}
|
||||
continue
|
||||
@@ -730,14 +692,11 @@ func petArmorShopView(petType string, armorTier int, buyCmd string) string {
|
||||
indicator := "⬆️"
|
||||
sb.WriteString(fmt.Sprintf("%s %s (T%d) — €%d — +%.1f%% deflect\n", indicator, def.Name, def.Tier, def.Price, def.DeflectBonus*100))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\nTo buy: `!thom %s <tier>` (e.g., `!thom %s %d`)", buyCmd, buyCmd, armorTier+1))
|
||||
sb.WriteString(fmt.Sprintf("\nTo buy: `!thom petbuy <tier>` (e.g., `!thom petbuy %d`)", char.PetArmorTier+1))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// petArmorBuyForSlot buys the next barding tier for the given pet slot. The
|
||||
// supply-shop unlock is shared (a pet-1 mechanic), but each pet's armor tier is
|
||||
// tracked and purchased independently.
|
||||
func (p *AdventurePlugin) petArmorBuyForSlot(ctx MessageContext, tierStr string, slot int) error {
|
||||
func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureCharacter, tierStr string) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
@@ -747,16 +706,8 @@ func (p *AdventurePlugin) petArmorBuyForSlot(ctx MessageContext, tierStr string,
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
// Slot accessors keep the pet-1 and pet-2 field sets isolated.
|
||||
hasPet, petType, petName, curTier := char.HasPet(), char.PetType, char.PetName, char.PetArmorTier
|
||||
buyCmd := "petbuy"
|
||||
if slot == 2 {
|
||||
hasPet, petType, petName, curTier = char.HasPet2(), char.Pet2Type, char.Pet2Name, char.Pet2ArmorTier
|
||||
buyCmd = "pet2buy"
|
||||
}
|
||||
|
||||
if !hasPet {
|
||||
return p.SendDM(ctx.Sender, "You don't have that pet.")
|
||||
if !char.HasPet() {
|
||||
return p.SendDM(ctx.Sender, "You don't have a pet.")
|
||||
}
|
||||
if !char.PetSupplyShopUnlocked {
|
||||
return p.SendDM(ctx.Sender, "Pet supplies aren't available yet.")
|
||||
@@ -765,19 +716,19 @@ func (p *AdventurePlugin) petArmorBuyForSlot(ctx MessageContext, tierStr string,
|
||||
tier := 0
|
||||
for _, c := range tierStr {
|
||||
if c < '0' || c > '9' {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Usage: `!thom %s <tier>`", buyCmd))
|
||||
return p.SendDM(ctx.Sender, "Usage: `!thom petbuy <tier>`")
|
||||
}
|
||||
tier = tier*10 + int(c-'0')
|
||||
}
|
||||
|
||||
if tier != curTier+1 {
|
||||
if tier <= curTier {
|
||||
if tier != char.PetArmorTier+1 {
|
||||
if tier <= char.PetArmorTier {
|
||||
return p.SendDM(ctx.Sender, "That's not an upgrade.")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You need to buy tier %d first.", curTier+1))
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You need to buy tier %d first.", char.PetArmorTier+1))
|
||||
}
|
||||
|
||||
defs := petArmorDefs(petType)
|
||||
defs := petArmorDefs(char.PetType)
|
||||
var def *PetArmorDef
|
||||
for i := range defs {
|
||||
if defs[i].Tier == tier {
|
||||
@@ -791,26 +742,18 @@ func (p *AdventurePlugin) petArmorBuyForSlot(ctx MessageContext, tierStr string,
|
||||
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
if balance < float64(def.Price) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("%s deserves better, but you can't afford €%d. Balance: €%.0f.", petName, def.Price, balance))
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("%s deserves better, but you can't afford €%d. Balance: €%.0f.", char.PetName, def.Price, balance))
|
||||
}
|
||||
|
||||
reason := "pet_armor_" + tierStr
|
||||
if slot == 2 {
|
||||
reason = "pet2_armor_" + tierStr
|
||||
}
|
||||
if !p.euro.Debit(ctx.Sender, float64(def.Price), reason) {
|
||||
if !p.euro.Debit(ctx.Sender, float64(def.Price), "pet_armor_"+tierStr) {
|
||||
return p.SendDM(ctx.Sender, "Transaction failed.")
|
||||
}
|
||||
|
||||
if slot == 2 {
|
||||
char.Pet2ArmorTier = tier
|
||||
} else {
|
||||
char.PetArmorTier = tier
|
||||
}
|
||||
char.PetArmorTier = tier
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
p.euro.Credit(ctx.Sender, float64(def.Price), "pet_armor_refund")
|
||||
return p.SendDM(ctx.Sender, "Failed to save. Refunded.")
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🐾 **%s** equipped on %s.\n\n+%.1f%% deflect bonus. %s looks... objectively better than you.", def.Name, petName, def.DeflectBonus*100, petName))
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🐾 **%s** equipped on %s.\n\n+%.1f%% deflect bonus. %s looks... objectively better than you.", def.Name, char.PetName, def.DeflectBonus*100, char.PetName))
|
||||
}
|
||||
|
||||
@@ -285,7 +285,8 @@ func TestPetArmorDefs_BothTypes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPetArmorShopView_HasUpgrades(t *testing.T) {
|
||||
text := petArmorShopView("dog", 0, "petbuy")
|
||||
char := &AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true, PetArmorTier: 0}
|
||||
text := petArmorShopView(char)
|
||||
|
||||
if !strings.Contains(text, "⬆️") {
|
||||
t.Error("should show upgrade indicators")
|
||||
@@ -296,7 +297,8 @@ func TestPetArmorShopView_HasUpgrades(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPetArmorShopView_MaxTier(t *testing.T) {
|
||||
text := petArmorShopView("cat", 5, "petbuy")
|
||||
char := &AdventureCharacter{PetType: "cat", PetName: "Luna", PetArrived: true, PetArmorTier: 5}
|
||||
text := petArmorShopView(char)
|
||||
|
||||
if !strings.Contains(text, "Max pet armor") {
|
||||
t.Error("should show max tier message")
|
||||
|
||||
@@ -435,8 +435,18 @@ func (p *AdventurePlugin) handleMasterworkEquipReply(ctx MessageContext, interac
|
||||
return p.SendDM(ctx.Sender, "Equip cancelled.")
|
||||
}
|
||||
|
||||
idx, ok := parseMenuIndex(reply, len(data.Items))
|
||||
if !ok {
|
||||
// Parse number
|
||||
idx := 0
|
||||
for _, c := range reply {
|
||||
if c >= '0' && c <= '9' {
|
||||
idx = idx*10 + int(c-'0')
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
idx-- // 1-indexed to 0-indexed
|
||||
|
||||
if idx < 0 || idx >= len(data.Items) {
|
||||
return p.SendDM(ctx.Sender, "Invalid selection. Reply with a number from the list, or \"cancel\".")
|
||||
}
|
||||
|
||||
@@ -492,33 +502,8 @@ func (p *AdventurePlugin) handleMasterworkEquipConfirm(ctx MessageContext, inter
|
||||
return p.SendDM(ctx.Sender, "Equip cancelled.")
|
||||
}
|
||||
|
||||
// Serialize against the sender's own !give: MasterworkGear/ArenaGear are
|
||||
// giftable (N4/E2), so the item captured at prompt time may have changed
|
||||
// hands before this confirmation. The lock makes gift-vs-confirm atomic,
|
||||
// and the re-load below refuses an item that is no longer ours — otherwise
|
||||
// equipping the captured copy would mint a duplicate the gift already
|
||||
// handed to the recipient.
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
selected := data.Item
|
||||
|
||||
inv, err := loadAdvInventory(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your inventory.")
|
||||
}
|
||||
stillOwned := false
|
||||
for _, it := range inv {
|
||||
if it.ID == selected.ID {
|
||||
stillOwned = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !stillOwned {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("**%s** isn't in your inventory anymore. Equip cancelled.", selected.Name))
|
||||
}
|
||||
|
||||
equip, err := loadAdvEquipment(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load equipment.")
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
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,15 +131,6 @@ 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
|
||||
@@ -150,12 +141,6 @@ 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":
|
||||
@@ -174,6 +159,11 @@ 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{
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
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 + "_"
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// TestDerivePlayerStats_OnePetIsByteIdentical pins that a single pet still
|
||||
// produces exactly the pre-two-pet mod values (the averaging code must reduce
|
||||
// to identity over one element, or the combat golden moves).
|
||||
func TestDerivePlayerStats_OnePetIsByteIdentical(t *testing.T) {
|
||||
char := testChar(10)
|
||||
char.PetType = "dog"
|
||||
char.PetName = "Rex"
|
||||
char.PetArrived = true
|
||||
char.PetLevel = 6
|
||||
char.PetArmorTier = 2
|
||||
|
||||
_, stats := DerivePlayerStats(char, testEquip(0), zeroBonuses(), 0, 0, false)
|
||||
|
||||
if stats.PetAttackProc != petAttackChance(6) {
|
||||
t.Errorf("PetAttackProc = %v, want %v (identity over one pet)", stats.PetAttackProc, petAttackChance(6))
|
||||
}
|
||||
if stats.PetDeflectProc != petDeflectChance(6, 2) {
|
||||
t.Errorf("PetDeflectProc = %v, want %v", stats.PetDeflectProc, petDeflectChance(6, 2))
|
||||
}
|
||||
if want := 0.01 + 6*0.005; stats.PetWhiffProc != want {
|
||||
t.Errorf("PetWhiffProc = %v, want %v", stats.PetWhiffProc, want)
|
||||
}
|
||||
if stats.PetAttackDmg != 3+6 {
|
||||
t.Errorf("PetAttackDmg = %d, want %d", stats.PetAttackDmg, 3+6)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDerivePlayerStats_TwoEqualPetsMatchOne: two identical pets average to the
|
||||
// same procs as one — a pair is not a stat spike.
|
||||
func TestDerivePlayerStats_TwoEqualPetsMatchOne(t *testing.T) {
|
||||
char := testChar(10)
|
||||
char.PetType, char.PetName, char.PetArrived, char.PetLevel, char.PetArmorTier = "dog", "Rex", true, 8, 3
|
||||
char.Pet2Type, char.Pet2Name, char.Pet2Arrived, char.Pet2Level, char.Pet2ArmorTier = "cat", "Luna", true, 8, 3
|
||||
|
||||
_, stats := DerivePlayerStats(char, testEquip(0), zeroBonuses(), 0, 0, false)
|
||||
|
||||
if stats.PetAttackProc != petAttackChance(8) {
|
||||
t.Errorf("two L8 pets PetAttackProc = %v, want one-pet %v", stats.PetAttackProc, petAttackChance(8))
|
||||
}
|
||||
if stats.PetAttackDmg != 3+8 {
|
||||
t.Errorf("two L8 pets PetAttackDmg = %d, want %d", stats.PetAttackDmg, 3+8)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDerivePlayerStats_TwoUnequalPetsAverage: mixed levels average, never
|
||||
// exceeding the stronger pet's solo contribution.
|
||||
func TestDerivePlayerStats_TwoUnequalPetsAverage(t *testing.T) {
|
||||
char := testChar(10)
|
||||
char.PetType, char.PetArrived, char.PetLevel, char.PetArmorTier = "dog", true, 10, 0
|
||||
char.Pet2Type, char.Pet2Arrived, char.Pet2Level, char.Pet2ArmorTier = "cat", true, 2, 0
|
||||
|
||||
_, stats := DerivePlayerStats(char, testEquip(0), zeroBonuses(), 0, 0, false)
|
||||
|
||||
wantAtk := (petAttackChance(10) + petAttackChance(2)) / 2
|
||||
if stats.PetAttackProc != wantAtk {
|
||||
t.Errorf("PetAttackProc = %v, want avg %v", stats.PetAttackProc, wantAtk)
|
||||
}
|
||||
// Averaged proc must sit strictly between the two pets' solo values.
|
||||
if !(stats.PetAttackProc < petAttackChance(10) && stats.PetAttackProc > petAttackChance(2)) {
|
||||
t.Errorf("averaged proc %v not between %v and %v", stats.PetAttackProc, petAttackChance(2), petAttackChance(10))
|
||||
}
|
||||
if want := 3 + (10+2+1)/2; stats.PetAttackDmg != want { // rounded avg level = 6
|
||||
t.Errorf("PetAttackDmg = %d, want %d", stats.PetAttackDmg, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDerivePlayerStats_Pet2OnlyStillContributes: if the first pet is chased
|
||||
// away but the second remains, combat still sees the second pet.
|
||||
func TestDerivePlayerStats_Pet2OnlyStillContributes(t *testing.T) {
|
||||
char := testChar(10)
|
||||
char.PetType, char.PetArrived, char.PetChasedAway, char.PetLevel = "dog", true, true, 5
|
||||
char.Pet2Type, char.Pet2Arrived, char.Pet2Level = "cat", true, 4
|
||||
|
||||
_, stats := DerivePlayerStats(char, testEquip(0), zeroBonuses(), 0, 0, false)
|
||||
|
||||
if !char.HasPet() && stats.PetAttackProc != petAttackChance(4) {
|
||||
t.Errorf("pet2-only PetAttackProc = %v, want %v", stats.PetAttackProc, petAttackChance(4))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDerivePlayerStats_NoPetsZero: the golden's own case — no pets, no procs.
|
||||
func TestDerivePlayerStats_NoPetsZero(t *testing.T) {
|
||||
_, stats := DerivePlayerStats(testChar(10), testEquip(0), zeroBonuses(), 0, 0, false)
|
||||
if stats.PetAttackProc != 0 || stats.PetDeflectProc != 0 || stats.PetWhiffProc != 0 || stats.PetAttackDmg != 0 {
|
||||
t.Errorf("petless character got pet mods: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetShouldArrive2_Gating(t *testing.T) {
|
||||
pet1 := PetState{Type: "dog", Arrived: true}
|
||||
tier4 := HouseState{Tier: 4}
|
||||
tier3 := HouseState{Tier: 3}
|
||||
|
||||
if petShouldArrive2(pet1, PetState{}, tier3) {
|
||||
t.Error("second pet must not arrive below Tier 4")
|
||||
}
|
||||
if petShouldArrive2(PetState{}, PetState{}, tier4) {
|
||||
t.Error("second pet needs an established first pet")
|
||||
}
|
||||
if petShouldArrive2(pet1, PetState{Arrived: true}, tier4) {
|
||||
t.Error("already-arrived second pet must not re-roll")
|
||||
}
|
||||
if petShouldArrive2(pet1, PetState{ChasedAway: true}, tier4) {
|
||||
t.Error("chased-away second pet does not re-arrive")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPet2StoreRoundTrip exercises the pet2_* columns and the AdvChar mirror.
|
||||
func TestPet2StoreRoundTrip(t *testing.T) {
|
||||
townTestDB(t)
|
||||
user := id.UserID("@pet2:test.invalid")
|
||||
|
||||
char := &AdventureCharacter{UserID: user}
|
||||
char.PetType, char.PetName, char.PetArrived, char.PetLevel = "dog", "Rex", true, 3
|
||||
char.Pet2Type, char.Pet2Name, char.Pet2Arrived, char.Pet2Level, char.Pet2ArmorTier = "cat", "Luna", true, 5, 2
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := loadAdvCharacter(user)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if !got.HasPet2() || got.Pet2Name != "Luna" || got.Pet2Level != 5 || got.Pet2ArmorTier != 2 || got.Pet2Type != "cat" {
|
||||
t.Fatalf("pet2 did not round-trip: %+v", got)
|
||||
}
|
||||
if !got.HasPet() || got.PetName != "Rex" {
|
||||
t.Fatalf("pet1 clobbered by pet2 write: %+v", got)
|
||||
}
|
||||
|
||||
// Direct slot loader agrees.
|
||||
p2, _ := loadPet2State(user)
|
||||
if p2.Name != "Luna" || p2.Level != 5 {
|
||||
t.Fatalf("loadPet2State mismatch: %+v", p2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPetShowcase_IncludesBothSlots verifies the town showcase surfaces second
|
||||
// pets and hides a chased-away one.
|
||||
func TestPetShowcase_IncludesBothSlots(t *testing.T) {
|
||||
townTestDB(t)
|
||||
|
||||
a := &AdventureCharacter{UserID: id.UserID("@a:test.invalid")}
|
||||
a.PetType, a.PetName, a.PetArrived, a.PetLevel = "dog", "Rex", true, 4
|
||||
a.Pet2Type, a.Pet2Name, a.Pet2Arrived, a.Pet2Level = "cat", "Luna", true, 9
|
||||
if err := saveAdvCharacter(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := &AdventureCharacter{UserID: id.UserID("@b:test.invalid")}
|
||||
b.Pet2Type, b.Pet2Name, b.Pet2Arrived, b.Pet2Level, b.Pet2ChasedAway = "dog", "Ghost", true, 7, true
|
||||
if err := saveAdvCharacter(b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pets, err := loadPetShowcase(townListCap)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pets) != 2 {
|
||||
t.Fatalf("want 2 active pets (Rex + Luna), got %d: %+v", len(pets), pets)
|
||||
}
|
||||
// Sorted by level desc → Luna (9) before Rex (4).
|
||||
if !strings.Contains(pets[0].Name, "Luna") || !strings.Contains(pets[1].Name, "Rex") {
|
||||
t.Fatalf("showcase not level-ordered across slots: %+v", pets)
|
||||
}
|
||||
for _, p := range pets {
|
||||
if strings.Contains(p.Name, "Ghost") {
|
||||
t.Errorf("chased-away second pet should be hidden: %+v", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,37 +31,28 @@ func petXPToNextLevel(level int) int {
|
||||
}
|
||||
}
|
||||
|
||||
// petGrantXP adds a per-action XP grant to the pet and handles level-ups.
|
||||
// Returns true if leveled up. Shares the level-up loop with the babysit trickle
|
||||
// via advancePetLevelsFromXP.
|
||||
// petGrantXP adds XP to the pet and handles level-ups. Returns true if leveled up.
|
||||
func petGrantXP(pet *PetState) bool {
|
||||
if !pet.HasPet() {
|
||||
if !pet.HasPet() || pet.Level >= 10 {
|
||||
return false
|
||||
}
|
||||
return advancePetLevelsFromXP(&pet.XP, &pet.Level, &pet.Level10Date, int(petXPPerAction*100))
|
||||
}
|
||||
|
||||
// advancePetLevelsFromXP adds centi-XP to a pet and applies any level-ups, up
|
||||
// to the level-10 cap, stamping the level-10 date on first reaching it. Shared
|
||||
// by both pet slots (the babysit trickle). Returns true if the pet leveled.
|
||||
func advancePetLevelsFromXP(xp, level *int, level10Date *string, addCentiXP int) bool {
|
||||
if *level >= 10 {
|
||||
return false
|
||||
}
|
||||
*xp += addCentiXP
|
||||
pet.XP += int(petXPPerAction * 100) // store as centixp for precision
|
||||
leveled := false
|
||||
for *level < 10 {
|
||||
needed := petXPToNextLevel(*level) * 100
|
||||
if *xp < needed {
|
||||
for pet.Level < 10 {
|
||||
needed := petXPToNextLevel(pet.Level) * 100
|
||||
if pet.XP < needed {
|
||||
break
|
||||
}
|
||||
*xp -= needed
|
||||
*level++
|
||||
pet.XP -= needed
|
||||
pet.Level++
|
||||
leveled = true
|
||||
}
|
||||
if *level >= 10 && *level10Date == "" {
|
||||
*level10Date = time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
if pet.Level >= 10 && pet.Level10Date == "" {
|
||||
pet.Level10Date = time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
return leveled
|
||||
}
|
||||
|
||||
@@ -215,23 +206,6 @@ func petShouldArrive(pet PetState, house HouseState) bool {
|
||||
return rand.Float64() < 0.30
|
||||
}
|
||||
|
||||
// petShouldArrive2 gates the SECOND companion (N4/E1). It needs a Tier-4 Estate
|
||||
// and an established first pet — the second animal wanders in to join the first,
|
||||
// not an empty house. A chased-away second pet does not re-arrive (Misty's
|
||||
// reactivation is a pet-1 mechanic).
|
||||
func petShouldArrive2(pet1, pet2 PetState, house HouseState) bool {
|
||||
if pet2.Arrived || pet2.ChasedAway {
|
||||
return false
|
||||
}
|
||||
if house.Tier < 4 {
|
||||
return false
|
||||
}
|
||||
if !pet1.HasPet() {
|
||||
return false
|
||||
}
|
||||
return rand.Float64() < 0.30
|
||||
}
|
||||
|
||||
// maybeRollPetArrivalOnEmerge rolls the pet-arrival check when a player
|
||||
// surfaces from an expedition (voluntary extract, abandon, or a survived
|
||||
// forced extraction) or revives after death. The arrival roll lives on the
|
||||
@@ -247,52 +221,32 @@ func (p *AdventurePlugin) maybeRollPetArrivalOnEmerge(userID id.UserID) {
|
||||
pet, _ := loadPetState(userID)
|
||||
house, _ := loadHouseState(userID)
|
||||
if petShouldArrive(pet, house) {
|
||||
p.petArrivalDM(userID, 1)
|
||||
return
|
||||
}
|
||||
// A Tier-4 Estate can draw a second companion once the first is settled.
|
||||
pet2, _ := loadPet2State(userID)
|
||||
if petShouldArrive2(pet, pet2, house) {
|
||||
p.petArrivalDM(userID, 2)
|
||||
p.petArrivalDM(userID)
|
||||
}
|
||||
}
|
||||
|
||||
// petArrivalDM sends the initial "there's an animal in your house" DM for the
|
||||
// given slot (1 or 2).
|
||||
func (p *AdventurePlugin) petArrivalDM(userID id.UserID, slot int) {
|
||||
// petArrivalDM sends the initial "there's an animal in your house" DM.
|
||||
func (p *AdventurePlugin) petArrivalDM(userID id.UserID) {
|
||||
// Don't overwrite an existing pending interaction
|
||||
if _, exists := p.pending.Load(string(userID)); exists {
|
||||
return
|
||||
}
|
||||
|
||||
intro := "There's an animal in your house. It looks like a..."
|
||||
if slot == 2 {
|
||||
intro = "There's *another* animal in your house. Your first pet seems unbothered. It looks like a..."
|
||||
}
|
||||
text := intro + "\n\n" +
|
||||
text := "There's an animal in your house. It looks like a...\n\n" +
|
||||
"🚪 Chase it away\n" +
|
||||
"🍖 Feed it\n\n" +
|
||||
"Reply with `chase` or `feed`."
|
||||
|
||||
p.pending.Store(string(userID), &advPendingInteraction{
|
||||
Type: "pet_arrival",
|
||||
Data: &advPendingPetArrival{Slot: slot},
|
||||
Data: &advPendingPetArrival{},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||
})
|
||||
_ = p.SendDM(userID, text)
|
||||
}
|
||||
|
||||
// petSlotFromData reads a Slot off any pending-pet payload, defaulting to slot 1
|
||||
// for the zero value so older/first-pet interactions are unaffected.
|
||||
func petSlotFromData(slot int) int {
|
||||
if slot == 2 {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// resolvePetArrival handles the chase/feed response.
|
||||
func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||
func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
@@ -302,18 +256,13 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext, interaction *adv
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
slot := petSlotFromData(interaction.Data.(*advPendingPetArrival).Slot)
|
||||
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
|
||||
|
||||
if reply == "chase" || reply == "🚪" {
|
||||
if slot == 2 {
|
||||
char.Pet2ChasedAway = true
|
||||
char.Pet2Reactivated = false
|
||||
} else {
|
||||
char.PetChasedAway = true
|
||||
char.PetReactivated = false
|
||||
}
|
||||
char.PetChasedAway = true
|
||||
char.PetReactivated = false
|
||||
_ = saveAdvCharacter(char)
|
||||
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char))
|
||||
return p.SendDM(ctx.Sender, "You chased it away. It disappeared around the corner and didn't come back.\n\nThe house is quiet again.")
|
||||
}
|
||||
|
||||
@@ -327,7 +276,7 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext, interaction *adv
|
||||
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_type",
|
||||
Data: &advPendingPetType{Slot: slot},
|
||||
Data: &advPendingPetType{},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
@@ -336,19 +285,18 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext, interaction *adv
|
||||
// Invalid response — re-prompt
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_arrival",
|
||||
Data: &advPendingPetArrival{Slot: slot},
|
||||
Data: &advPendingPetArrival{},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, "Reply with `chase` or `feed`.")
|
||||
}
|
||||
|
||||
// resolvePetType handles dog/cat selection.
|
||||
func (p *AdventurePlugin) resolvePetType(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||
func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
slot := petSlotFromData(interaction.Data.(*advPendingPetType).Slot)
|
||||
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
|
||||
|
||||
petType := ""
|
||||
@@ -361,7 +309,7 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext, interaction *advPen
|
||||
if petType == "" {
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_type",
|
||||
Data: &advPendingPetType{Slot: slot},
|
||||
Data: &advPendingPetType{},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, "Reply with `dog` or `cat`.")
|
||||
@@ -369,7 +317,7 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext, interaction *advPen
|
||||
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_name",
|
||||
Data: &advPendingPetName{PetType: petType, Slot: slot},
|
||||
Data: &advPendingPetName{PetType: petType},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||
})
|
||||
|
||||
@@ -407,36 +355,24 @@ func (p *AdventurePlugin) resolvePetName(ctx MessageContext, interaction *advPen
|
||||
return p.SendDM(ctx.Sender, "Name can only contain letters, numbers, spaces, hyphens, and apostrophes. Try again.")
|
||||
}
|
||||
|
||||
if petSlotFromData(data.Slot) == 2 {
|
||||
char.Pet2Type = data.PetType
|
||||
char.Pet2Name = name
|
||||
char.Pet2Arrived = true
|
||||
char.Pet2ChasedAway = false
|
||||
char.Pet2Level = 1
|
||||
char.Pet2XP = 0
|
||||
} else {
|
||||
char.PetType = data.PetType
|
||||
char.PetName = name
|
||||
char.PetArrived = true
|
||||
char.PetChasedAway = false
|
||||
char.PetLevel = 1
|
||||
char.PetXP = 0
|
||||
}
|
||||
char.PetType = data.PetType
|
||||
char.PetName = name
|
||||
char.PetArrived = true
|
||||
char.PetChasedAway = false
|
||||
char.PetLevel = 1
|
||||
char.PetXP = 0
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to save.")
|
||||
}
|
||||
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char))
|
||||
|
||||
emoji := "🐶"
|
||||
if data.PetType == "cat" {
|
||||
emoji = "🐱"
|
||||
}
|
||||
tail := fmt.Sprintf("%s will join you in combat. %s will not explain their decisions.", name, name)
|
||||
if petSlotFromData(data.Slot) == 2 {
|
||||
tail = fmt.Sprintf("%s fights alongside your first companion — the two share the spotlight, so together they're about as much help in a scrap as one seasoned pet. %s will not explain their decisions.", name, name)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("%s **%s** has moved in.\n\nBreed: Massive %s.\nLevel: 1\n\n%s",
|
||||
emoji, name, titleCase(data.PetType), tail))
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("%s **%s** has moved in.\n\nBreed: Massive %s.\nLevel: 1\n\n%s will join you in combat. %s will not explain their decisions.",
|
||||
emoji, name, titleCase(data.PetType), name, name))
|
||||
}
|
||||
|
||||
// ── Morning Pet Events ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -312,20 +312,6 @@ 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)
|
||||
@@ -988,7 +974,6 @@ type AdvLeaderboardEntry struct {
|
||||
ForagingSkill int
|
||||
FishingSkill int
|
||||
CurrentStreak int
|
||||
Renown int // N7/B2 — prestige level, cosmetic marker only
|
||||
}
|
||||
|
||||
func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
@@ -1002,21 +987,16 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1048,11 +1028,7 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
if e.Streak >= 7 {
|
||||
streak = fmt.Sprintf(" 🔥%d", e.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))
|
||||
sb.WriteString(fmt.Sprintf("%s **%s** — %s (score: %d%s)\n", prefix, e.Name, e.Levels, e.Score, streak))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
@@ -1061,17 +1037,8 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
// ── Treasure Discard Prompt ──────────────────────────────────────────────────
|
||||
|
||||
func renderAdvTreasureDiscardPrompt(newTreasure *AdvTreasureDef, existing []AdvTreasureDef) string {
|
||||
// The flavor templates below are written for the base 3-slot set. With a
|
||||
// T3 trophy room the set can hold 4, so fall back to a plain dynamic list
|
||||
// whenever it isn't exactly 3 — every slot has to be visible to choose it.
|
||||
if len(TreasureInventoryCap) == 0 || len(existing) != 3 {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "You found **%s** but your treasure slots are full. Discard one to make room:\n", newTreasure.Name)
|
||||
for i, ex := range existing {
|
||||
fmt.Fprintf(&b, "%d. %s — %s\n", i+1, ex.Name, ex.InventoryDesc)
|
||||
}
|
||||
fmt.Fprintf(&b, "\nReply with the number to discard, or `keep` to leave the new one behind.")
|
||||
return b.String()
|
||||
if len(TreasureInventoryCap) == 0 {
|
||||
return "You found a treasure but your inventory is full. Reply 1, 2, or 3 to discard, or `keep`."
|
||||
}
|
||||
|
||||
// Build substitution map with existing treasure info
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
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,28 +164,16 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
|
||||
gaveCard = true
|
||||
}
|
||||
|
||||
// Update visit count, and every 10th visit leave a small consumable
|
||||
// "for the trouble" (D2 NPC arc).
|
||||
var leftGift *AdvItem
|
||||
// Update visit count
|
||||
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, leftGift)
|
||||
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard)
|
||||
if err := p.SendDM(userID, dm); err != nil {
|
||||
slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err)
|
||||
}
|
||||
@@ -203,12 +191,10 @@ 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, 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" {
|
||||
// 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" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -257,27 +243,7 @@ func robbiePlayerHasCard(userID id.UserID) bool {
|
||||
|
||||
// ── DM Rendering ─────────────────────────────────────────────────────────────
|
||||
|
||||
// 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 {
|
||||
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Opening
|
||||
@@ -324,12 +290,6 @@ 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,7 +163,6 @@ 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 := ""
|
||||
@@ -421,12 +420,6 @@ 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).
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
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 + "."
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
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())
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
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,38 +1146,13 @@ 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] })
|
||||
|
||||
target := curiosStockSize
|
||||
if target > len(ids) {
|
||||
target = len(ids)
|
||||
n := curiosStockSize
|
||||
if n > len(ids) {
|
||||
n = len(ids)
|
||||
}
|
||||
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 := make([]MagicItem, 0, n)
|
||||
for _, id := range ids[:n] {
|
||||
stock = append(stock, magicItemRegistry[id])
|
||||
seen[id] = true
|
||||
}
|
||||
// Stable display order: rarity ascending, then name.
|
||||
sort.Slice(stock, func(i, j int) bool {
|
||||
@@ -1192,11 +1167,7 @@ func dailyCuriosStock() []MagicItem {
|
||||
|
||||
func (p *AdventurePlugin) luigiCuriosView(userID id.UserID, balance float64) string {
|
||||
var sb strings.Builder
|
||||
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("🔮 **Curios** — fresh stock daily at dawn\n")
|
||||
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
|
||||
|
||||
factor := p.shopSessionPriceFactor(userID)
|
||||
|
||||
@@ -140,12 +140,8 @@ func findTemperMaterial(items []AdvItem) (AdvItem, bool) {
|
||||
return AdvItem{}, false
|
||||
}
|
||||
|
||||
// parseMenuIndex reads a leading 1-indexed number off a menu reply and returns
|
||||
// the bounds-checked 0-indexed position. The single parser behind every "reply
|
||||
// with a number from the list" prompt — temper, magic-item equip, masterwork
|
||||
// equip. Leading whitespace and any trailing text after the digits are ignored;
|
||||
// a reply with no leading digit, or one outside [1, n], returns ok=false.
|
||||
func parseMenuIndex(reply string, n int) (int, bool) {
|
||||
// parseTemperIndex reads a leading 1-indexed number and bounds-checks it.
|
||||
func parseTemperIndex(reply string, n int) (int, bool) {
|
||||
idx, parsed := 0, false
|
||||
for _, c := range strings.TrimSpace(reply) {
|
||||
if c < '0' || c > '9' {
|
||||
@@ -218,7 +214,7 @@ func (p *AdventurePlugin) resolveTemperPick(ctx MessageContext, interaction *adv
|
||||
if strings.EqualFold(reply, "cancel") {
|
||||
return p.SendDM(ctx.Sender, "_The forge keeps burning._ Come back when you've decided.")
|
||||
}
|
||||
idx, ok := parseMenuIndex(reply, len(data.Targets))
|
||||
idx, ok := parseTemperIndex(reply, len(data.Targets))
|
||||
if !ok {
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, "Reply with a number from the list, or \"cancel\".")
|
||||
|
||||
@@ -346,9 +346,9 @@ func TestParseTemperIndex(t *testing.T) {
|
||||
{"cancel", 3, 0, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, ok := parseMenuIndex(tc.in, tc.n)
|
||||
got, ok := parseTemperIndex(tc.in, tc.n)
|
||||
if ok != tc.wantOK || (ok && got != tc.want) {
|
||||
t.Errorf("parseMenuIndex(%q, %d) = (%d, %v), want (%d, %v)",
|
||||
t.Errorf("parseTemperIndex(%q, %d) = (%d, %v), want (%d, %v)",
|
||||
tc.in, tc.n, got, ok, tc.want, tc.wantOK)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,487 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// E3 — Surfacing the buried social data. Three read-only registries that
|
||||
// render data the game already stores: !town (civic pride + housing + pets),
|
||||
// !graveyard (recent deaths), and !rivals board (room-wide duel standings).
|
||||
//
|
||||
// Leak-check (gogobee_engagement_plan.md §E3): the civic-pride board ranks
|
||||
// tax_ledger.total_paid, which is dominated by gambling/shop/arena rake and
|
||||
// carries ZERO Misty/Arina donation signal — those debits never touch
|
||||
// tax_ledger (adventure_npcs.go routes them through euro.Debit with their own
|
||||
// reason strings, counters live in player_meta.misty_*/arina_* columns). So no
|
||||
// board here can let a player correlate donations with the hidden NPC buffs.
|
||||
|
||||
const (
|
||||
// townCivicBoardLimit caps the civic-pride board.
|
||||
townCivicBoardLimit = 10
|
||||
// townListCap caps the housing/pet showcase lists.
|
||||
townListCap = 15
|
||||
// graveyardLimit caps the graveyard.
|
||||
graveyardLimit = 12
|
||||
// rivalsBoardLimit caps the room-wide rivalry standings.
|
||||
rivalsBoardLimit = 10
|
||||
)
|
||||
|
||||
// ── Civic pride ──────────────────────────────────────────────────────────────
|
||||
|
||||
type civicEntry struct {
|
||||
Name string
|
||||
TotalPaid int64
|
||||
}
|
||||
|
||||
// loadCivicPrideBoard returns the top contributors to the community pot by
|
||||
// lifetime tax paid. Safe to rank — see the leak-check note above.
|
||||
func loadCivicPrideBoard(limit int) ([]civicEntry, error) {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT COALESCE(NULLIF(c.display_name, ''), t.user_id), t.total_paid
|
||||
FROM tax_ledger t
|
||||
LEFT JOIN player_meta c ON c.user_id = t.user_id
|
||||
WHERE t.total_paid > 0
|
||||
ORDER BY t.total_paid DESC
|
||||
LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []civicEntry
|
||||
for rows.Next() {
|
||||
var e civicEntry
|
||||
if err := rows.Scan(&e.Name, &e.TotalPaid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ── Housing street ───────────────────────────────────────────────────────────
|
||||
|
||||
type housingEntry struct {
|
||||
Name string
|
||||
Tier int
|
||||
}
|
||||
|
||||
// loadHousingStreet returns everyone who owns a house, best homes first.
|
||||
func loadHousingStreet(limit int) ([]housingEntry, error) {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT COALESCE(NULLIF(display_name, ''), user_id), house_tier
|
||||
FROM player_meta
|
||||
WHERE house_tier > 0
|
||||
ORDER BY house_tier DESC, display_name
|
||||
LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []housingEntry
|
||||
for rows.Next() {
|
||||
var e housingEntry
|
||||
if err := rows.Scan(&e.Name, &e.Tier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ── Pet showcase ─────────────────────────────────────────────────────────────
|
||||
|
||||
type petShowcaseEntry struct {
|
||||
Name string
|
||||
Type string
|
||||
Level int
|
||||
ArmorName string
|
||||
}
|
||||
|
||||
// loadPetShowcase returns every active pet — both slots — across all players,
|
||||
// highest level first. The arrived/chased flags live in the *_flags_json
|
||||
// columns, so they're decoded in Go rather than in SQL.
|
||||
func loadPetShowcase(limit int) ([]petShowcaseEntry, error) {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT COALESCE(NULLIF(display_name, ''), user_id),
|
||||
pet_name, pet_type, pet_level, pet_armor_tier, pet_flags_json,
|
||||
pet2_name, pet2_type, pet2_level, pet2_armor_tier, pet2_flags_json
|
||||
FROM player_meta
|
||||
WHERE pet_type != '' OR pet2_type != ''`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []petShowcaseEntry
|
||||
for rows.Next() {
|
||||
var (
|
||||
owner string
|
||||
petName, petType, flagsRaw string
|
||||
level, armorTier int
|
||||
pet2Name, pet2Type, flags2Raw string
|
||||
pet2Level, pet2Armor int
|
||||
)
|
||||
if err := rows.Scan(&owner, &petName, &petType, &level, &armorTier, &flagsRaw,
|
||||
&pet2Name, &pet2Type, &pet2Level, &pet2Armor, &flags2Raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e, ok := showcaseEntryFor(owner, petName, petType, level, armorTier, flagsRaw); ok {
|
||||
out = append(out, e)
|
||||
}
|
||||
if e, ok := showcaseEntryFor(owner, pet2Name, pet2Type, pet2Level, pet2Armor, flags2Raw); ok {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Level != out[j].Level {
|
||||
return out[i].Level > out[j].Level
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// showcaseEntryFor builds a showcase entry for one pet slot, returning ok=false
|
||||
// if the slot is empty or its pet was chased away.
|
||||
func showcaseEntryFor(owner, petName, petType string, level, armorTier int, flagsRaw string) (petShowcaseEntry, bool) {
|
||||
if petType == "" {
|
||||
return petShowcaseEntry{}, false
|
||||
}
|
||||
var flags petFlagsJSON
|
||||
if flagsRaw != "" {
|
||||
_ = json.Unmarshal([]byte(flagsRaw), &flags)
|
||||
}
|
||||
if !flags.Arrived || flags.ChasedAway {
|
||||
return petShowcaseEntry{}, false
|
||||
}
|
||||
return petShowcaseEntry{
|
||||
Name: firstNonEmpty(petName, "an unnamed companion") + " (" + owner + ")",
|
||||
Type: petType,
|
||||
Level: level,
|
||||
ArmorName: petArmorName(petType, armorTier),
|
||||
}, true
|
||||
}
|
||||
|
||||
// petArmorName maps a pet's armor tier to its barding name, or "" for none.
|
||||
func petArmorName(petType string, tier int) string {
|
||||
if tier <= 0 {
|
||||
return ""
|
||||
}
|
||||
for _, a := range petArmorDefs(petType) {
|
||||
if a.Tier == tier {
|
||||
return a.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── Graveyard ────────────────────────────────────────────────────────────────
|
||||
|
||||
type graveEntry struct {
|
||||
Name string
|
||||
Source string // "adventure" | "arena" | ""
|
||||
Location string
|
||||
LastDeathDate string // "2006-01-02"
|
||||
StillDown bool // alive == 0 (inside the 6h respawn window)
|
||||
}
|
||||
|
||||
// loadGraveyard returns the most recent deaths across all players. Deaths are
|
||||
// day-granularity (last_death_date), so ordering falls back to dead_until for
|
||||
// the still-in-the-ground; a revived player keeps their last_death_date but
|
||||
// clears dead_until.
|
||||
func loadGraveyard(limit int) ([]graveEntry, error) {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT COALESCE(NULLIF(display_name, ''), user_id),
|
||||
alive, death_source, death_location, last_death_date, dead_until
|
||||
FROM player_meta
|
||||
WHERE last_death_date != ''
|
||||
ORDER BY last_death_date DESC, dead_until DESC
|
||||
LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []graveEntry
|
||||
for rows.Next() {
|
||||
var (
|
||||
e graveEntry
|
||||
aliveInt int
|
||||
deadUntil sql.NullTime
|
||||
)
|
||||
if err := rows.Scan(&e.Name, &aliveInt, &e.Source, &e.Location, &e.LastDeathDate, &deadUntil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.StillDown = aliveInt == 0
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ── Rivals board ─────────────────────────────────────────────────────────────
|
||||
|
||||
type rivalBoardEntry struct {
|
||||
Name string
|
||||
Wins int
|
||||
Losses int
|
||||
LastDuelAt *time.Time
|
||||
}
|
||||
|
||||
// loadRivalsBoard aggregates adventure_rival_records into room-wide standings.
|
||||
// The table is a directed per-pair ledger written from both duellists' sides,
|
||||
// so summing a user_id's rows gives that player's total record.
|
||||
func loadRivalsBoard(limit int) ([]rivalBoardEntry, error) {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT COALESCE(NULLIF(c.display_name, ''), r.user_id),
|
||||
SUM(r.wins), SUM(r.losses), MAX(r.last_duel_at)
|
||||
FROM adventure_rival_records r
|
||||
LEFT JOIN player_meta c ON c.user_id = r.user_id
|
||||
GROUP BY r.user_id
|
||||
ORDER BY SUM(r.wins) DESC, SUM(r.losses) ASC
|
||||
LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []rivalBoardEntry
|
||||
for rows.Next() {
|
||||
var (
|
||||
e rivalBoardEntry
|
||||
lastDuel sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&e.Name, &e.Wins, &e.Losses, &lastDuel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// MAX() over a DATETIME column loses SQLite's type affinity and comes
|
||||
// back as text, so parse it by hand rather than scanning a NullTime.
|
||||
if lastDuel.Valid {
|
||||
if t, ok := parseSQLiteTime(lastDuel.String); ok {
|
||||
e.LastDuelAt = &t
|
||||
}
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ── Rendering (client-free, unit-testable) ───────────────────────────────────
|
||||
|
||||
func renderTownRegistry(civic []civicEntry, houses []housingEntry, pets []petShowcaseEntry) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🏛️ **The Town Registry**\n\n")
|
||||
|
||||
sb.WriteString("**Civic Pride** — the guild's most generous coffers-fillers\n")
|
||||
if len(civic) == 0 {
|
||||
sb.WriteString(" _The collection plate is empty. Tragic._\n")
|
||||
} else {
|
||||
for i, e := range civic {
|
||||
sb.WriteString(fmt.Sprintf(" %2d. %-18s %s\n", i+1, truncName(e.Name, 18), fmtEuro(e.TotalPaid)))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("**Housing Street** — who's put down roots\n")
|
||||
if len(houses) == 0 {
|
||||
sb.WriteString(" _Not a single deed on file. A town of drifters._\n")
|
||||
} else {
|
||||
for _, e := range houses {
|
||||
tierName := "a house"
|
||||
if def := houseTierByTier(e.Tier); def != nil {
|
||||
tierName = def.Name
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %-18s %s\n", truncName(e.Name, 18), tierName))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("**Pet Showcase** — the good companions\n")
|
||||
if len(pets) == 0 {
|
||||
sb.WriteString(" _No pets in town. Somebody adopt something._\n")
|
||||
} else {
|
||||
for _, e := range pets {
|
||||
line := fmt.Sprintf(" %-30s L%d %s", truncName(e.Name, 30), e.Level, e.Type)
|
||||
if e.ArmorName != "" {
|
||||
line += " · " + e.ArmorName
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimRight(sb.String(), "\n")
|
||||
}
|
||||
|
||||
func renderGraveyard(deaths []graveEntry, now time.Time) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("⚰️ **St. Guildmore's Memorial Garden**\n")
|
||||
sb.WriteString("_The groundskeeper tips his hat. \"They gave their all. Well — they gave enough.\"_\n\n")
|
||||
|
||||
if len(deaths) == 0 {
|
||||
sb.WriteString("_Nobody's died lately. The garden's quiet. Enjoy it while it lasts._")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
for _, e := range deaths {
|
||||
place := e.Location
|
||||
if place == "" {
|
||||
place = "parts unknown"
|
||||
}
|
||||
verb := "fell"
|
||||
if e.Source == "arena" {
|
||||
verb = "fell in the arena"
|
||||
}
|
||||
when := humanizeDeathDate(e.LastDeathDate, now)
|
||||
line := fmt.Sprintf(" ⚰️ **%s** — %s at %s (%s)", truncName(e.Name, 24), verb, place, when)
|
||||
if e.StillDown {
|
||||
line += " · _still resting_"
|
||||
} else {
|
||||
line += " · _back on their feet_"
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
|
||||
return strings.TrimRight(sb.String(), "\n")
|
||||
}
|
||||
|
||||
func renderRivalsBoard(board []rivalBoardEntry, now time.Time) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("⚔️ **Rivalry Standings** — duels across the guild\n\n")
|
||||
|
||||
if len(board) == 0 {
|
||||
sb.WriteString("_No duels on record. A suspiciously peaceful town._")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
for i, e := range board {
|
||||
last := "—"
|
||||
if e.LastDuelAt != nil {
|
||||
last = humanizeDaysAgo(*e.LastDuelAt, now)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %2d. %-18s %dW - %dL last duel: %s\n",
|
||||
i+1, truncName(e.Name, 18), e.Wins, e.Losses, last))
|
||||
}
|
||||
|
||||
return strings.TrimRight(sb.String(), "\n")
|
||||
}
|
||||
|
||||
// ── Small render helpers ─────────────────────────────────────────────────────
|
||||
|
||||
func truncName(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
if max <= 1 {
|
||||
return string(r[:max])
|
||||
}
|
||||
return string(r[:max-1]) + "…"
|
||||
}
|
||||
|
||||
func firstNonEmpty(s, fallback string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return fallback
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// parseSQLiteTime parses a SQLite CURRENT_TIMESTAMP string ("2006-01-02
|
||||
// 15:04:05", UTC), tolerating an RFC3339 variant. Aggregates like MAX() strip
|
||||
// the column's type affinity so the driver hands back text.
|
||||
func parseSQLiteTime(s string) (time.Time, bool) {
|
||||
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.UTC(), true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// humanizeDaysAgo renders a duel/event time as "today" / "1 day ago" / "N days
|
||||
// ago", matching the inline style in handleRivalsCmd.
|
||||
func humanizeDaysAgo(t, now time.Time) string {
|
||||
days := int(now.Sub(t).Hours() / 24)
|
||||
switch {
|
||||
case days <= 0:
|
||||
return "today"
|
||||
case days == 1:
|
||||
return "1 day ago"
|
||||
default:
|
||||
return fmt.Sprintf("%d days ago", days)
|
||||
}
|
||||
}
|
||||
|
||||
// humanizeDeathDate renders a "2006-01-02" death date relative to now. Falls
|
||||
// back to the raw string if it doesn't parse.
|
||||
func humanizeDeathDate(dateStr string, now time.Time) string {
|
||||
t, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
if dateStr == "" {
|
||||
return "some time ago"
|
||||
}
|
||||
return dateStr
|
||||
}
|
||||
days := int(now.UTC().Truncate(24*time.Hour).Sub(t).Hours() / 24)
|
||||
switch {
|
||||
case days <= 0:
|
||||
return "today"
|
||||
case days == 1:
|
||||
return "yesterday"
|
||||
default:
|
||||
return fmt.Sprintf("%d days ago", days)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleTownCmd(ctx MessageContext) error {
|
||||
civic, err := loadCivicPrideBoard(townCivicBoardLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
houses, err := loadHousingStreet(townListCap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pets, err := loadPetShowcase(townListCap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, renderTownRegistry(civic, houses, pets))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleGraveyardCmd(ctx MessageContext) error {
|
||||
deaths, err := loadGraveyard(graveyardLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, renderGraveyard(deaths, time.Now().UTC()))
|
||||
}
|
||||
|
||||
// handleRivalsTopCmd routes the top-level !rivals command: "!rivals board" is
|
||||
// the room-wide standings; anything else defers to the per-user record view.
|
||||
func (p *AdventurePlugin) handleRivalsTopCmd(ctx MessageContext, args string) error {
|
||||
if strings.EqualFold(strings.TrimSpace(args), "board") {
|
||||
board, err := loadRivalsBoard(rivalsBoardLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, renderRivalsBoard(board, time.Now().UTC()))
|
||||
}
|
||||
return p.handleRivalsCmd(ctx)
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func townTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
// TestTownLoaders_ExerciseRealSchema seeds each source table and runs every
|
||||
// loader against a real DB, catching column typos / GROUP BY errors that the
|
||||
// pure render tests can't.
|
||||
func TestTownLoaders_ExerciseRealSchema(t *testing.T) {
|
||||
townTestDB(t)
|
||||
|
||||
alice := id.UserID("@alice:test.invalid")
|
||||
bob := id.UserID("@bob:test.invalid")
|
||||
|
||||
if err := upsertPlayerMetaDisplayName(alice, "Alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := upsertPlayerMetaDisplayName(bob, "Bob"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Civic pride.
|
||||
trackTaxPaid(alice, 5000)
|
||||
trackTaxPaid(bob, 100)
|
||||
civic, err := loadCivicPrideBoard(townCivicBoardLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("loadCivicPrideBoard: %v", err)
|
||||
}
|
||||
if len(civic) != 2 || civic[0].Name != "Alice" || civic[0].TotalPaid != 5000 {
|
||||
t.Fatalf("civic board wrong: %+v", civic)
|
||||
}
|
||||
|
||||
// Housing street.
|
||||
if err := upsertPlayerMetaHouseState(alice, HouseState{Tier: 4}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := upsertPlayerMetaHouseState(bob, HouseState{Tier: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
houses, err := loadHousingStreet(townListCap)
|
||||
if err != nil {
|
||||
t.Fatalf("loadHousingStreet: %v", err)
|
||||
}
|
||||
if len(houses) != 2 || houses[0].Tier != 4 {
|
||||
t.Fatalf("housing street wrong: %+v", houses)
|
||||
}
|
||||
|
||||
// Pet showcase — only arrived, non-chased pets appear.
|
||||
if err := upsertPlayerMetaPetState(alice, PetState{Type: "dog", Name: "Rex", Level: 5, ArmorTier: 2, Arrived: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := upsertPlayerMetaPetState(bob, PetState{Type: "cat", Name: "Ghost", Level: 3, Arrived: true, ChasedAway: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pets, err := loadPetShowcase(townListCap)
|
||||
if err != nil {
|
||||
t.Fatalf("loadPetShowcase: %v", err)
|
||||
}
|
||||
if len(pets) != 1 || !strings.Contains(pets[0].Name, "Rex") || pets[0].ArmorName != "Chain Dog Barding" {
|
||||
t.Fatalf("pet showcase wrong (chased-away pet should be hidden): %+v", pets)
|
||||
}
|
||||
|
||||
// Graveyard.
|
||||
deadUntil := time.Now().Add(6 * time.Hour).UTC()
|
||||
if err := upsertPlayerMetaDeathState(alice, DeathState{
|
||||
Alive: false, DeadUntil: &deadUntil, LastDeathDate: "2026-07-10",
|
||||
DeathSource: "arena", DeathLocation: "the Colosseum",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
graves, err := loadGraveyard(graveyardLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("loadGraveyard: %v", err)
|
||||
}
|
||||
if len(graves) != 1 || graves[0].Name != "Alice" || !graves[0].StillDown || graves[0].Source != "arena" {
|
||||
t.Fatalf("graveyard wrong: %+v", graves)
|
||||
}
|
||||
|
||||
// Rivals board — directed pair ledger summed per user.
|
||||
upsertRivalRecord(alice, bob, true)
|
||||
upsertRivalRecord(alice, bob, true)
|
||||
upsertRivalRecord(bob, alice, false)
|
||||
board, err := loadRivalsBoard(rivalsBoardLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("loadRivalsBoard: %v", err)
|
||||
}
|
||||
if len(board) != 2 || board[0].Name != "Alice" || board[0].Wins != 2 {
|
||||
t.Fatalf("rivals board wrong: %+v", board)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTownRegistry_AllThreeBoards(t *testing.T) {
|
||||
civic := []civicEntry{{Name: "Alice", TotalPaid: 1234567}, {Name: "Bob", TotalPaid: 500}}
|
||||
houses := []housingEntry{{Name: "Alice", Tier: 4}, {Name: "Bob", Tier: 1}}
|
||||
pets := []petShowcaseEntry{{Name: "Rex (Alice)", Type: "dog", Level: 7, ArmorName: "Plate Dog Barding"}}
|
||||
|
||||
out := renderTownRegistry(civic, houses, pets)
|
||||
|
||||
for _, want := range []string{
|
||||
"Civic Pride", "€1,234,567", "Alice",
|
||||
"Housing Street", "Established", "Base House",
|
||||
"Pet Showcase", "Rex (Alice)", "L7 dog", "Plate Dog Barding",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("town registry missing %q\n---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTownRegistry_EmptyBoardsDontPanic(t *testing.T) {
|
||||
out := renderTownRegistry(nil, nil, nil)
|
||||
for _, want := range []string{"Civic Pride", "Housing Street", "Pet Showcase"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("empty registry missing header %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetArmorName(t *testing.T) {
|
||||
if got := petArmorName("dog", 0); got != "" {
|
||||
t.Errorf("tier 0 should be no armor, got %q", got)
|
||||
}
|
||||
if got := petArmorName("dog", 3); got != "Plate Dog Barding" {
|
||||
t.Errorf("dog tier 3 = %q, want Plate Dog Barding", got)
|
||||
}
|
||||
if got := petArmorName("cat", 5); got != "Dragonscale Cat Armor" {
|
||||
t.Errorf("cat tier 5 = %q, want Dragonscale Cat Armor", got)
|
||||
}
|
||||
if got := petArmorName("dog", 99); got != "" {
|
||||
t.Errorf("out-of-range tier should be empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderGraveyard_StillDownVsRecovered(t *testing.T) {
|
||||
now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
|
||||
deaths := []graveEntry{
|
||||
{Name: "Alice", Source: "arena", Location: "the Colosseum", LastDeathDate: "2026-07-10", StillDown: true},
|
||||
{Name: "Bob", Source: "adventure", Location: "", LastDeathDate: "2026-07-08", StillDown: false},
|
||||
}
|
||||
out := renderGraveyard(deaths, now)
|
||||
|
||||
if !strings.Contains(out, "fell in the arena") {
|
||||
t.Errorf("arena death should read distinctly\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "parts unknown") {
|
||||
t.Errorf("blank location should fall back\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "still resting") {
|
||||
t.Errorf("Alice is still down\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "back on their feet") {
|
||||
t.Errorf("Bob has recovered\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "today") || !strings.Contains(out, "2 days ago") {
|
||||
t.Errorf("relative dates missing\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderGraveyard_Empty(t *testing.T) {
|
||||
out := renderGraveyard(nil, time.Now())
|
||||
if !strings.Contains(out, "garden's quiet") {
|
||||
t.Errorf("empty graveyard should be flavored\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRivalsBoard_OrdersAndFormats(t *testing.T) {
|
||||
now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
|
||||
yesterday := now.Add(-24 * time.Hour)
|
||||
board := []rivalBoardEntry{
|
||||
{Name: "Alice", Wins: 9, Losses: 2, LastDuelAt: &yesterday},
|
||||
{Name: "Bob", Wins: 3, Losses: 5, LastDuelAt: nil},
|
||||
}
|
||||
out := renderRivalsBoard(board, now)
|
||||
|
||||
if !strings.Contains(out, "9W - 2L") {
|
||||
t.Errorf("Alice's record missing\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "1 day ago") {
|
||||
t.Errorf("relative last-duel missing\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "last duel: —") {
|
||||
t.Errorf("Bob has no recorded duel time\n%s", out)
|
||||
}
|
||||
// Alice ranks above Bob.
|
||||
if strings.Index(out, "Alice") > strings.Index(out, "Bob") {
|
||||
t.Errorf("board should be ordered by wins\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanizeDeathDate(t *testing.T) {
|
||||
now := time.Date(2026, 7, 10, 6, 0, 0, 0, time.UTC)
|
||||
cases := map[string]string{
|
||||
"2026-07-10": "today",
|
||||
"2026-07-09": "yesterday",
|
||||
"2026-07-05": "5 days ago",
|
||||
"": "some time ago",
|
||||
"garbage": "garbage",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := humanizeDeathDate(in, now); got != want {
|
||||
t.Errorf("humanizeDeathDate(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncName(t *testing.T) {
|
||||
if got := truncName("short", 18); got != "short" {
|
||||
t.Errorf("short name unchanged, got %q", got)
|
||||
}
|
||||
long := truncName("this-is-a-very-long-display-name", 10)
|
||||
if len([]rune(long)) != 10 {
|
||||
t.Errorf("truncated to %d runes, want 10: %q", len([]rune(long)), long)
|
||||
}
|
||||
if !strings.HasSuffix(long, "…") {
|
||||
t.Errorf("truncated name should end with ellipsis: %q", long)
|
||||
}
|
||||
}
|
||||
@@ -50,26 +50,6 @@ var advTreasureDropRates = map[int]float64{
|
||||
|
||||
const advMaxTreasures = 3
|
||||
|
||||
// houseTierTrophyRoom is the T3 "Comfortable" house that unlocks the trophy
|
||||
// room — a fourth treasure slot.
|
||||
const houseTierTrophyRoom = 3
|
||||
|
||||
// maxTreasuresForTier returns how many treasures a player may hold. The base
|
||||
// cap is 3; a T3+ house adds the trophy-room slot for a 4th.
|
||||
//
|
||||
// The extra slot is a housing perk, but HouseTier is only ever written upward
|
||||
// (purchase paths; there is no foreclosure/downgrade that writes it back down),
|
||||
// so a player can only ever hold a 4th treasure while tier>=3. There is
|
||||
// therefore no reachable state where a held treasure must be revoked at
|
||||
// bonus-computation time, and the cap is enforced only at the save gate.
|
||||
func maxTreasuresForTier(houseTier int) int {
|
||||
n := advMaxTreasures
|
||||
if houseTier >= houseTierTrophyRoom {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ── Treasure Definitions ─────────────────────────────────────────────────────
|
||||
|
||||
var advAllTreasures = map[int][]AdvTreasureDef{
|
||||
|
||||
@@ -79,13 +79,3 @@ func TestAdvLocForZone(t *testing.T) {
|
||||
t.Errorf("name = %q, want the zone's display name", loc.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaxTreasuresForTier pins the T3 "trophy room" 4th slot: the base cap is
|
||||
// 3, and only a tier-3+ home lifts it to 4.
|
||||
func TestMaxTreasuresForTier(t *testing.T) {
|
||||
for tier, want := range map[int]int{0: 3, 1: 3, 2: 3, 3: 4, 4: 4} {
|
||||
if got := maxTreasuresForTier(tier); got != want {
|
||||
t.Errorf("maxTreasuresForTier(%d) = %d, want %d", tier, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func twinBeeMaxTier() int {
|
||||
if !c.Alive {
|
||||
continue
|
||||
}
|
||||
combined := combinedAdvLevel(&c)
|
||||
combined := dndLevelForUser(c.UserID) + c.MiningSkill + c.ForagingSkill + c.FishingSkill
|
||||
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 = combinedAdvLevel(&c)
|
||||
weight = dndLevelForUser(c.UserID) + c.MiningSkill + c.ForagingSkill + c.FishingSkill
|
||||
if weight < 4 {
|
||||
weight = 4
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// N4/E1 — the Tier-4 "Established" home unlocks a vault: a fixed pool of slots
|
||||
// that shelters items from `!sell all`, crafting, and combat consumption. It is
|
||||
// pure storage — a vaulted item drops out of every play surface (loadAdvInventory
|
||||
// filters it) until it is taken back — and the plumbing E2 gifting builds on.
|
||||
const (
|
||||
vaultCapacity = 10 // slots an Established home provides
|
||||
vaultMinHouseTier = 4 // Tier 4 "Established" gates the vault
|
||||
)
|
||||
|
||||
// vaultUnlocked reports whether the character's house tier grants a vault, and a
|
||||
// player-facing refusal line when it does not.
|
||||
func vaultUnlocked(char *AdventureCharacter) (bool, string) {
|
||||
if char.HouseTier >= vaultMinHouseTier {
|
||||
return true, ""
|
||||
}
|
||||
return false, "🔒 A vault comes with an **Established** home (Tier 4). Upgrade your house at `!thom` to unlock " +
|
||||
fmt.Sprintf("%d slots of protected storage — vaulted items are safe from `!sell all` and never spent in combat.", vaultCapacity)
|
||||
}
|
||||
|
||||
// handleVaultCmd routes `!adventure vault [store|take] <item>`. The reply-building
|
||||
// core is client-free (renderVault / vaultStoreItem / vaultTakeItem) so it is
|
||||
// unit-testable without a Matrix stub; the handler only loads the character and
|
||||
// sends the result.
|
||||
func (p *AdventurePlugin) handleVaultCmd(ctx MessageContext, args string) 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.")
|
||||
}
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "You're dead. The vault stays locked.")
|
||||
}
|
||||
if ok, refusal := vaultUnlocked(char); !ok {
|
||||
return p.SendDM(ctx.Sender, refusal)
|
||||
}
|
||||
|
||||
lower := strings.ToLower(strings.TrimSpace(args))
|
||||
var reply string
|
||||
switch {
|
||||
case lower == "" || lower == "list":
|
||||
reply = renderVault(ctx.Sender)
|
||||
case strings.HasPrefix(lower, "store "):
|
||||
// Serialize a player's own store/take so two near-simultaneous stores
|
||||
// can't both pass the capacity check and overfill the vault.
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
reply = vaultStoreItem(ctx.Sender, strings.TrimSpace(args[len("store "):]))
|
||||
userMu.Unlock()
|
||||
case strings.HasPrefix(lower, "take "):
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
reply = vaultTakeItem(ctx.Sender, strings.TrimSpace(args[len("take "):]))
|
||||
userMu.Unlock()
|
||||
default:
|
||||
reply = "Usage: `!adventure vault` to view · `!adventure vault store <item>` · `!adventure vault take <item>`."
|
||||
}
|
||||
return p.SendDM(ctx.Sender, reply)
|
||||
}
|
||||
|
||||
// renderVault shows what is stowed and how many slots remain.
|
||||
func renderVault(userID id.UserID) string {
|
||||
items, err := loadAdvVault(userID)
|
||||
if err != nil {
|
||||
return "The vault door won't budge. Try again in a moment."
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🏛️ **Your Vault** — %d / %d slots\n\n", len(items), vaultCapacity))
|
||||
if len(items) == 0 {
|
||||
sb.WriteString("Empty. `!adventure vault store <item>` to shelter something from sale or use.")
|
||||
return sb.String()
|
||||
}
|
||||
for _, it := range items {
|
||||
name := it.Name
|
||||
if it.Temper > 0 {
|
||||
name = fmt.Sprintf("%s +%d", name, it.Temper)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("• %s — €%d\n", name, it.Value))
|
||||
}
|
||||
sb.WriteString("\n`!adventure vault take <item>` to bring one back into play.")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// vaultStoreItem moves one matching inventory item into the vault. Guards the
|
||||
// capacity and reports the fresh slot count so the player never has to re-list.
|
||||
func vaultStoreItem(userID id.UserID, query string) string {
|
||||
if query == "" {
|
||||
return "Store what? `!adventure vault store <item>`."
|
||||
}
|
||||
if advVaultCount(userID) >= vaultCapacity {
|
||||
return fmt.Sprintf("The vault is full (%d / %d). Take something out first with `!adventure vault take <item>`.", vaultCapacity, vaultCapacity)
|
||||
}
|
||||
items, err := loadAdvInventory(userID)
|
||||
if err != nil {
|
||||
return "Couldn't reach your inventory. Try again in a moment."
|
||||
}
|
||||
match := findInventoryMatch(items, query)
|
||||
if match == nil {
|
||||
return fmt.Sprintf("No inventory item matches %q. Check `!adventure inventory`.", query)
|
||||
}
|
||||
moved, err := setItemVaulted(userID, match.ID, true)
|
||||
if err != nil || !moved {
|
||||
return "Couldn't stow that item. Try again in a moment."
|
||||
}
|
||||
return fmt.Sprintf("🏛️ Stowed **%s** in the vault. %d / %d slots used.", match.Name, advVaultCount(userID), vaultCapacity)
|
||||
}
|
||||
|
||||
// vaultTakeItem returns one matching vaulted item to the active inventory.
|
||||
func vaultTakeItem(userID id.UserID, query string) string {
|
||||
if query == "" {
|
||||
return "Take what? `!adventure vault take <item>`."
|
||||
}
|
||||
items, err := loadAdvVault(userID)
|
||||
if err != nil {
|
||||
return "The vault door won't budge. Try again in a moment."
|
||||
}
|
||||
match := findInventoryMatch(items, query)
|
||||
if match == nil {
|
||||
return fmt.Sprintf("Nothing in the vault matches %q. Check `!adventure vault`.", query)
|
||||
}
|
||||
moved, err := setItemVaulted(userID, match.ID, false)
|
||||
if err != nil || !moved {
|
||||
return "Couldn't retrieve that item. Try again in a moment."
|
||||
}
|
||||
return fmt.Sprintf("Took **%s** out of the vault and back into your pack. %d / %d slots used.", match.Name, advVaultCount(userID), vaultCapacity)
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func vaultTestDB(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 seedVaultItem(t *testing.T, user id.UserID, name string, value int64) {
|
||||
t.Helper()
|
||||
if err := addAdvInventoryItem(user, AdvItem{Name: name, Type: "treasure", Tier: 3, Value: value}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVaultStoreTakeRoundTrip: an item vaulted leaves the active inventory and
|
||||
// joins the vault; taken back, it reverses exactly.
|
||||
func TestVaultStoreTakeRoundTrip(t *testing.T) {
|
||||
vaultTestDB(t)
|
||||
user := id.UserID("@vault:test.invalid")
|
||||
seedVaultItem(t, user, "Jeweled Crown", 5000)
|
||||
|
||||
if got := vaultStoreItem(user, "crown"); !strings.Contains(got, "Stowed") {
|
||||
t.Fatalf("store reply = %q, want a stow confirmation", got)
|
||||
}
|
||||
|
||||
inv, _ := loadAdvInventory(user)
|
||||
if len(inv) != 0 {
|
||||
t.Fatalf("active inventory = %d items, want 0 (item is vaulted)", len(inv))
|
||||
}
|
||||
vault, _ := loadAdvVault(user)
|
||||
if len(vault) != 1 || vault[0].Name != "Jeweled Crown" {
|
||||
t.Fatalf("vault = %+v, want the crown", vault)
|
||||
}
|
||||
|
||||
if got := vaultTakeItem(user, "crown"); !strings.Contains(got, "back into your pack") {
|
||||
t.Fatalf("take reply = %q, want a retrieval confirmation", got)
|
||||
}
|
||||
inv, _ = loadAdvInventory(user)
|
||||
if len(inv) != 1 {
|
||||
t.Fatalf("active inventory = %d items, want 1 after take", len(inv))
|
||||
}
|
||||
if n := advVaultCount(user); n != 0 {
|
||||
t.Fatalf("vault count = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVaultCapacity: the 10th item stores, the 11th is refused, and the refusal
|
||||
// leaves the item in the active inventory.
|
||||
func TestVaultCapacity(t *testing.T) {
|
||||
vaultTestDB(t)
|
||||
user := id.UserID("@vaultcap:test.invalid")
|
||||
for i := 0; i < vaultCapacity; i++ {
|
||||
seedVaultItem(t, user, "Gem", 100)
|
||||
if got := vaultStoreItem(user, "Gem"); !strings.Contains(got, "Stowed") {
|
||||
t.Fatalf("store %d reply = %q, want success", i, got)
|
||||
}
|
||||
}
|
||||
if n := advVaultCount(user); n != vaultCapacity {
|
||||
t.Fatalf("vault count = %d, want %d", n, vaultCapacity)
|
||||
}
|
||||
|
||||
seedVaultItem(t, user, "Overflow Gem", 100)
|
||||
got := vaultStoreItem(user, "Overflow Gem")
|
||||
if !strings.Contains(got, "full") {
|
||||
t.Fatalf("11th store reply = %q, want a full-vault refusal", got)
|
||||
}
|
||||
// The refused item is still in play.
|
||||
inv, _ := loadAdvInventory(user)
|
||||
if len(inv) != 1 || inv[0].Name != "Overflow Gem" {
|
||||
t.Fatalf("active inventory = %+v, want the overflow gem left in play", inv)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVaultShieldsFromSellAll: `!sell all` liquidates loadAdvInventory, which a
|
||||
// vaulted item is no longer part of — the whole point of the vault.
|
||||
func TestVaultShieldsFromSellAll(t *testing.T) {
|
||||
vaultTestDB(t)
|
||||
user := id.UserID("@vaultsell:test.invalid")
|
||||
seedVaultItem(t, user, "Heirloom", 9000)
|
||||
seedVaultItem(t, user, "Junk", 5)
|
||||
|
||||
if got := vaultStoreItem(user, "Heirloom"); !strings.Contains(got, "Stowed") {
|
||||
t.Fatalf("store reply = %q", got)
|
||||
}
|
||||
// What sell-all would consume:
|
||||
sellable, _ := loadAdvInventory(user)
|
||||
if len(sellable) != 1 || sellable[0].Name != "Junk" {
|
||||
t.Fatalf("sellable inventory = %+v, want only Junk (heirloom sheltered)", sellable)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVaultGate: below Tier 4 the vault is locked; at Tier 4 it opens.
|
||||
func TestVaultGate(t *testing.T) {
|
||||
locked := &AdventureCharacter{HouseTier: 3}
|
||||
if ok, msg := vaultUnlocked(locked); ok || msg == "" {
|
||||
t.Fatalf("tier 3: ok=%v msg=%q, want locked with a refusal", ok, msg)
|
||||
}
|
||||
open := &AdventureCharacter{HouseTier: 4}
|
||||
if ok, msg := vaultUnlocked(open); !ok || msg != "" {
|
||||
t.Fatalf("tier 4: ok=%v msg=%q, want unlocked", ok, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetItemVaultedIsOwnerScoped: another user's id cannot be flipped.
|
||||
func TestSetItemVaultedIsOwnerScoped(t *testing.T) {
|
||||
vaultTestDB(t)
|
||||
owner := id.UserID("@owner:test.invalid")
|
||||
intruder := id.UserID("@intruder:test.invalid")
|
||||
seedVaultItem(t, owner, "Relic", 1000)
|
||||
items, _ := loadAdvInventory(owner)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("setup: owner inventory = %d, want 1", len(items))
|
||||
}
|
||||
moved, err := setItemVaulted(intruder, items[0].ID, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if moved {
|
||||
t.Fatalf("intruder moved the owner's item; want no-op")
|
||||
}
|
||||
if advVaultCount(owner) != 0 {
|
||||
t.Fatalf("owner's item was vaulted by an intruder")
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Well-rested — a long rest taken at a home of tier 2+ grants a temporary buff
|
||||
// that lasts until the next long rest. Renting a room at Thom Krooke's inn does
|
||||
// not: the perk exists to give owning (and upgrading) a house a payoff the inn
|
||||
// can't match, and it turns the dead T3/T4 prestige tiers into something worth
|
||||
// buying.
|
||||
//
|
||||
// Two halves, both scaling with house tier (2 < 3 < 4):
|
||||
// - wellRestedTempHP: a temporary hit-point cushion, layered above MaxHP for
|
||||
// the day's fights via applyDnDHPScaling; and
|
||||
// - wellRestedSlotBonus: bonus spell slots folded into the caster's pool at
|
||||
// their highest available slot level.
|
||||
//
|
||||
// The tuning below is deliberately generous toward casters, who trail on
|
||||
// spell-pool richness — the bonus slots are the substantive reward and the
|
||||
// temp HP is a lighter feel-good cushion. All values are tunable.
|
||||
//
|
||||
// Nothing here is a secret discovery mechanic (cf. Misty/Arina), so the rest
|
||||
// message surfaces exactly what was granted.
|
||||
|
||||
// homeRestTempHPPct maps house tier → temp-HP fraction of MaxHP.
|
||||
func homeRestTempHPPct(houseTier int) float64 {
|
||||
switch {
|
||||
case houseTier >= 4:
|
||||
return 0.16
|
||||
case houseTier >= 3:
|
||||
return 0.12
|
||||
case houseTier >= 2:
|
||||
return 0.08
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// homeRestBonusSlots maps house tier → number of bonus spell slots.
|
||||
func homeRestBonusSlots(houseTier int) int {
|
||||
switch {
|
||||
case houseTier >= 4:
|
||||
return 3
|
||||
case houseTier >= 3:
|
||||
return 2
|
||||
case houseTier >= 2:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// wellRestedTempHP returns the temporary HP granted by a home long rest at the
|
||||
// given house tier. Tier 1 and the inn grant none. Always at least 1 when the
|
||||
// tier qualifies, so a low-HP character still sees the buff.
|
||||
func wellRestedTempHP(hpMax, houseTier int) int {
|
||||
pct := homeRestTempHPPct(houseTier)
|
||||
if pct <= 0 {
|
||||
return 0
|
||||
}
|
||||
hp := int(float64(hpMax) * pct)
|
||||
if hp < 1 {
|
||||
hp = 1
|
||||
}
|
||||
return hp
|
||||
}
|
||||
|
||||
// wellRestedSlotBonus returns the temporary bonus spell slots granted by a home
|
||||
// long rest, placed entirely at the caster's highest available slot level (the
|
||||
// most valuable, and one a caster is guaranteed to have). Tier 1, the inn, and
|
||||
// non-casters (empty pool) grant none.
|
||||
func wellRestedSlotBonus(houseTier int, pool map[int]int) map[int]int {
|
||||
n := homeRestBonusSlots(houseTier)
|
||||
if n == 0 || len(pool) == 0 {
|
||||
return nil
|
||||
}
|
||||
highest := 0
|
||||
for lvl := range pool {
|
||||
if lvl > highest {
|
||||
highest = lvl
|
||||
}
|
||||
}
|
||||
if highest == 0 {
|
||||
return nil
|
||||
}
|
||||
return map[int]int{highest: n}
|
||||
}
|
||||
|
||||
// wellRestedSummary renders the player-facing description of a granted buff,
|
||||
// e.g. "+18 temp HP, +2 level-5 spell slots". Returns "" when nothing was
|
||||
// granted (tier 1, the inn, or a non-caster with no temp HP).
|
||||
func wellRestedSummary(tempHP int, slotBonus map[int]int) string {
|
||||
var bits []string
|
||||
if tempHP > 0 {
|
||||
bits = append(bits, fmt.Sprintf("+%d temp HP", tempHP))
|
||||
}
|
||||
for lvl, n := range slotBonus {
|
||||
if n <= 0 {
|
||||
continue
|
||||
}
|
||||
plural := ""
|
||||
if n != 1 {
|
||||
plural = "s"
|
||||
}
|
||||
bits = append(bits, fmt.Sprintf("+%d level-%d spell slot%s", n, lvl, plural))
|
||||
}
|
||||
return strings.Join(bits, ", ")
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func TestWellRestedTempHP_ByTier(t *testing.T) {
|
||||
cases := []struct {
|
||||
tier, hpMax, want int
|
||||
}{
|
||||
{0, 100, 0}, // no house
|
||||
{1, 100, 0}, // tier-1 shack: nothing
|
||||
{2, 100, 8}, // 8%
|
||||
{3, 100, 12}, // 12%
|
||||
{4, 100, 16}, // 16%
|
||||
{2, 3, 1}, // rounds to <1 → floored to 1
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := wellRestedTempHP(c.hpMax, c.tier); got != c.want {
|
||||
t.Errorf("wellRestedTempHP(hpMax=%d, tier=%d) = %d, want %d", c.hpMax, c.tier, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWellRestedSlotBonus_PlacesAtHighestLevel(t *testing.T) {
|
||||
pool := map[int]int{1: 4, 2: 3, 3: 2} // highest available = 3
|
||||
|
||||
if got := wellRestedSlotBonus(1, pool); got != nil {
|
||||
t.Errorf("tier 1 → %v, want nil", got)
|
||||
}
|
||||
if got := wellRestedSlotBonus(0, pool); got != nil {
|
||||
t.Errorf("tier 0 → %v, want nil", got)
|
||||
}
|
||||
if got := wellRestedSlotBonus(3, nil); got != nil {
|
||||
t.Errorf("non-caster (nil pool) → %v, want nil", got)
|
||||
}
|
||||
|
||||
for tier, wantN := range map[int]int{2: 1, 3: 2, 4: 3} {
|
||||
got := wellRestedSlotBonus(tier, pool)
|
||||
if len(got) != 1 || got[3] != wantN {
|
||||
t.Errorf("tier %d → %v, want {3: %d}", tier, got, wantN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDnDHPScaling_TempHPCushion(t *testing.T) {
|
||||
// Golden-safety: TempHP==0 must leave a full-HP character untouched
|
||||
// (StartHP stays 0 = "enter at MaxHP", MaxHP unchanged).
|
||||
stats := CombatStats{MaxHP: 100, HPBonus: 10}
|
||||
full := &DnDCharacter{HPMax: 100, HPCurrent: 100, TempHP: 0}
|
||||
applyDnDHPScaling(&stats, full)
|
||||
if stats.MaxHP != 100 || stats.StartHP != 0 {
|
||||
t.Fatalf("TempHP=0 full HP: MaxHP=%d StartHP=%d, want 100/0", stats.MaxHP, stats.StartHP)
|
||||
}
|
||||
|
||||
// Full HP + cushion: MaxHP grows by TempHP, StartHP stays the sentinel so
|
||||
// combat enters at the bumped max (full + cushion).
|
||||
stats = CombatStats{MaxHP: 100, HPBonus: 10}
|
||||
rested := &DnDCharacter{HPMax: 100, HPCurrent: 100, TempHP: 15}
|
||||
applyDnDHPScaling(&stats, rested)
|
||||
if stats.MaxHP != 115 || stats.StartHP != 0 {
|
||||
t.Fatalf("full HP + cushion: MaxHP=%d StartHP=%d, want 115/0", stats.MaxHP, stats.StartHP)
|
||||
}
|
||||
|
||||
// Wounded + cushion: entry HP is current + gear bonus + cushion, MaxHP grows.
|
||||
stats = CombatStats{MaxHP: 100, HPBonus: 10}
|
||||
wounded := &DnDCharacter{HPMax: 100, HPCurrent: 40, TempHP: 15}
|
||||
applyDnDHPScaling(&stats, wounded)
|
||||
if stats.MaxHP != 115 || stats.StartHP != 65 { // 40 + 10 + 15
|
||||
t.Fatalf("wounded + cushion: MaxHP=%d StartHP=%d, want 115/65", stats.MaxHP, stats.StartHP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLongRestSpellSlots_GrantAndExpiry(t *testing.T) {
|
||||
if err := db.Init(t.TempDir()); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
uid := id.UserID("@rested_caster:example")
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassCleric, Level: 5,
|
||||
STR: 10, DEX: 12, CON: 14, INT: 10, WIS: 16, CHA: 10,
|
||||
}
|
||||
c.HPMax = computeMaxHP(c.Class, abilityModifier(c.CON), c.Level)
|
||||
c.HPCurrent = c.HPMax
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatalf("SaveDnDCharacter: %v", err)
|
||||
}
|
||||
if err := setSpellSlotsForCharacter(c); err != nil {
|
||||
t.Fatalf("setSpellSlotsForCharacter: %v", err)
|
||||
}
|
||||
|
||||
base, _ := getSpellSlots(uid)
|
||||
if len(base) == 0 {
|
||||
t.Fatal("cleric L5 should have a base slot pool")
|
||||
}
|
||||
highest := 0
|
||||
for lvl := range base {
|
||||
if lvl > highest {
|
||||
highest = lvl
|
||||
}
|
||||
}
|
||||
|
||||
// Home rest at tier 4 → +3 at highest level, used reset to 0.
|
||||
bonus, err := applyLongRestSpellSlots(c, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("applyLongRestSpellSlots(tier4): %v", err)
|
||||
}
|
||||
if bonus[highest] != 3 {
|
||||
t.Fatalf("tier-4 bonus = %v, want {%d: 3}", bonus, highest)
|
||||
}
|
||||
after, _ := getSpellSlots(uid)
|
||||
if after[highest][0] != base[highest][0]+3 {
|
||||
t.Errorf("highest total = %d, want base+3 = %d", after[highest][0], base[highest][0]+3)
|
||||
}
|
||||
for lvl, pair := range after {
|
||||
if pair[1] != 0 {
|
||||
t.Errorf("slot L%d used = %d after rest, want 0", lvl, pair[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Next rest at the inn (tier 0) → bonus expires, totals back to base.
|
||||
if _, err := applyLongRestSpellSlots(c, 0); err != nil {
|
||||
t.Fatalf("applyLongRestSpellSlots(tier0): %v", err)
|
||||
}
|
||||
reset, _ := getSpellSlots(uid)
|
||||
for lvl, pair := range reset {
|
||||
if pair[0] != base[lvl][0] {
|
||||
t.Errorf("L%d total = %d after expiry, want base %d", lvl, pair[0], base[lvl][0])
|
||||
}
|
||||
}
|
||||
|
||||
// Tier-1 house grants nothing.
|
||||
if bonus, _ := applyLongRestSpellSlots(c, 1); bonus != nil {
|
||||
t.Errorf("tier-1 bonus = %v, want nil", bonus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLongRestSpellSlots_NonCaster(t *testing.T) {
|
||||
if err := db.Init(t.TempDir()); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
uid := id.UserID("@rested_fighter:example")
|
||||
c := &DnDCharacter{UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5, CON: 14}
|
||||
c.HPMax = computeMaxHP(c.Class, abilityModifier(c.CON), c.Level)
|
||||
c.HPCurrent = c.HPMax
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatalf("SaveDnDCharacter: %v", err)
|
||||
}
|
||||
|
||||
bonus, err := applyLongRestSpellSlots(c, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("applyLongRestSpellSlots: %v", err)
|
||||
}
|
||||
if bonus != nil {
|
||||
t.Errorf("non-caster bonus = %v, want nil", bonus)
|
||||
}
|
||||
if slots, _ := getSpellSlots(uid); len(slots) != 0 {
|
||||
t.Errorf("non-caster has %d slot rows, want 0", len(slots))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHomeWorkshop pins the T3 workshop craft bonus and its effect on the
|
||||
// success-rate cap.
|
||||
func TestHomeWorkshop(t *testing.T) {
|
||||
for tier, want := range map[int]float64{0: 0, 1: 0, 2: 0, 3: 0.05, 4: 0.05} {
|
||||
if got := homeWorkshopCraftBonus(tier); got != want {
|
||||
t.Errorf("homeWorkshopCraftBonus(%d) = %.2f, want %.2f", tier, got, want)
|
||||
}
|
||||
}
|
||||
// No workshop: base cap holds at 0.95 for a maxed forager.
|
||||
if got := craftingSuccessRate(100, 1, 0); got != 0.95 {
|
||||
t.Errorf("maxed forager, no workshop = %.3f, want 0.95", got)
|
||||
}
|
||||
// Workshop lifts the cap to 0.98.
|
||||
if got := craftingSuccessRate(100, 1, 0.05); got != 0.98 {
|
||||
t.Errorf("maxed forager, workshop = %.3f, want 0.98", got)
|
||||
}
|
||||
// Mid-level forager gets the flat +5% added below the cap.
|
||||
base := craftingSuccessRate(15, 10, 0)
|
||||
withShop := craftingSuccessRate(15, 10, 0.05)
|
||||
if diff := withShop - base; diff < 0.049 || diff > 0.051 {
|
||||
t.Errorf("workshop delta = %.3f, want ~0.05", diff)
|
||||
}
|
||||
}
|
||||
@@ -1,784 +0,0 @@
|
||||
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())
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// An armed ability is consumed once, at fight start, and its id is parked on the
|
||||
// seat. Everything here defends that split.
|
||||
//
|
||||
// Before it existed, buildZoneCombatants consumed the ability itself — and the
|
||||
// turn-based engine calls that builder again on every !attack. So a Berserker
|
||||
// raged on round 1, the builder cleared their armed flag and saved, and round 2
|
||||
// rebuilt them with no rage at all. They paid stamina for one round of a buff
|
||||
// that is supposed to span the fight, and the close-out could not see the rage
|
||||
// it was supposed to charge exhaustion for.
|
||||
|
||||
// ragingBerserker is a Fighter who has already `!arm`ed rage.
|
||||
func ragingBerserker(t *testing.T, uid id.UserID) *DnDCharacter {
|
||||
t.Helper()
|
||||
if err := createAdvCharacter(uid, string(uid)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Subclass: SubclassBerserker, Level: 5,
|
||||
STR: 16, DEX: 12, CON: 14, INT: 10, WIS: 10, CHA: 10,
|
||||
HPMax: 40, HPCurrent: 40, ArmorClass: 15,
|
||||
ArmedAbility: "rage",
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ── the split ────────────────────────────────────────────────────────────────
|
||||
|
||||
// consumeArmedAbility disarms and reports; applyAbilityByID applies and does not
|
||||
// disarm. Calling the second one twice must be indistinguishable from once.
|
||||
func TestApplyAbilityByID_IsPureAndRepeatable(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
c := ragingBerserker(t, "@pure:example.org")
|
||||
|
||||
armed := consumeArmedAbility(c)
|
||||
if armed != "rage" {
|
||||
t.Fatalf("consumeArmedAbility = %q, want rage", armed)
|
||||
}
|
||||
if c.ArmedAbility != "" {
|
||||
t.Errorf("character still armed after consume: %q", c.ArmedAbility)
|
||||
}
|
||||
if again := consumeArmedAbility(c); again != "" {
|
||||
t.Errorf("second consume returned %q, want \"\" — the ability is spent", again)
|
||||
}
|
||||
|
||||
// Same id, applied to two independent mod sets, yields the same rage.
|
||||
for i, want := range []bool{true, true} {
|
||||
var mods CombatModifiers
|
||||
name, fired := applyAbilityByID(c, armed, &mods)
|
||||
if !fired || name != "Rage" {
|
||||
t.Fatalf("apply #%d: fired=%v name=%q", i+1, fired, name)
|
||||
}
|
||||
if mods.BerserkerRage != want {
|
||||
t.Errorf("apply #%d: BerserkerRage = %v, want %v", i+1, mods.BerserkerRage, want)
|
||||
}
|
||||
if mods.RageMeleeDmg != 2 || !mods.PhysicalResistRage {
|
||||
t.Errorf("apply #%d: rage did not carry its full mod set: %+v", i+1, mods)
|
||||
}
|
||||
}
|
||||
|
||||
// And it never writes back to the sheet.
|
||||
got, _ := LoadDnDCharacter(c.UserID)
|
||||
if got.ArmedAbility != "" {
|
||||
t.Errorf("applyAbilityByID re-armed the sheet: %q", got.ArmedAbility)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAbilityByID_UnknownAndEmptyAreNoOps(t *testing.T) {
|
||||
c := &DnDCharacter{Class: ClassFighter}
|
||||
for _, id := range []string{"", "no_such_ability"} {
|
||||
var mods CombatModifiers
|
||||
if _, fired := applyAbilityByID(c, id, &mods); fired {
|
||||
t.Errorf("applyAbilityByID(%q) fired", id)
|
||||
}
|
||||
if mods.BerserkerRage {
|
||||
t.Errorf("applyAbilityByID(%q) set rage", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── the fight-start seat ─────────────────────────────────────────────────────
|
||||
|
||||
// The seat carries the id forward, and the sheet is disarmed exactly once.
|
||||
func TestBuildFightSeats_ConsumesTheAbilityOnceAndCarriesItOnTheSeat(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@berserk:example.org")
|
||||
ragingBerserker(t, uid)
|
||||
|
||||
seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats(
|
||||
uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0)
|
||||
if refusal != "" {
|
||||
t.Fatalf("fight refused: %s", refusal)
|
||||
}
|
||||
if len(seats) != 1 {
|
||||
t.Fatalf("seats = %d, want 1", len(seats))
|
||||
}
|
||||
if seats[0].ArmedAbility != "rage" {
|
||||
t.Errorf("seat.ArmedAbility = %q, want rage", seats[0].ArmedAbility)
|
||||
}
|
||||
if !seats[0].Mods.BerserkerRage {
|
||||
t.Error("seat built without rage in its mods")
|
||||
}
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.ArmedAbility != "" {
|
||||
t.Errorf("sheet still armed after fight start: %q", got.ArmedAbility)
|
||||
}
|
||||
}
|
||||
|
||||
// The regression itself: rebuilding the combatant — what every !attack does —
|
||||
// must reproduce the rage from the persisted id, not from the (now cleared)
|
||||
// sheet. Two rebuilds in a row, both raging.
|
||||
func TestBuildZoneCombatants_RebuildKeepsTheRageForTheWholeFight(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@rebuild:example.org")
|
||||
ragingBerserker(t, uid)
|
||||
p := &AdventurePlugin{}
|
||||
|
||||
seats, _, _, refusal := p.buildFightSeats(uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0)
|
||||
if refusal != "" {
|
||||
t.Fatalf("fight refused: %s", refusal)
|
||||
}
|
||||
armed := seats[0].ArmedAbility
|
||||
|
||||
for round := 2; round <= 3; round++ {
|
||||
player, _, _, err := p.buildZoneCombatants(uid, dndBestiary["goblin"], 1, 0, armed)
|
||||
if err != nil {
|
||||
t.Fatalf("round %d rebuild: %v", round, err)
|
||||
}
|
||||
if !player.Mods.BerserkerRage {
|
||||
t.Errorf("round %d: rage evaporated on rebuild", round)
|
||||
}
|
||||
if player.Mods.RageMeleeDmg != 2 {
|
||||
t.Errorf("round %d: RageMeleeDmg = %d, want 2", round, player.Mods.RageMeleeDmg)
|
||||
}
|
||||
}
|
||||
|
||||
// A seat that armed nothing rebuilds without rage — the id is the only source.
|
||||
player, _, _, err := p.buildZoneCombatants(uid, dndBestiary["goblin"], 1, 0, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if player.Mods.BerserkerRage {
|
||||
t.Error("unarmed rebuild raged anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// A member who is down sits the fight out. They must not be charged the ability
|
||||
// they had readied for it — the refusal is checked before the arm.
|
||||
func TestBuildFightSeats_SatOutMemberKeepsTheirArmedAbility(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader := id.UserID("@lead:example.org")
|
||||
downed := id.UserID("@downed:example.org")
|
||||
fightTestChar(t, leader, 30)
|
||||
ragingBerserker(t, downed)
|
||||
|
||||
// Drop the member to 0 HP with rage still armed.
|
||||
c, _ := LoadDnDCharacter(downed)
|
||||
c.HPCurrent = 0
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats(
|
||||
leader, []id.UserID{leader, downed}, dndBestiary["goblin"], 1, 0)
|
||||
if refusal != "" {
|
||||
t.Fatalf("fight refused: %s", refusal)
|
||||
}
|
||||
if len(seats) != 1 || seats[0].UserID != leader {
|
||||
t.Fatalf("seats = %+v, want the leader alone", seats)
|
||||
}
|
||||
got, _ := LoadDnDCharacter(downed)
|
||||
if got.ArmedAbility != "rage" {
|
||||
t.Errorf("downed member was disarmed for a fight they never joined: %q", got.ArmedAbility)
|
||||
}
|
||||
}
|
||||
|
||||
// ── the close-out ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSeatFightStartMods_ReadsTheRageOffTheSeatsStatuses(t *testing.T) {
|
||||
c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassBerserker, Level: 5}
|
||||
|
||||
raging := &CombatSession{}
|
||||
raging.Statuses.ArmedAbility = "rage"
|
||||
if !seatFightStartMods(raging, 0, c).BerserkerRage {
|
||||
t.Error("seat 0 armed rage, mods say otherwise")
|
||||
}
|
||||
|
||||
// Seat 1 reads its own statuses, not the leader's.
|
||||
party := &CombatSession{Participants: []CombatParticipant{{Seat: 1}}}
|
||||
party.Statuses.ArmedAbility = "rage"
|
||||
if seatFightStartMods(party, 1, c).BerserkerRage {
|
||||
t.Error("a member inherited the leader's rage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeatCombatResult_ReadsWinAndNearDeathOffTheSession(t *testing.T) {
|
||||
sess := &CombatSession{
|
||||
Status: CombatStatusWon, Round: 4, PlayerHP: 5, PlayerHPMax: 40, EnemyHP: 0,
|
||||
TurnLog: []CombatEvent{
|
||||
{Action: "attack", Seat: 0},
|
||||
{Action: "use_consumable", Seat: 1},
|
||||
},
|
||||
}
|
||||
got := seatCombatResult(sess, 0)
|
||||
if !got.PlayerWon {
|
||||
t.Error("PlayerWon = false on a won session")
|
||||
}
|
||||
if !got.NearDeath {
|
||||
t.Error("5/40 HP is under the 15% near-death line")
|
||||
}
|
||||
if len(got.Events) != 1 || got.Events[0].Action != "attack" {
|
||||
t.Errorf("seat 0 got seat 1's events: %+v", got.Events)
|
||||
}
|
||||
|
||||
sess.PlayerHP = 30
|
||||
if seatCombatResult(sess, 0).NearDeath {
|
||||
t.Error("30/40 HP is not near death")
|
||||
}
|
||||
sess.Status = CombatStatusLost
|
||||
sess.PlayerHP = 0
|
||||
if seatCombatResult(sess, 0).PlayerWon {
|
||||
t.Error("PlayerWon = true on a lost session")
|
||||
}
|
||||
}
|
||||
|
||||
// Item A: the manual kill now costs the Berserker their exhaustion, exactly as
|
||||
// the auto-resolved one always has.
|
||||
func TestPostCombatBookkeepingForSeat_RageCostsExhaustionOnTheTurnBasedPath(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@exhausted:example.org")
|
||||
ragingBerserker(t, uid)
|
||||
|
||||
sess := &CombatSession{
|
||||
UserID: string(uid), Status: CombatStatusWon, Round: 3,
|
||||
PlayerHP: 12, PlayerHPMax: 40,
|
||||
}
|
||||
sess.Statuses.ArmedAbility = "rage"
|
||||
|
||||
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
|
||||
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.Exhaustion != 1 {
|
||||
t.Errorf("Exhaustion = %d after a raging win, want 1", got.Exhaustion)
|
||||
}
|
||||
}
|
||||
|
||||
// A fight nobody raged in leaves the sheet alone.
|
||||
func TestPostCombatBookkeepingForSeat_NoRageNoExhaustion(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@calm:example.org")
|
||||
fightTestChar(t, uid, 30)
|
||||
|
||||
sess := &CombatSession{
|
||||
UserID: string(uid), Status: CombatStatusWon, Round: 2,
|
||||
PlayerHP: 20, PlayerHPMax: 30,
|
||||
}
|
||||
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
|
||||
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.Exhaustion != 0 {
|
||||
t.Errorf("Exhaustion = %d with no rage, want 0", got.Exhaustion)
|
||||
}
|
||||
}
|
||||
|
||||
// Losing while raging still exhausts you — the close-out runs on every terminal
|
||||
// status, not just the win. This is the half the turn-based path used to skip.
|
||||
func TestPostCombatBookkeepingForSeat_RageCostsExhaustionOnALoss(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@dead:example.org")
|
||||
ragingBerserker(t, uid)
|
||||
|
||||
sess := &CombatSession{
|
||||
UserID: string(uid), Status: CombatStatusLost, Round: 6,
|
||||
PlayerHP: 0, PlayerHPMax: 40,
|
||||
}
|
||||
sess.Statuses.ArmedAbility = "rage"
|
||||
|
||||
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
|
||||
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.Exhaustion != 1 {
|
||||
t.Errorf("Exhaustion = %d after a raging loss, want 1", got.Exhaustion)
|
||||
}
|
||||
}
|
||||
@@ -10,30 +10,6 @@ import (
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// postCombatBookkeeping is the close-out every fight owes its character,
|
||||
// whatever surface it was fought on: achievements, and the subclass state that
|
||||
// outlives the fight (Berserker exhaustion, Grim Harvest's kill-heal).
|
||||
//
|
||||
// It exists because there are four close-outs — two auto-resolve
|
||||
// (runDungeonCombat, runZoneCombatRoster) and two turn-based
|
||||
// (finishCombatSession, finishPartyCombatSession) — and for a long time only
|
||||
// the auto-resolve pair ran any of this. The same Elite kill therefore paid out
|
||||
// differently depending on whether the player let it auto-resolve or fought it
|
||||
// a round at a time. Route all four through here and the divergence cannot
|
||||
// silently reopen.
|
||||
//
|
||||
// raged is whether the character's Berserker rage was active for this fight;
|
||||
// mods carries the fight-start modifiers Grim Harvest reads. HP persistence is
|
||||
// NOT done here — the turn-based paths already own their own HP writes.
|
||||
func (p *AdventurePlugin) postCombatBookkeeping(
|
||||
userID id.UserID, dndChar *DnDCharacter, raged bool, result CombatResult, mods CombatModifiers,
|
||||
) {
|
||||
p.grantCombatAchievements(userID, result)
|
||||
if err := persistDnDPostCombatSubclass(dndChar, raged, result, mods); err != nil {
|
||||
slog.Error("dnd: post-combat subclass persist", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// grantCombatAchievements checks combat results for achievement-worthy moments.
|
||||
func (p *AdventurePlugin) grantCombatAchievements(userID id.UserID, result CombatResult) {
|
||||
if p.achievements == nil {
|
||||
@@ -111,7 +87,7 @@ func (p *AdventurePlugin) runDungeonCombat(
|
||||
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
||||
applyMagicItemEffects(&playerStats, &playerMods, userID)
|
||||
trySimAutoArm(dndChar)
|
||||
if firedName, fired := armAbilityForFight(dndChar, &playerMods); fired {
|
||||
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
||||
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
|
||||
}
|
||||
applyDnDDungeonMonsterLayer(&enemyStats, loc.Tier)
|
||||
@@ -153,8 +129,12 @@ func (p *AdventurePlugin) runDungeonCombat(
|
||||
// until a player-driven use command lands.
|
||||
consumeFiredHealingItems(userID, countHealEventsFired(result))
|
||||
|
||||
p.grantCombatAchievements(userID, result)
|
||||
|
||||
persistDnDHPAfterCombat(userID, result.PlayerEndHP)
|
||||
p.postCombatBookkeeping(userID, dndChar, playerMods.BerserkerRage, result, playerMods)
|
||||
if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage, result, playerMods); err != nil {
|
||||
slog.Error("dnd: post-combat subclass persist (dungeon)", "user", userID, "err", err)
|
||||
}
|
||||
|
||||
if xp := dungeonCombatXP(result, loc.Tier); xp > 0 {
|
||||
if _, err := p.grantDnDXP(userID, xp); err != nil {
|
||||
@@ -171,12 +151,7 @@ func (p *AdventurePlugin) loadCombatBonuses(userID id.UserID, char *AdventureCha
|
||||
treasures, _ := loadAdvTreasureBonuses(userID)
|
||||
buffs, _ := loadAdvActiveBuffs(userID)
|
||||
hasGrudge := char.GrudgeLocation != ""
|
||||
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
|
||||
return computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
|
||||
}
|
||||
|
||||
// loadConsumableInventory scans inventory for items matching consumable definitions.
|
||||
@@ -190,7 +165,7 @@ func (p *AdventurePlugin) loadConsumableInventory(userID id.UserID) []Consumable
|
||||
// Auto-craft if player has foraging level 10+
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err == nil && char.ForagingSkill >= 10 {
|
||||
craftResults, updatedItems := autoCraftConsumables(userID, items, char.ForagingSkill, homeWorkshopCraftBonus(char.HouseTier))
|
||||
craftResults, updatedItems := autoCraftConsumables(userID, items, char.ForagingSkill)
|
||||
if len(craftResults) > 0 {
|
||||
items = updatedItems
|
||||
for _, cr := range craftResults {
|
||||
|
||||
@@ -101,11 +101,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
||||
if refusal != "" {
|
||||
return p.replyDM(ctx, refusal)
|
||||
}
|
||||
// The persisted session scales enemy HP for a party (solo scales by 1.0, so
|
||||
// this is a no-op there); mirror it here so the entry banner and the opening
|
||||
// round resolve against the same ceiling startPartyCombatSession persisted and
|
||||
// the rebuilt rounds use.
|
||||
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats))
|
||||
enemyHP := enemy.Stats.MaxHP
|
||||
|
||||
// Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded
|
||||
// per seat onto the session and its participant rows, so they survive the
|
||||
@@ -134,11 +130,6 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
||||
|
||||
if sess.IsParty() {
|
||||
players := seatCombatants(seats)
|
||||
// Align the in-memory template with the scaled HP persisted above, so the
|
||||
// opening-round settle resolves the enemy against the same MaxHP the
|
||||
// rebuilt rounds do (regen clamp, bloodied-ability threshold). The persist
|
||||
// already happened off the unscaled value, so this does not double-scale.
|
||||
enemy.Stats.MaxHP = enemyHP
|
||||
// The enemy may have won initiative. Resolve everything the round owes
|
||||
// before anyone is asked to act, so the opening block narrates the hit
|
||||
// rather than showing its damage with no explanation.
|
||||
@@ -341,10 +332,6 @@ func continueHint(userID id.UserID) string {
|
||||
// the room's manual combat is done, and a fresh !zone advance clears the room.
|
||||
func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSession, enemy Combatant) string {
|
||||
persistDnDHPAfterCombat(userID, sess.PlayerHP)
|
||||
// Achievements and post-combat subclass state are owed on every terminal
|
||||
// status, not just a win — a Berserker who rages and loses still comes out
|
||||
// of it exhausted. The auto-resolve close-outs have always done this.
|
||||
p.postCombatBookkeepingForSeat(sess, 0)
|
||||
|
||||
run, _ := getZoneRun(sess.RunID)
|
||||
var zone ZoneDefinition
|
||||
@@ -363,12 +350,28 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
|
||||
var b strings.Builder
|
||||
switch sess.Status {
|
||||
case CombatStatusWon:
|
||||
recordZoneKillForUser(userID, sess.EnemyID)
|
||||
applyRoomCombatThreatForUser(userID, elite)
|
||||
// zoneCombatXP only reads PlayerWon + NearDeath off the result.
|
||||
nearDeath := sess.PlayerHPMax > 0 && sess.PlayerHP*5 < sess.PlayerHPMax
|
||||
tier := 1
|
||||
if run != nil {
|
||||
tier = int(zone.Tier)
|
||||
}
|
||||
bossOnExpedition := p.applyOwnerWinEffects(userID, sess.EnemyID, elite)
|
||||
p.grantSeatWinXP(userID, sess.PlayerHP, sess.PlayerHPMax, monster, tier)
|
||||
if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 {
|
||||
if _, err := p.grantDnDXP(userID, xp); err != nil {
|
||||
slog.Error("combat: grantDnDXP turn-based", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
bossOnExpedition := false
|
||||
if !elite {
|
||||
// §8.1 — zone boss defeat drops expedition threat. Silent no-op
|
||||
// for standalone zone runs (no active expedition).
|
||||
if exp, eerr := getActiveExpedition(userID); eerr == nil && exp != nil {
|
||||
_ = applyBossDefeatThreat(exp.ID)
|
||||
bossOnExpedition = true
|
||||
}
|
||||
}
|
||||
if line := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence); line != "" {
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
@@ -381,9 +384,6 @@ 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
|
||||
@@ -395,7 +395,11 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
|
||||
}
|
||||
|
||||
case CombatStatusLost:
|
||||
endRunOnLoss(userID, sess.RunID, true)
|
||||
if run != nil {
|
||||
_, _ = applyMoodEvent(sess.RunID, MoodEventPlayerDeath)
|
||||
}
|
||||
_ = abandonZoneRun(userID)
|
||||
forceExtractExpeditionForRunLoss(userID, "combat death")
|
||||
markAdventureDead(userID, "zone", zone.Display)
|
||||
if line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence); line != "" {
|
||||
b.WriteString(line + "\n")
|
||||
@@ -406,7 +410,8 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
|
||||
// Flee = run ends, light penalty: wounds persist (HP already saved),
|
||||
// but no death timer. Chosen candidate from the migration plan's
|
||||
// open question on flee outcome.
|
||||
endRunOnLoss(userID, sess.RunID, false)
|
||||
_ = abandonZoneRun(userID)
|
||||
forceExtractExpeditionForRunLoss(userID, "combat flee")
|
||||
b.WriteString(fmt.Sprintf("🏃 You broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name))
|
||||
|
||||
default:
|
||||
@@ -593,18 +598,6 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
||||
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
|
||||
return PlayerAction{}, noop, msg
|
||||
}
|
||||
// Park the Necromancy kill-heal stash on the casting seat. The
|
||||
// auto-resolve path keeps it on the fight-start CombatModifiers, which
|
||||
// a turn-based fight has nowhere to hold — it rebuilds its combatants
|
||||
// every round. Only a damaging cast stashes (a miss leaves the slot at
|
||||
// 0), and each one overwrites the last, so the stash always describes
|
||||
// the seat's most recent landed spell — which is the only one that can
|
||||
// have been lethal by the time the close-out reads it.
|
||||
if out.GrimHarvestSlot > 0 {
|
||||
as := ct.sess.actorStatusesPtr(seat)
|
||||
as.GrimHarvestSlot = out.GrimHarvestSlot
|
||||
as.GrimHarvestNecrotic = out.GrimHarvestNecrotic
|
||||
}
|
||||
eff = &turnActionEffect{
|
||||
Label: out.Desc,
|
||||
Action: "spell_cast",
|
||||
|
||||
@@ -162,11 +162,9 @@ type CombatModifiers struct {
|
||||
// ArcaneWardHP: flat HP buffer absorbed before player HP. Refilled at the
|
||||
// start of each combat by Abjuration L5+ (2× Mage level, +prof at L7).
|
||||
// Persists across rounds within a single combat; not refunded between fights.
|
||||
// GrimHarvestSlot/Necrotic: snapshot of the damaging spell stashed for the
|
||||
// post-combat Grim Harvest hook (Necromancy L5+) — by applyPendingCast on
|
||||
// the auto-resolve path, and by seatFightStartMods reading the seat's
|
||||
// statuses on the turn-based one. Heal fires only if that spell's event is
|
||||
// what dropped the enemy to 0.
|
||||
// GrimHarvestSlot/Necrotic: snapshot of the queued spell stashed by
|
||||
// applyPendingCast for the post-combat Grim Harvest hook (Necromancy L5+).
|
||||
// Heal fires only if the spell event is what dropped the enemy to 0.
|
||||
ArcaneWardHP int
|
||||
GrimHarvestSlot int
|
||||
GrimHarvestNecrotic bool
|
||||
|
||||
@@ -64,12 +64,6 @@ func simulateParty(players []Combatant, enemy Combatant, phases []CombatPhase) P
|
||||
}
|
||||
|
||||
func simulatePartyWithRNG(players []Combatant, enemy Combatant, phases []CombatPhase, rng *rand.Rand) PartyCombatResult {
|
||||
// Party-only: bump the enemy's max HP so the fight lasts long enough for the
|
||||
// scaled action economy to actually threaten each member. Solo is unchanged
|
||||
// (scale 1.0), so SimulateCombat and the golden do not move.
|
||||
if len(players) > 1 {
|
||||
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, len(players))
|
||||
}
|
||||
enemyStart := enemy.Stats.MaxHP
|
||||
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP {
|
||||
enemyStart = enemy.Stats.StartHP
|
||||
@@ -295,141 +289,6 @@ func roundInitiative(st *combatState, enemy *Combatant, phase *CombatPhase) []in
|
||||
return order
|
||||
}
|
||||
|
||||
// enemyActionsThisRound is how many attack-actions the enemy takes this round
|
||||
// against the seated roster. Solo returns 1 without drawing from the RNG, so both
|
||||
// combat engines collapse to their pre-party behaviour and the characterization
|
||||
// golden and d8prereq corpus do not move.
|
||||
//
|
||||
// A party's action budget is a *fractional expectation* (partyActionExpectation),
|
||||
// realised as floor(exp) actions plus one more with probability frac(exp). The
|
||||
// fraction matters because the action lever is coarse: against a party of two, an
|
||||
// integer 2 actions is a 100% faceroll and 3 is brutal (~45%), with nothing
|
||||
// between — a per-round coin-flip for the extra action is the only way to land
|
||||
// the band in the gap. The single draw is taken only for a party, so the solo RNG
|
||||
// stream is untouched.
|
||||
func enemyActionsThisRound(st *combatState) int {
|
||||
n := len(st.actors)
|
||||
if n < 2 {
|
||||
return 1
|
||||
}
|
||||
exp := partyActionExpectation(n)
|
||||
base := int(exp)
|
||||
if frac := exp - float64(base); frac > 0 && st.randFloat() < frac {
|
||||
base++
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// partyActionExpectation is the expected number of enemy attack-actions per round
|
||||
// against a party of n. A single enemy swing (the pre-P8 behaviour) let each
|
||||
// member absorb ~1/N² of the solo incoming and cleared 100% of T5; the sim band
|
||||
// landed the martial-leader target only near ~2N actions, and HP alone can't touch
|
||||
// a martial (fighter stays at 100% even at enemy HP ×1.6). So the enemy's action
|
||||
// economy is the load-bearing lever, and it is fractional so a two-person party
|
||||
// lands between soloing and a trio instead of snapping to one of two integers
|
||||
// (against a duo, an integer 2 actions is a ~91% faceroll and 3 is a ~45% meat
|
||||
// grinder — 2.4 sits fighter+cleric at ~75%, between solo 70% and trio 90%).
|
||||
//
|
||||
// N≥3 follows 2N−1, the point that gave fighter ~90% / cleric-led ~72% at
|
||||
// HP ×1.15. The whole curve is monotonic by party size and never drops a
|
||||
// composition below its solo clear rate — bringing a friend is never a penalty.
|
||||
func partyActionExpectation(n int) float64 {
|
||||
switch {
|
||||
case n < 2:
|
||||
return 1
|
||||
case n == 2:
|
||||
return 2.4
|
||||
default:
|
||||
return float64(2*n - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// partyEnemyHPScale is the party-only multiplier on the enemy's max HP. Solo
|
||||
// (roster < 2) returns 1.0, so the solo path — and the characterization golden
|
||||
// and the d8prereq corpus — is byte-for-byte untouched. With the action economy
|
||||
// fixed, each member now takes ~1 swing a round, so a longer fight is more
|
||||
// cumulative exposure per member: HP became a live difficulty lever exactly the
|
||||
// way P6e predicted it would once the enemy stopped swinging only once.
|
||||
//
|
||||
// 1.15 is where the P8 sim sweep landed the band: with 2N−1 actions, a party of
|
||||
// three clears the T5 martial-leader band at ~89% (fighter) / ~67% (cleric-led),
|
||||
// clearly safer than soloing (70% / 27%) but with real TPKs and member deaths —
|
||||
// not the 100% faceroll a single enemy swing produced.
|
||||
func partyEnemyHPScale(rosterSize int) float64 {
|
||||
if rosterSize < 2 {
|
||||
return 1.0
|
||||
}
|
||||
return 1.15
|
||||
}
|
||||
|
||||
// scaledEnemyMaxHP applies the party HP scalar to a base max-HP with one rounding
|
||||
// rule, so every call site (the auto-resolve engine, the party session's initial
|
||||
// persist, and the per-turn rebuild) agrees on the same number.
|
||||
func scaledEnemyMaxHP(baseMaxHP, rosterSize int) int {
|
||||
return int(float64(baseMaxHP) * partyEnemyHPScale(rosterSize))
|
||||
}
|
||||
|
||||
// enemyActionPlan is the shared action budget both combat engines resolve an
|
||||
// enemy turn against: how many attack-actions the enemy takes, and whether the
|
||||
// first one reuses the round's already-picked target (and its already-rolled
|
||||
// procs). A cleave/lifesteal ability already spent the first action, so one fewer
|
||||
// follows and none of them reuse the initial target. Both engines call this so
|
||||
// the load-bearing count stays in lockstep even though their per-action bodies
|
||||
// differ.
|
||||
func enemyActionPlan(st *combatState, abilityDealtDamage bool) (count int, reuseFirst bool) {
|
||||
count = enemyActionsThisRound(st)
|
||||
reuseFirst = !abilityDealtDamage
|
||||
if abilityDealtDamage {
|
||||
count--
|
||||
}
|
||||
return count, reuseFirst
|
||||
}
|
||||
|
||||
// enemyRoundSwings resolves the enemy's attacks for one round of the auto-resolve
|
||||
// engine. A solo roster takes exactly one swing at its single seat, reusing the
|
||||
// round's already-rolled target and pet procs, so its RNG stream and event log are
|
||||
// byte-for-byte the pre-P8 engine's. A party faces enemyActionsThisRound() swings,
|
||||
// each re-targeted at a random standing seat with that seat's own pet procs, so the
|
||||
// damage is spread across the roster instead of pinning the round's first target.
|
||||
//
|
||||
// abilityDealtDamage means a cleave/lifesteal already spent the enemy's first
|
||||
// action this round, so one fewer swing follows — for solo that collapses to the
|
||||
// old "the ability stands in for the attack" skip. Returns true if the fight is
|
||||
// decided.
|
||||
func enemyRoundSwings(st *combatState, enemy *Combatant, phase *CombatPhase, seats []CombatResult,
|
||||
target int, abilityDealtDamage, petWhiff, petDeflect, sporeMiss bool) bool {
|
||||
|
||||
swings, reuseFirst := enemyActionPlan(st, abilityDealtDamage)
|
||||
for k := 0; k < swings; k++ {
|
||||
tgt := target
|
||||
sw, sd, sp := petWhiff, petDeflect, sporeMiss
|
||||
if !(reuseFirst && k == 0) {
|
||||
// A fresh target for every swing past the first: re-pick among the
|
||||
// standing seats and roll that seat's own pet procs. This branch never
|
||||
// runs for a solo roster, so it adds no draws to the solo stream.
|
||||
var alive bool
|
||||
if tgt, alive = enemyTargetSeat(st); !alive {
|
||||
return combatOver(st)
|
||||
}
|
||||
st.seat(tgt)
|
||||
sw = st.c.Mods.PetWhiffProc > 0 && st.randFloat() < st.c.Mods.PetWhiffProc
|
||||
sd = st.c.Mods.PetDeflectProc > 0 && st.randFloat() < st.c.Mods.PetDeflectProc
|
||||
if sd {
|
||||
seats[tgt].PetDeflected = true
|
||||
}
|
||||
sp = st.sporeRounds > 0 && st.randFloat() < 0.15
|
||||
}
|
||||
st.seat(tgt)
|
||||
mark := len(st.events)
|
||||
over := resolveEnemyAttack(st, st.c, enemy, phase, &seats[tgt], sw, sd, sp)
|
||||
stampEventSeats(st, mark, tgt)
|
||||
if over && combatOver(st) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// simulatePartyRound runs one round for the whole roster. Returns true if the
|
||||
// fight is over.
|
||||
func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, seats []CombatResult) bool {
|
||||
@@ -524,7 +383,14 @@ func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, s
|
||||
// via Mods.InitiativeBias — +X means they go first more often.
|
||||
for _, s := range roundInitiative(st, enemy, phase) {
|
||||
if s == enemySeat {
|
||||
if enemyRoundSwings(st, enemy, phase, seats, target, abilityDealtDamage, petWhiff, petDeflect, sporeMiss) {
|
||||
if abilityDealtDamage {
|
||||
continue
|
||||
}
|
||||
st.seat(target)
|
||||
mark := len(st.events)
|
||||
over := resolveEnemyAttack(st, st.c, enemy, phase, &seats[target], petWhiff, petDeflect, sporeMiss)
|
||||
stampEventSeats(st, mark, target)
|
||||
if over && combatOver(st) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
@@ -582,78 +448,6 @@ func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, s
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Misty's pair, shared by both engines ─────────────────────────────────────
|
||||
//
|
||||
// These two are the only round-end effects the turn engine did not have its own
|
||||
// copy of. Everything else in endOfRoundForSeat either exists there already (the
|
||||
// pet, the spiritual weapon — fired after a player action rather than at round
|
||||
// end), is deliberately absent (the environmental hazard: turnCombatPhase is a
|
||||
// single flat "Duel" phase with EnvironmentProc at 0), or is replaced by an
|
||||
// explicit player command (the consumable auto-heal, which is `!consume`).
|
||||
//
|
||||
// So they are hoisted rather than re-implemented. A parallel sibling is what let
|
||||
// the two win close-outs drift apart (deferred item D); this pair is now one
|
||||
// list of effects with two callers, and cannot.
|
||||
//
|
||||
// Neither draws from the RNG unless its proc is armed, so a character with no
|
||||
// Misty history rolls exactly the dice it rolled before — the sim corpus and
|
||||
// combat_characterization.golden do not move.
|
||||
|
||||
// mistyCrowdRevenge is the debuff for declining Misty: her crowd takes a swing
|
||||
// at the end of the round. Returns true when it decided the fight — that is,
|
||||
// when it dropped this character, the death save failed, and nobody else is
|
||||
// standing. A downed character in a still-live party returns false.
|
||||
func mistyCrowdRevenge(st *combatState, player *Combatant, phaseName string) bool {
|
||||
if player.Mods.CrowdRevengeProc <= 0 || st.randFloat() >= player.Mods.CrowdRevengeProc {
|
||||
return false
|
||||
}
|
||||
dmg := player.Mods.CrowdRevengeDmg
|
||||
st.playerHP = max(0, st.playerHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "environment", Action: "crowd_revenge",
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Desc: "Misty's crowd",
|
||||
})
|
||||
return st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive()
|
||||
}
|
||||
|
||||
// mistyHeal is the buff's payout. It cannot decide the fight, so it returns
|
||||
// nothing. result may be a scratch value the caller discards (the turn engine's
|
||||
// is) — the durable record is the misty_heal event on the log, which is what
|
||||
// seatCombatResult reads to award combat_misty_clutch.
|
||||
func mistyHeal(st *combatState, player *Combatant, phaseName string, result *CombatResult) {
|
||||
if player.Mods.MistyHealProc <= 0 || st.randFloat() >= player.Mods.MistyHealProc {
|
||||
return
|
||||
}
|
||||
healAmt := player.Mods.MistyHealAmt
|
||||
st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt)
|
||||
result.MistyHealed = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal",
|
||||
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Desc: "Misty",
|
||||
})
|
||||
}
|
||||
|
||||
// seatEndOfRound runs the round-end effects the turn engine owes one seat, in
|
||||
// the order endOfRoundForSeat runs them: the crowd's swing, then — if the seat
|
||||
// survived it — Misty's heal. Returns true when the fight is over.
|
||||
//
|
||||
// It exists because the turn engine's stepRoundEnd never ran either one. A
|
||||
// player carrying Misty's buff lost it by fighting manually; worse, a player
|
||||
// carrying her debuff *escaped* it by doing the same, which needed no discovery
|
||||
// to exploit — just press !attack.
|
||||
func seatEndOfRound(st *combatState, player *Combatant, phaseName string, result *CombatResult) bool {
|
||||
if over := mistyCrowdRevenge(st, player, phaseName); over {
|
||||
return true
|
||||
}
|
||||
if st.playerHP <= 0 {
|
||||
return false
|
||||
}
|
||||
mistyHeal(st, player, phaseName, result)
|
||||
return false
|
||||
}
|
||||
|
||||
// endOfRoundForSeat runs the per-character close of a round against the seat the
|
||||
// cursor already points at: environment, Misty's crowd, the pet, the spiritual
|
||||
// weapon, Misty's heal, and the consumable auto-heal — in that order, which is
|
||||
@@ -676,8 +470,17 @@ func endOfRoundForSeat(st *combatState, phase *CombatPhase, result *CombatResult
|
||||
}
|
||||
|
||||
// Misty crowd revenge (debuff for declining Misty)
|
||||
if over := mistyCrowdRevenge(st, player, phaseName); over {
|
||||
return true
|
||||
if player.Mods.CrowdRevengeProc > 0 && st.randFloat() < player.Mods.CrowdRevengeProc {
|
||||
dmg := player.Mods.CrowdRevengeDmg
|
||||
st.playerHP = max(0, st.playerHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "environment", Action: "crowd_revenge",
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Desc: "Misty's crowd",
|
||||
})
|
||||
if st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// A character the round just killed stops acting, but the fight goes on if
|
||||
@@ -714,7 +517,16 @@ func endOfRoundForSeat(st *combatState, phase *CombatPhase, result *CombatResult
|
||||
}
|
||||
|
||||
// Misty heal
|
||||
mistyHeal(st, player, phaseName, result)
|
||||
if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc {
|
||||
healAmt := player.Mods.MistyHealAmt
|
||||
st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt)
|
||||
result.MistyHealed = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal",
|
||||
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Desc: "Misty",
|
||||
})
|
||||
}
|
||||
|
||||
// Consumable heal: triggers when the character drops below 60% HP. Fires up
|
||||
// to HealItemCharges times per fight (1 = legacy one-shot; bosses can
|
||||
|
||||
@@ -124,89 +124,31 @@ func TestSimulateParty_DownedMemberDoesNotEndTheFight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// P8 scaling constants: solo is exempt on both levers (1 action, ×1.0 HP) so the
|
||||
// characterization golden and the d8prereq corpus are untouched. A party's action
|
||||
// budget is a fractional expectation — 2.4 for a duo (so it lands between soloing
|
||||
// and a trio, where an integer 2 vs 3 has no room), 2N−1 for N≥3 — plus ×1.15
|
||||
// enemy HP.
|
||||
func TestP8PartyScaling_SoloExemptPartyScaled(t *testing.T) {
|
||||
if got := partyActionExpectation(1); got != 1 {
|
||||
t.Fatalf("solo action expectation = %v, want 1", got)
|
||||
}
|
||||
if got := partyEnemyHPScale(1); got != 1.0 {
|
||||
t.Fatalf("solo HP scale = %v, want 1.0", got)
|
||||
}
|
||||
if got := scaledEnemyMaxHP(200, 1); got != 200 {
|
||||
t.Fatalf("solo enemy HP = %d, want 200 (unscaled)", got)
|
||||
}
|
||||
if got := partyActionExpectation(2); got != 2.4 {
|
||||
t.Fatalf("duo action expectation = %v, want 2.4", got)
|
||||
}
|
||||
for n := 3; n <= 5; n++ {
|
||||
if got, want := partyActionExpectation(n), float64(2*n-1); got != want {
|
||||
t.Fatalf("party of %d: action expectation = %v, want %v", n, got, want)
|
||||
}
|
||||
}
|
||||
if got := partyEnemyHPScale(3); got != 1.15 {
|
||||
t.Fatalf("party HP scale = %v, want 1.15", got)
|
||||
}
|
||||
// int(200*1.15) truncates 229.99… to 229; the 1-HP floor is immaterial.
|
||||
if got := scaledEnemyMaxHP(200, 3); got != 229 {
|
||||
t.Fatalf("party enemy HP = %d, want 229 (trunc 200*1.15)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// P8: the enemy's action economy scales with the roster. A party of N faces up to
|
||||
// N attack-actions a round, each re-targeted, so the enemy's damage spreads across
|
||||
// the roster instead of pinning one seat — the 1/N² exposure that made P6e's party
|
||||
// a 100%-clear faceroll. Solo stays at exactly one swing a round, the pre-party
|
||||
// behaviour the characterization golden pins.
|
||||
func TestSimulateParty_EnemyActionEconomyScalesWithRoster(t *testing.T) {
|
||||
enemyRollsPerRound := func(res PartyCombatResult) map[int]int {
|
||||
perRound := map[int]int{}
|
||||
for _, e := range res.Events {
|
||||
if e.Actor == "enemy" && e.Roll > 0 {
|
||||
perRound[e.Round]++
|
||||
}
|
||||
}
|
||||
return perRound
|
||||
}
|
||||
|
||||
// Solo: never more than one enemy swing in a round.
|
||||
soloTank := baseEnemy()
|
||||
soloTank.Stats.MaxHP = 5000
|
||||
solo := simulatePartyWithRNG(
|
||||
[]Combatant{basePlayer()}, soloTank, dungeonCombatPhases, seededRNG(23))
|
||||
if len(enemyRollsPerRound(solo)) == 0 {
|
||||
t.Fatal("solo: the enemy never attacked")
|
||||
}
|
||||
for round, n := range enemyRollsPerRound(solo) {
|
||||
if n > 1 {
|
||||
t.Fatalf("solo round %d: enemy swung %d times, want at most 1", round, n)
|
||||
}
|
||||
}
|
||||
|
||||
// Party of 3: no round exceeds the roster's action budget, and at least one
|
||||
// round sees more than one swing — the enemy is really taking extra actions.
|
||||
// The enemy swings at one seat per round, not at everyone.
|
||||
func TestSimulateParty_EnemyStrikesOneSeatPerRound(t *testing.T) {
|
||||
tank := baseEnemy()
|
||||
tank.Stats.MaxHP = 5000
|
||||
party := simulatePartyWithRNG(
|
||||
|
||||
res := simulatePartyWithRNG(
|
||||
[]Combatant{basePlayer(), basePlayer(), basePlayer()}, tank, dungeonCombatPhases, seededRNG(23))
|
||||
budget := int(partyActionExpectation(3))
|
||||
if partyActionExpectation(3) > float64(budget) {
|
||||
budget++ // ceil: the coin-flip round can add one action
|
||||
}
|
||||
sawMulti := false
|
||||
for round, n := range enemyRollsPerRound(party) {
|
||||
if n > budget {
|
||||
t.Fatalf("party round %d: enemy swung %d times, over the %d-action budget", round, n, budget)
|
||||
|
||||
perRound := map[int]map[int]bool{}
|
||||
for _, e := range res.Events {
|
||||
if e.Actor != "enemy" || e.Roll == 0 {
|
||||
continue
|
||||
}
|
||||
if n > 1 {
|
||||
sawMulti = true
|
||||
if perRound[e.Round] == nil {
|
||||
perRound[e.Round] = map[int]bool{}
|
||||
}
|
||||
perRound[e.Round][e.Seat] = true
|
||||
}
|
||||
if !sawMulti {
|
||||
t.Fatal("a party of 3 never faced more than one enemy swing in a round — action economy did not scale")
|
||||
if len(perRound) == 0 {
|
||||
t.Fatal("the enemy never attacked")
|
||||
}
|
||||
for round, seats := range perRound {
|
||||
if len(seats) != 1 {
|
||||
t.Fatalf("round %d: enemy attack rolls landed on %d seats, want 1", round, len(seats))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Grim Harvest on the turn-based surface.
|
||||
//
|
||||
// The auto-resolve path stashes the killing spell's slot level on the
|
||||
// fight-start CombatModifiers, where the close-out reads it. A turn-based fight
|
||||
// has nowhere to keep that: it rebuilds its combatants from the session row on
|
||||
// every !attack / !cast. resolveTurnSpell computed the stash into a local mod
|
||||
// set and dropped it on the floor, so a Necromancy Mage who killed with a spell
|
||||
// never healed — on a class that already trails.
|
||||
//
|
||||
// The stash now rides on the casting seat's ActorStatuses, and the close-out
|
||||
// asks whether the *last* spell_cast is the one that dropped the enemy to 0.
|
||||
//
|
||||
// (Empowered Evocation and Overchannel were never broken here: they only move
|
||||
// mods.SpellPreDamage, which resolveTurnSpell already returns as EnemyDamage.)
|
||||
|
||||
// necromancer is a Mage who has reached L5 Grim Harvest.
|
||||
func necromancer(t *testing.T, uid id.UserID, level int) *DnDCharacter {
|
||||
t.Helper()
|
||||
if err := createAdvCharacter(uid, string(uid)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassMage, Subclass: SubclassNecromancy, Level: level,
|
||||
STR: 8, DEX: 12, CON: 12, INT: 16, WIS: 10, CHA: 10,
|
||||
HPMax: 30, HPCurrent: 10, ArmorClass: 12,
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// tough is a stat block a spell can be cast at without any of it mattering.
|
||||
func tough() CombatStats { return CombatStats{MaxHP: 40, AC: 10} }
|
||||
|
||||
// ── the leak itself ──────────────────────────────────────────────────────────
|
||||
|
||||
// resolveTurnSpell must hand the stash back to its caller. Before this, the
|
||||
// slot level lived and died inside the function.
|
||||
func TestResolveTurnSpell_CarriesTheGrimHarvestStashOut(t *testing.T) {
|
||||
c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, INT: 16}
|
||||
|
||||
// magic_missile: auto-hit, force damage, upcast to a 3rd-level slot.
|
||||
spell, _ := lookupSpell("magic_missile")
|
||||
enemy := tough()
|
||||
out, ok := resolveTurnSpell(c, spell, 3, &enemy)
|
||||
if !ok {
|
||||
t.Fatal("magic_missile should be a supported turn spell")
|
||||
}
|
||||
if out.GrimHarvestSlot != 3 {
|
||||
t.Errorf("GrimHarvestSlot = %d, want 3 (the slot it was cast at)", out.GrimHarvestSlot)
|
||||
}
|
||||
if out.GrimHarvestNecrotic {
|
||||
t.Error("magic_missile deals force damage — Necrotic should be false")
|
||||
}
|
||||
|
||||
// blight: auto-hit, necrotic — the flag that triples the heal.
|
||||
spell, _ = lookupSpell("blight")
|
||||
enemy = tough()
|
||||
out, _ = resolveTurnSpell(c, spell, 4, &enemy)
|
||||
if out.GrimHarvestSlot != 4 || !out.GrimHarvestNecrotic {
|
||||
t.Errorf("blight L4: slot=%d necrotic=%v, want 4/true", out.GrimHarvestSlot, out.GrimHarvestNecrotic)
|
||||
}
|
||||
}
|
||||
|
||||
// Nobody else stashes. A Mage below L5 has not learned the harvest, and an
|
||||
// Evocation Mage never will.
|
||||
func TestResolveTurnSpell_NoStashForAnyoneElse(t *testing.T) {
|
||||
spell, _ := lookupSpell("magic_missile")
|
||||
for _, c := range []*DnDCharacter{
|
||||
{Class: ClassMage, Subclass: SubclassNecromancy, Level: 4, INT: 16},
|
||||
{Class: ClassMage, Subclass: SubclassEvocation, Level: 12, INT: 16},
|
||||
{Class: ClassSorcerer, Level: 12, CHA: 16},
|
||||
} {
|
||||
enemy := tough()
|
||||
out, _ := resolveTurnSpell(c, spell, 3, &enemy)
|
||||
if out.GrimHarvestSlot != 0 {
|
||||
t.Errorf("%s/%s L%d stashed slot %d, want 0",
|
||||
c.Class, c.Subclass, c.Level, out.GrimHarvestSlot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── which cast counts ────────────────────────────────────────────────────────
|
||||
|
||||
// The killing-blow check used to break on the first spell_cast it saw. In a
|
||||
// turn-based fight the mage casts every round, so the first one is an opening
|
||||
// cantrip that left the enemy standing — and it vetoed the heal the killing
|
||||
// spell had earned. It is the last cast that has to be lethal.
|
||||
func TestGrimHarvestHeal_ReadsTheLastCastNotTheFirst(t *testing.T) {
|
||||
c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, INT: 16}
|
||||
mods := CombatModifiers{GrimHarvestSlot: 2, GrimHarvestNecrotic: true}
|
||||
|
||||
result := CombatResult{PlayerWon: true, Events: []CombatEvent{
|
||||
{Round: 1, Action: "spell_cast", EnemyHP: 22}, // opening cantrip, enemy stands
|
||||
{Round: 2, Action: "enemy_attack", EnemyHP: 22},
|
||||
{Round: 2, Action: "spell_cast", EnemyHP: 0}, // this one killed it
|
||||
}}
|
||||
if got := grimHarvestHeal(c, result, mods); got != 6 {
|
||||
t.Errorf("heal = %d, want 6 (3× a 2nd-level necrotic slot)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The mirror: the mage softened it up with a spell, then finished it with a
|
||||
// weapon. No spell landed the blow, so no harvest.
|
||||
func TestGrimHarvestHeal_WeaponKillAfterASpellDoesNotHarvest(t *testing.T) {
|
||||
c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, INT: 16}
|
||||
mods := CombatModifiers{GrimHarvestSlot: 2, GrimHarvestNecrotic: true}
|
||||
|
||||
result := CombatResult{PlayerWon: true, Events: []CombatEvent{
|
||||
{Round: 1, Action: "spell_cast", EnemyHP: 9},
|
||||
{Round: 2, Action: "player_attack", EnemyHP: 0},
|
||||
}}
|
||||
if got := grimHarvestHeal(c, result, mods); got != 0 {
|
||||
t.Errorf("heal = %d, want 0 — the sword landed the blow, not the spell", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── the seam through the session ─────────────────────────────────────────────
|
||||
|
||||
// The stash has to survive the round-trip the fight puts it through: parked on
|
||||
// the seat by the cast, carried across commit()'s snapshot, read back by the
|
||||
// close-out.
|
||||
func TestSeatFightStartMods_ReadsTheStashOffTheSeat(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@seatstash:example.org")
|
||||
c := necromancer(t, uid, 5)
|
||||
|
||||
sess := &CombatSession{UserID: string(uid), Status: CombatStatusWon}
|
||||
sess.Statuses.GrimHarvestSlot = 3
|
||||
sess.Statuses.GrimHarvestNecrotic = true
|
||||
|
||||
mods := seatFightStartMods(sess, 0, c)
|
||||
if mods.GrimHarvestSlot != 3 || !mods.GrimHarvestNecrotic {
|
||||
t.Fatalf("seatFightStartMods lost the stash: %+v", mods)
|
||||
}
|
||||
|
||||
// snapshotActor rebuilds ActorStatuses from combatState each commit; fields
|
||||
// with no combatState counterpart must be carried over from the prior
|
||||
// snapshot, the way ArmedAbility is.
|
||||
kept := snapshotActor(&actor{}, sess.Statuses.ActorStatuses)
|
||||
if kept.GrimHarvestSlot != 3 || !kept.GrimHarvestNecrotic {
|
||||
t.Errorf("commit() dropped the stash: %+v", kept)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end at the close-out: a necromancer who ended the fight at 10/30 HP
|
||||
// with a lethal 2nd-level necrotic spell walks away with 6 HP back.
|
||||
func TestPostCombatBookkeepingForSeat_GrimHarvestHealsOnASpellKill(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@harvest:example.org")
|
||||
necromancer(t, uid, 5)
|
||||
|
||||
sess := &CombatSession{
|
||||
UserID: string(uid), Status: CombatStatusWon, Round: 3,
|
||||
PlayerHP: 10, PlayerHPMax: 30, EnemyHP: 0,
|
||||
TurnLog: []CombatEvent{
|
||||
{Round: 1, Action: "spell_cast", EnemyHP: 14},
|
||||
{Round: 3, Action: "spell_cast", EnemyHP: 0},
|
||||
},
|
||||
}
|
||||
sess.Statuses.GrimHarvestSlot = 2
|
||||
sess.Statuses.GrimHarvestNecrotic = true
|
||||
|
||||
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
|
||||
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.HPCurrent != 16 {
|
||||
t.Errorf("HPCurrent = %d after a turn-based spell kill, want 16 (10 + 3×2)", got.HPCurrent)
|
||||
}
|
||||
}
|
||||
|
||||
// A seat with no stash — the mage never cast, or missed every time — is left
|
||||
// exactly as the fight left them.
|
||||
func TestPostCombatBookkeepingForSeat_NoStashNoHeal(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@nostash:example.org")
|
||||
necromancer(t, uid, 5)
|
||||
|
||||
sess := &CombatSession{
|
||||
UserID: string(uid), Status: CombatStatusWon, Round: 2,
|
||||
PlayerHP: 10, PlayerHPMax: 30, EnemyHP: 0,
|
||||
TurnLog: []CombatEvent{{Round: 2, Action: "player_attack", EnemyHP: 0}},
|
||||
}
|
||||
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
|
||||
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.HPCurrent != 10 {
|
||||
t.Errorf("HPCurrent = %d, want 10 — nothing was stashed", got.HPCurrent)
|
||||
}
|
||||
}
|
||||
|
||||
// Party fights: the stash is per-seat, so seat 1's harvest cannot heal seat 0.
|
||||
// This is the same class of bug P5 fixed for mid-fight buffs.
|
||||
func TestGrimHarvestStash_IsPerSeat(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader, member := id.UserID("@leader:example.org"), id.UserID("@member:example.org")
|
||||
fightTestChar(t, leader, 30)
|
||||
necromancer(t, member, 5)
|
||||
|
||||
sess := &CombatSession{
|
||||
UserID: string(leader), Status: CombatStatusWon, Round: 2,
|
||||
PlayerHP: 20, PlayerHPMax: 30, EnemyHP: 0,
|
||||
Participants: []CombatParticipant{{Seat: 1, UserID: string(member), HP: 10, HPMax: 30}},
|
||||
TurnLog: []CombatEvent{
|
||||
{Round: 2, Seat: 1, Action: "spell_cast", EnemyHP: 0},
|
||||
},
|
||||
}
|
||||
sess.Participants[0].Statuses.GrimHarvestSlot = 2
|
||||
sess.Participants[0].Statuses.GrimHarvestNecrotic = true
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
p.postCombatBookkeepingForSeat(sess, 0)
|
||||
p.postCombatBookkeepingForSeat(sess, 1)
|
||||
|
||||
gotLeader, _ := LoadDnDCharacter(leader)
|
||||
if gotLeader.HPCurrent != 30 {
|
||||
t.Errorf("leader HPCurrent = %d, want 30 — the member's harvest is not theirs", gotLeader.HPCurrent)
|
||||
}
|
||||
gotMember, _ := LoadDnDCharacter(member)
|
||||
if gotMember.HPCurrent != 16 {
|
||||
t.Errorf("member HPCurrent = %d, want 16 (10 + 3×2)", gotMember.HPCurrent)
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Misty's two round-end procs on the turn-based surface.
|
||||
//
|
||||
// Both were built onto every turn-based combatant by DerivePlayerStats and then
|
||||
// never read: stepRoundEnd had no counterpart to endOfRoundForSeat. The buff was
|
||||
// simply lost. The debuff was an exploit — a player who declined Misty escaped
|
||||
// her crowd entirely by fighting with !attack instead of letting the room
|
||||
// auto-resolve, which needed no discovery at all.
|
||||
//
|
||||
// seatEndOfRound is the shared hook. These pin that it fires, that it cannot be
|
||||
// dodged, and that a character with no Misty history is not touched at all —
|
||||
// the property that keeps the sim corpus and the golden file still.
|
||||
|
||||
// mistyState seats one combatant at hp, cursor armed, with a seeded RNG.
|
||||
func mistyState(t *testing.T, player *Combatant, hp int) *combatState {
|
||||
t.Helper()
|
||||
st := testCombatState(hp, 100, 1, rand.New(rand.NewPCG(7, 7)))
|
||||
st.actor.c = player
|
||||
st.actor.hpMax = player.Stats.MaxHP
|
||||
return st
|
||||
}
|
||||
|
||||
// The two procs at certainty, so no test depends on a roll.
|
||||
func mistyCursed(maxHP, dmg int) *Combatant {
|
||||
return &Combatant{
|
||||
Name: "Cursed",
|
||||
Stats: CombatStats{MaxHP: maxHP, AC: 10},
|
||||
Mods: CombatModifiers{CrowdRevengeProc: 1.0, CrowdRevengeDmg: dmg},
|
||||
}
|
||||
}
|
||||
|
||||
func mistyBuffed(maxHP, heal int) *Combatant {
|
||||
return &Combatant{
|
||||
Name: "Buffed",
|
||||
Stats: CombatStats{MaxHP: maxHP, AC: 10},
|
||||
Mods: CombatModifiers{MistyHealProc: 1.0, MistyHealAmt: heal},
|
||||
}
|
||||
}
|
||||
|
||||
// ── the exploit ──────────────────────────────────────────────────────────────
|
||||
|
||||
// The debuff must land at round end in a manual fight, exactly as it does when
|
||||
// the room auto-resolves. Before seatEndOfRound, !attack was a clean escape.
|
||||
func TestSeatEndOfRound_CrowdRevengeCannotBeDodgedByFightingManually(t *testing.T) {
|
||||
st := mistyState(t, mistyCursed(40, 6), 40)
|
||||
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})
|
||||
|
||||
if over {
|
||||
t.Fatal("a 6-damage swing against 40 HP should not end the fight")
|
||||
}
|
||||
if st.playerHP != 34 {
|
||||
t.Errorf("playerHP = %d, want 34 — Misty's crowd took its swing", st.playerHP)
|
||||
}
|
||||
if !hasAction(st.events, "crowd_revenge") {
|
||||
t.Error("no crowd_revenge event on the log")
|
||||
}
|
||||
}
|
||||
|
||||
// The debuff can be lethal, and a lethal one with nobody else standing ends the
|
||||
// fight rather than leaving a corpse to take the next round's turn.
|
||||
func TestSeatEndOfRound_CrowdRevengeCanEndASoloFight(t *testing.T) {
|
||||
st := mistyState(t, mistyCursed(40, 30), 3)
|
||||
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})
|
||||
|
||||
if st.playerHP != 0 {
|
||||
t.Fatalf("playerHP = %d, want 0", st.playerHP)
|
||||
}
|
||||
if !over {
|
||||
t.Error("a solo character dropped by the crowd should end the fight")
|
||||
}
|
||||
}
|
||||
|
||||
// ── the buff ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// The heal fires, caps at max HP, and marks the result so the achievement can
|
||||
// be granted.
|
||||
func TestSeatEndOfRound_MistyHealFiresAndCaps(t *testing.T) {
|
||||
st := mistyState(t, mistyBuffed(40, 12), 34)
|
||||
var res CombatResult
|
||||
seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &res)
|
||||
|
||||
if st.playerHP != 40 {
|
||||
t.Errorf("playerHP = %d, want 40 — the heal caps at max HP", st.playerHP)
|
||||
}
|
||||
if !res.MistyHealed {
|
||||
t.Error("MistyHealed flag not set")
|
||||
}
|
||||
if !hasAction(st.events, "misty_heal") {
|
||||
t.Error("no misty_heal event on the log")
|
||||
}
|
||||
}
|
||||
|
||||
// A character the crowd just dropped is not then healed back up by the same
|
||||
// hook. The heal is for the living.
|
||||
func TestSeatEndOfRound_NoHealForACharacterTheCrowdJustDropped(t *testing.T) {
|
||||
c := mistyCursed(40, 40)
|
||||
c.Mods.MistyHealProc = 1.0
|
||||
c.Mods.MistyHealAmt = 20
|
||||
|
||||
st := mistyState(t, c, 10)
|
||||
st.actors = append(st.actors, &actor{playerHP: 5}) // an ally keeps the fight alive
|
||||
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})
|
||||
|
||||
if over {
|
||||
t.Fatal("an ally is still standing — the fight is not over")
|
||||
}
|
||||
if st.playerHP != 0 {
|
||||
t.Errorf("playerHP = %d, want 0 — the heal must not resurrect them", st.playerHP)
|
||||
}
|
||||
if hasAction(st.events, "misty_heal") {
|
||||
t.Error("misty_heal fired on a downed character")
|
||||
}
|
||||
}
|
||||
|
||||
// ── the property that protects the corpus ────────────────────────────────────
|
||||
|
||||
// A character with no Misty history is untouched: no HP change, no events, and
|
||||
// — because both procs short-circuit before st.randFloat() — no dice drawn. If
|
||||
// the hook drew even one float, every simulated fight would diverge and
|
||||
// combat_characterization.golden would move.
|
||||
func TestSeatEndOfRound_UnbuffedCharacterIsUntouchedAndDrawsNoDice(t *testing.T) {
|
||||
plain := &Combatant{Name: "Plain", Stats: CombatStats{MaxHP: 40, AC: 10}}
|
||||
|
||||
st := mistyState(t, plain, 40)
|
||||
control := mistyState(t, plain, 40) // same seed, never passed to the hook
|
||||
|
||||
if over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{}); over {
|
||||
t.Fatal("an unbuffed character cannot be dropped by a hook that does nothing")
|
||||
}
|
||||
if st.playerHP != 40 || len(st.events) != 0 {
|
||||
t.Errorf("unbuffed character was touched: hp=%d events=%d", st.playerHP, len(st.events))
|
||||
}
|
||||
// The RNG streams must still be in lockstep: the hook consumed nothing.
|
||||
if got, want := st.randFloat(), control.randFloat(); got != want {
|
||||
t.Errorf("seatEndOfRound consumed RNG: next float %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ── the wiring ───────────────────────────────────────────────────────────────
|
||||
|
||||
// The unit tests above call seatEndOfRound directly, which proves the hook is
|
||||
// correct but not that stepRoundEnd calls it. This drives the real session API
|
||||
// through a round_end step. Comment the call out of stepRoundEnd and this test
|
||||
// reports PlayerHP=40 — the exploit, exactly as it shipped.
|
||||
func TestStepRoundEnd_WiresSeatEndOfRound(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
player := mistyCursed(40, 6)
|
||||
enemy := &Combatant{Name: "Target", Stats: CombatStats{MaxHP: 100, AC: 10}}
|
||||
sess := &CombatSession{
|
||||
SessionID: "wiring", UserID: "@u:example.org", Status: CombatStatusActive,
|
||||
PlayerHP: 40, PlayerHPMax: 40, EnemyHP: 100,
|
||||
Phase: CombatPhaseRoundEnd, Round: 1,
|
||||
}
|
||||
if _, err := advanceCombatSession(sess, player, enemy, PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.PlayerHP != 34 {
|
||||
t.Errorf("PlayerHP = %d, want 34 — stepRoundEnd never ran the hook", sess.PlayerHP)
|
||||
}
|
||||
if !hasAction(sess.TurnLog, "crowd_revenge") {
|
||||
t.Error("no crowd_revenge event persisted to the session log")
|
||||
}
|
||||
}
|
||||
@@ -979,12 +979,6 @@ func renderAllySeatEvent(e CombatEvent, name, enemyName string) string {
|
||||
return down(fmt.Sprintf("☠️ %s takes %d from poison.", who, e.Damage))
|
||||
case "environmental":
|
||||
return down(fmt.Sprintf("%s takes %d from the room.", who, e.Damage))
|
||||
case "crowd_revenge":
|
||||
// Deliberately unattributed. An ally sees the damage land — silence
|
||||
// would read as HP vanishing — but Misty's grudge is the owner's own
|
||||
// discovery, and naming her here would spoil it for the whole party.
|
||||
// Same reason misty_heal below reads as a plain recovery.
|
||||
return down(fmt.Sprintf("%s takes %d.", who, e.Damage))
|
||||
case "flat_damage":
|
||||
return fmt.Sprintf("%s deals %d.", who, e.Damage)
|
||||
case "heal_item", "misty_heal":
|
||||
|
||||
@@ -51,12 +51,9 @@ func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
|
||||
// nat20/nat1 mood deltas from the whole fight's event log — the run's, so once.
|
||||
scanMoodEventsFromEvents(sess.RunID, sess.TurnLog)
|
||||
|
||||
// Every seat's HP lands on their sheet regardless of how the fight ended, and
|
||||
// so does the bookkeeping that outlives the fight — a Berserker who raged and
|
||||
// lost is still exhausted. Both fan out; neither is the owner's alone.
|
||||
// Every seat's HP lands on their sheet regardless of how the fight ended.
|
||||
for seat := range sess.RosterSize() {
|
||||
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
|
||||
p.postCombatBookkeepingForSeat(sess, seat)
|
||||
}
|
||||
|
||||
switch sess.Status {
|
||||
@@ -65,7 +62,8 @@ func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
|
||||
case CombatStatusLost:
|
||||
return p.finishPartyLoss(ct, zone, cadence)
|
||||
case CombatStatusFled:
|
||||
endRunOnLoss(owner, sess.RunID, false)
|
||||
_ = abandonZoneRun(owner)
|
||||
forceExtractExpeditionForRunLoss(owner, "combat flee")
|
||||
return p.eachSeat(ct, fmt.Sprintf(
|
||||
"🏃 The party broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name))
|
||||
default:
|
||||
@@ -83,7 +81,16 @@ func (p *AdventurePlugin) finishPartyWin(
|
||||
sess := ct.sess
|
||||
owner := id.UserID(sess.UserID)
|
||||
|
||||
bossOnExpedition := p.applyOwnerWinEffects(owner, sess.EnemyID, elite)
|
||||
recordZoneKillForUser(owner, sess.EnemyID)
|
||||
applyRoomCombatThreatForUser(owner, elite)
|
||||
|
||||
bossOnExpedition := false
|
||||
if !elite {
|
||||
if exp, eerr := getActiveExpedition(owner); eerr == nil && exp != nil {
|
||||
_ = applyBossDefeatThreat(exp.ID)
|
||||
bossOnExpedition = true
|
||||
}
|
||||
}
|
||||
|
||||
tier := 1
|
||||
if run != nil {
|
||||
@@ -100,8 +107,14 @@ func (p *AdventurePlugin) finishPartyWin(
|
||||
uid := id.UserID(sess.seatUserID(seat))
|
||||
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
|
||||
|
||||
// A member who went down before the killing blow still earns the kill.
|
||||
p.grantSeatWinXP(uid, hp, hpMax, monster, tier)
|
||||
// A member who went down before the killing blow still earns the kill —
|
||||
// but at a fifth of their pool or less, the XP path calls it near-death.
|
||||
nearDeath := hpMax > 0 && hp*5 < hpMax
|
||||
if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 {
|
||||
if _, err := p.grantDnDXP(uid, xp); err != nil {
|
||||
slog.Error("combat: party grantDnDXP", "user", uid, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
if shared != "" && seat == 0 {
|
||||
@@ -118,9 +131,6 @@ 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.")
|
||||
@@ -143,7 +153,11 @@ func (p *AdventurePlugin) finishPartyLoss(ct *combatTurn, zone ZoneDefinition, c
|
||||
sess := ct.sess
|
||||
owner := id.UserID(sess.UserID)
|
||||
|
||||
endRunOnLoss(owner, sess.RunID, true)
|
||||
if run, _ := getZoneRun(sess.RunID); run != nil {
|
||||
_, _ = applyMoodEvent(sess.RunID, MoodEventPlayerDeath)
|
||||
}
|
||||
_ = abandonZoneRun(owner)
|
||||
forceExtractExpeditionForRunLoss(owner, "combat death")
|
||||
|
||||
line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence)
|
||||
out := make([]string, sess.RosterSize())
|
||||
@@ -167,62 +181,3 @@ func (p *AdventurePlugin) eachSeat(ct *combatTurn, block string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Shared terminal effects (item D) ─────────────────────────────────────────
|
||||
//
|
||||
// finishCombatSession (solo, combat_cmd.go) and finishPartyWin/finishPartyLoss
|
||||
// (party, above) still write their own player-facing text — the solo line says
|
||||
// "You finished", the party line says "The party stands", and the death-on-win
|
||||
// rule is deliberately roster-size-dependent (see item E). But the *effects*
|
||||
// they fire were hand-copied lists, and item A drifted exactly there. These
|
||||
// helpers are the single copy of each effect; both close-outs route through
|
||||
// them so the effect lists cannot diverge again.
|
||||
|
||||
// applyOwnerWinEffects fires the terminal effects a win owes the run and
|
||||
// expedition — the zone-kill record, room threat, and the boss-defeat threat
|
||||
// drop — once, through the row's owner (a party member owns neither row).
|
||||
// Returns whether the kill was a zone boss on an active expedition; both
|
||||
// close-outs use it to reframe the final line as the expedition's climax.
|
||||
func (p *AdventurePlugin) applyOwnerWinEffects(owner id.UserID, enemyID string, elite bool) (bossOnExpedition bool) {
|
||||
recordZoneKillForUser(owner, enemyID)
|
||||
applyRoomCombatThreatForUser(owner, elite)
|
||||
if !elite {
|
||||
// §8.1 — a zone boss defeat drops expedition threat. Silent no-op for a
|
||||
// standalone zone run with no active expedition.
|
||||
if exp, eerr := getActiveExpedition(owner); eerr == nil && exp != nil {
|
||||
_ = applyBossDefeatThreat(exp.ID)
|
||||
bossOnExpedition = true
|
||||
}
|
||||
}
|
||||
return bossOnExpedition
|
||||
}
|
||||
|
||||
// grantSeatWinXP pays one combatant their kill XP. A seat that finished at a
|
||||
// fifth of its pool or less takes the near-death path. Full XP per person,
|
||||
// solo or party, per the accessibility-over-crunch stance.
|
||||
func (p *AdventurePlugin) grantSeatWinXP(uid id.UserID, hp, hpMax int, monster DnDMonsterTemplate, tier int) {
|
||||
nearDeath := hpMax > 0 && hp*5 < hpMax
|
||||
if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 {
|
||||
if _, err := p.grantDnDXP(uid, xp); err != nil {
|
||||
slog.Error("combat: grantDnDXP", "user", uid, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// endRunOnLoss tears down the owner's run and wrapping expedition when a fight
|
||||
// ends in death or flight. On a death (not a flee) it also stamps the run's
|
||||
// mood event. Owner-scoped, fires once; the per-seat death mark stays with the
|
||||
// caller, since it is decided per HP and gated on roster size.
|
||||
func endRunOnLoss(owner id.UserID, runID string, death bool) {
|
||||
if death {
|
||||
if run, _ := getZoneRun(runID); run != nil {
|
||||
_, _ = applyMoodEvent(runID, MoodEventPlayerDeath)
|
||||
}
|
||||
}
|
||||
_ = abandonZoneRun(owner)
|
||||
reason := "combat flee"
|
||||
if death {
|
||||
reason = "combat death"
|
||||
}
|
||||
forceExtractExpeditionForRunLoss(owner, reason)
|
||||
}
|
||||
|
||||
@@ -58,10 +58,19 @@ func (p *AdventurePlugin) buildFightSeats(
|
||||
}
|
||||
for i, uid := range roster {
|
||||
leader := i == 0
|
||||
player, e, _, err := p.buildZoneCombatants(uid, monster, tier, dmMood)
|
||||
if err != nil {
|
||||
if leader {
|
||||
return nil, nil, "", "Couldn't set up the fight: " + err.Error()
|
||||
}
|
||||
slog.Warn("combat: party member left out of fight", "user", uid, "err", err)
|
||||
skip(uid, "Couldn't bring you into the fight: "+err.Error())
|
||||
continue
|
||||
}
|
||||
if leader {
|
||||
enemy = &e
|
||||
}
|
||||
|
||||
// Both refusals below are cheap and neither needs the build, so they run
|
||||
// before it: consuming a seat's armed ability and *then* sitting them out
|
||||
// would spend their rage on a fight they never joined.
|
||||
hp, hpMax := dndHPSnapshot(uid)
|
||||
if hp <= 0 {
|
||||
if leader {
|
||||
@@ -87,32 +96,7 @@ func (p *AdventurePlugin) buildFightSeats(
|
||||
continue
|
||||
}
|
||||
|
||||
// Consumed exactly once for the fight, here. Every later rebuild
|
||||
// re-applies this id off the seat's statuses rather than re-arming.
|
||||
armed := ""
|
||||
if c, cerr := LoadDnDCharacter(uid); cerr == nil && c != nil {
|
||||
trySimAutoArm(c)
|
||||
armed = consumeArmedAbility(c)
|
||||
}
|
||||
player, e, _, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed)
|
||||
if err != nil {
|
||||
if leader {
|
||||
return nil, nil, "", "Couldn't set up the fight: " + err.Error()
|
||||
}
|
||||
slog.Warn("combat: party member left out of fight", "user", uid, "err", err)
|
||||
skip(uid, "Couldn't bring you into the fight: "+err.Error())
|
||||
continue
|
||||
}
|
||||
if leader {
|
||||
enemy = &e
|
||||
}
|
||||
if armed != "" {
|
||||
slog.Info("combat: armed ability fired (turn-based start)", "user", uid, "ability", armed)
|
||||
}
|
||||
|
||||
seats = append(seats, CombatSeatSetup{
|
||||
UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player, ArmedAbility: armed,
|
||||
})
|
||||
seats = append(seats, CombatSeatSetup{UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player})
|
||||
}
|
||||
return seats, enemy, senderSkip, ""
|
||||
}
|
||||
|
||||
@@ -88,24 +88,6 @@ type ActorStatuses struct {
|
||||
// and druids with no sustained DPS once their burst landed.
|
||||
ConcentrationDmg int `json:"concentration_dmg,omitempty"`
|
||||
|
||||
// ArmedAbility is the id of the active ability this character armed and
|
||||
// spent entering the fight (rage, second_wind, …). The resource is already
|
||||
// debited and the character disarmed; this is the record that lets every
|
||||
// rebuild of an in-flight fight re-apply the ability's mods, and lets the
|
||||
// close-out know a rage fired. Empty when nothing was armed.
|
||||
ArmedAbility string `json:"armed_ability,omitempty"`
|
||||
|
||||
// GrimHarvestSlot / GrimHarvestNecrotic snapshot the most recent damaging
|
||||
// spell this seat cast, for the Necromancy Mage's post-combat kill-heal.
|
||||
// The auto-resolve path carries the same pair on CombatModifiers, stashed
|
||||
// once by applyPendingCast; the turn-based path can cast every round, so
|
||||
// the stash lives here and each damaging cast overwrites it. Whether the
|
||||
// heal actually fires is decided at close-out by grimHarvestHeal, which
|
||||
// asks whether the *last* spell_cast event is the one that dropped the
|
||||
// enemy to 0 — so a stale stash from an earlier, non-lethal cast is inert.
|
||||
GrimHarvestSlot int `json:"grim_harvest_slot,omitempty"`
|
||||
GrimHarvestNecrotic bool `json:"grim_harvest_necrotic,omitempty"`
|
||||
|
||||
// Once-per-fight class/race/subclass one-shots: the "already used" flags.
|
||||
// Without persistence these reset every round on resume, letting a Halfling
|
||||
// reroll a nat 1 or an Orc rage every single round of a turn-based fight.
|
||||
@@ -239,26 +221,23 @@ func (s *ActorStatuses) applyBuffDelta(d turnBuffDelta) {
|
||||
// the Abjuration Arcane Ward is normally non-zero at fight start — the
|
||||
// turn-based build deliberately omits pre-combat consumables and queued casts —
|
||||
// but the full set is seeded for robustness. Returns true if anything was set.
|
||||
func seedCombatSessionOneShots(s *CombatSession, seat CombatSeatSetup) bool {
|
||||
return seedActorOneShots(&s.Statuses.ActorStatuses, seat)
|
||||
func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) bool {
|
||||
return seedActorOneShots(&s.Statuses.ActorStatuses, playerMods)
|
||||
}
|
||||
|
||||
// seedActorOneShots copies one character's fight-start one-shot resources onto
|
||||
// their persisted statuses. Seat 0's live on the session row; a party member's
|
||||
// live on their participant row, and each seat reads its own combatant's mods —
|
||||
// a party Abjurer brings their own Arcane Ward, not the leader's.
|
||||
func seedActorOneShots(st *ActorStatuses, seat CombatSeatSetup) bool {
|
||||
playerMods := seat.Mods
|
||||
func seedActorOneShots(st *ActorStatuses, playerMods CombatModifiers) bool {
|
||||
st.WardCharges = playerMods.WardCharges
|
||||
st.SporeRounds = playerMods.SporeCloud
|
||||
st.ReflectFrac = playerMods.ReflectNext
|
||||
st.AutoCritFirst = playerMods.AutoCritFirst
|
||||
st.ArcaneWardHP = playerMods.ArcaneWardHP
|
||||
st.HealChargesLeft = playerMods.HealItemCharges
|
||||
st.ArmedAbility = seat.ArmedAbility
|
||||
return st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
|
||||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 ||
|
||||
st.ArmedAbility != ""
|
||||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0
|
||||
}
|
||||
|
||||
// CombatParticipant is one party member's seat in a fight, from seat 1 up.
|
||||
@@ -488,10 +467,7 @@ func (p *AdventurePlugin) startPartyCombatSession(
|
||||
if len(seats) == 0 {
|
||||
return nil, fmt.Errorf("start combat session: empty roster")
|
||||
}
|
||||
// Party-only enemy HP bump (solo roster scales by 1.0). The per-turn rebuild
|
||||
// in partyCombatantsForSession applies the identical scalar to the enemy's
|
||||
// Stats.MaxHP, so the persisted current HP and the rebuilt max never drift.
|
||||
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats))
|
||||
enemyHP := enemy.Stats.MaxHP
|
||||
owner := seats[0]
|
||||
sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID,
|
||||
owner.HP, owner.HPMax, enemyHP, enemyHP)
|
||||
@@ -501,13 +477,13 @@ func (p *AdventurePlugin) startPartyCombatSession(
|
||||
|
||||
// Seat 0's one-shots live on the session row; seeding them is a mutation of
|
||||
// sess.Statuses that the save below flushes along with the participants.
|
||||
dirty := seedCombatSessionOneShots(sess, owner)
|
||||
dirty := seedCombatSessionOneShots(sess, owner.Mods)
|
||||
|
||||
if len(seats) > 1 {
|
||||
ps := make([]CombatParticipant, 0, len(seats)-1)
|
||||
for i, s := range seats[1:] {
|
||||
var st ActorStatuses
|
||||
seedActorOneShots(&st, s)
|
||||
seedActorOneShots(&st, s.Mods)
|
||||
ps = append(ps, CombatParticipant{
|
||||
Seat: i + 1, UserID: string(s.UserID), HP: s.HP, HPMax: s.HPMax, Statuses: st,
|
||||
})
|
||||
@@ -549,10 +525,6 @@ type CombatSeatSetup struct {
|
||||
HPMax int
|
||||
Mods CombatModifiers
|
||||
C *Combatant
|
||||
// ArmedAbility is the ability id this seat consumed entering the fight, ""
|
||||
// if they armed nothing. It is persisted onto the seat's statuses so every
|
||||
// later rebuild can re-apply the ability without re-spending it.
|
||||
ArmedAbility string
|
||||
}
|
||||
|
||||
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil).
|
||||
|
||||
@@ -33,18 +33,11 @@ import (
|
||||
// + armed ability) and the monster's bestiary stat block with tier scaling and
|
||||
// the DM-mood combat tilt folded in. The returned dndChar is handed back so
|
||||
// callers can run post-combat subclass persistence without reloading it.
|
||||
//
|
||||
// armed is the ability id already consumed for this fight (consumeArmedAbility,
|
||||
// or armAbilityForFight's return on the auto-resolve paths); "" means none. The
|
||||
// build re-applies it rather than consuming, because the turn-based path calls
|
||||
// this once per player command and a consuming build would spend the ability on
|
||||
// round 1 and drop it for the rest of the fight.
|
||||
func (p *AdventurePlugin) buildZoneCombatants(
|
||||
userID id.UserID,
|
||||
monster DnDMonsterTemplate,
|
||||
tier int,
|
||||
dmMood int,
|
||||
armed string,
|
||||
) (player Combatant, enemy Combatant, dndChar *DnDCharacter, err error) {
|
||||
tilt := dmMoodCombatTilt(dmMood)
|
||||
char, err := loadAdvCharacter(userID)
|
||||
@@ -71,7 +64,10 @@ func (p *AdventurePlugin) buildZoneCombatants(
|
||||
applyRacePassives(&playerStats, &playerMods, dndChar)
|
||||
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
||||
applyMagicItemEffects(&playerStats, &playerMods, userID)
|
||||
applyAbilityByID(dndChar, armed, &playerMods)
|
||||
trySimAutoArm(dndChar)
|
||||
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
||||
slog.Info("dnd: armed ability fired (turn-based build)", "user", userID, "ability", firedName)
|
||||
}
|
||||
|
||||
enemyStats, enemyMods := monster.toCombatStats()
|
||||
// Tier scaling mirrors runZoneCombat: only raise AC/AttackBonus to a
|
||||
@@ -156,11 +152,7 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
|
||||
players := make([]*Combatant, len(seats))
|
||||
var enemy Combatant
|
||||
for seat, uid := range seats {
|
||||
// The ability this seat armed was consumed once, at fight start, and its
|
||||
// id parked on their statuses. Re-applying it here — not re-consuming —
|
||||
// is what makes a rage last the whole fight instead of one round.
|
||||
st := sess.actorStatusesForSeat(seat)
|
||||
player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility)
|
||||
player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
|
||||
}
|
||||
@@ -169,107 +161,18 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
|
||||
// one-shots (ward/spore/…) live on their persisted statuses and flow
|
||||
// through the turn engine's resume/commit cycle, so only the persistent
|
||||
// stat deltas are applied here — and only that seat's own.
|
||||
applySessionBuffs(&player, st)
|
||||
applySessionBuffs(&player, sess.actorStatusesForSeat(seat))
|
||||
players[seat] = &player
|
||||
if seat == 0 {
|
||||
// The enemy build reads only (monster, tier, dmMood): every seat
|
||||
// rebuilds the identical stat block, so seat 0's copy is the fight's.
|
||||
// Only the *player* half of the build varies by seat.
|
||||
enemy = e
|
||||
// Party-only enemy HP bump, re-derived each turn from the template so
|
||||
// it never compounds. Matches the scalar startPartyCombatSession used
|
||||
// for the initial persist; solo (roster 1) scales by 1.0.
|
||||
if sess.IsParty() {
|
||||
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, sess.RosterSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
return players, &enemy, nil
|
||||
}
|
||||
|
||||
// seatFightStartMods re-derives the modifiers a finished fight's close-out still
|
||||
// needs: the Berserker's rage flag, which decides whether the character owes a
|
||||
// point of exhaustion, and the Necromancy Mage's Grim Harvest stash.
|
||||
//
|
||||
// It re-applies the seat's armed ability to an empty mod set rather than
|
||||
// rebuilding the whole combatant: no Apply writes to the character (they read
|
||||
// level and HP and write only mods), so this is pure, and the passive/equipment
|
||||
// layers a full build would add are not read by any post-combat hook.
|
||||
//
|
||||
// The Grim Harvest pair is not re-derived but read back off the seat's
|
||||
// statuses, where castActionForSeat parked it: the spell that stashed it was
|
||||
// cast mid-fight, not at fight start, and nothing in the character sheet still
|
||||
// remembers which one it was.
|
||||
func seatFightStartMods(sess *CombatSession, seat int, c *DnDCharacter) CombatModifiers {
|
||||
var mods CombatModifiers
|
||||
st := sess.actorStatusesForSeat(seat)
|
||||
applyAbilityByID(c, st.ArmedAbility, &mods)
|
||||
mods.GrimHarvestSlot = st.GrimHarvestSlot
|
||||
mods.GrimHarvestNecrotic = st.GrimHarvestNecrotic
|
||||
return mods
|
||||
}
|
||||
|
||||
// seatCombatResult reconstructs, for one seat of a finished turn-based fight,
|
||||
// the slice of CombatResult that the post-combat hooks actually read. The turn
|
||||
// engine never builds a CombatResult — it persists a session and a shared event
|
||||
// log — so the close-out has to assemble one.
|
||||
//
|
||||
// MistyHealed is read back off the seat's event log rather than off a flag: the
|
||||
// turn engine's CombatResult is a scratch value it discards between steps, and
|
||||
// the log is the only thing that survives to the close-out. This is how
|
||||
// combat_pet_save has always been detected.
|
||||
//
|
||||
// SniperKilled stays false. Arina's proc is a pre-combat one-shot, not a
|
||||
// round-end effect, so seatEndOfRound does not carry it and the turn engine
|
||||
// still has no place to fire it — combat_sniper_kill remains unreachable from a
|
||||
// manual kill.
|
||||
//
|
||||
// NearDeath mirrors the auto-resolve engine's win threshold (below 15% of max);
|
||||
// its loss-side meaning is unused here, since the only hook that reads it gates
|
||||
// on PlayerWon.
|
||||
func seatCombatResult(sess *CombatSession, seat int) CombatResult {
|
||||
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
|
||||
won := sess.Status == CombatStatusWon
|
||||
events := eventsForSeat(sess.TurnLog, seat)
|
||||
return CombatResult{
|
||||
PlayerWon: won,
|
||||
Events: events,
|
||||
PlayerEndHP: hp,
|
||||
EnemyEndHP: sess.EnemyHP,
|
||||
TotalRounds: sess.Round,
|
||||
NearDeath: won && hp > 0 && float64(hp) < float64(max(1, hpMax))*0.15,
|
||||
MistyHealed: hasAction(events, "misty_heal"),
|
||||
}
|
||||
}
|
||||
|
||||
// hasAction reports whether the event log holds at least one event with the
|
||||
// given Action. The one-line "did this happen in the fight?" scan the close-out
|
||||
// uses to read a proc's outcome off the log rather than a flag.
|
||||
func hasAction(events []CombatEvent, action string) bool {
|
||||
for _, e := range events {
|
||||
if e.Action == action {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// postCombatBookkeepingForSeat is postCombatBookkeeping for one seat of a
|
||||
// terminal turn-based session — the bridge between what the turn engine
|
||||
// persisted and what the shared close-out expects.
|
||||
func (p *AdventurePlugin) postCombatBookkeepingForSeat(sess *CombatSession, seat int) {
|
||||
uid := id.UserID(sess.seatUserID(seat))
|
||||
// Loaded after the caller's persistDnDHPAfterCombat, so a Grim-Harvest-style
|
||||
// hook that heals off HPCurrent reads the fight's real ending HP.
|
||||
c, err := LoadDnDCharacter(uid)
|
||||
if err != nil || c == nil {
|
||||
slog.Error("combat: post-combat bookkeeping skipped, no sheet", "user", uid, "err", err)
|
||||
return
|
||||
}
|
||||
mods := seatFightStartMods(sess, seat, c)
|
||||
p.postCombatBookkeeping(uid, c, mods.BerserkerRage, seatCombatResult(sess, seat), mods)
|
||||
}
|
||||
|
||||
// applySessionBuffs re-derives the persistent stat effect of every mid-fight
|
||||
// buff onto one rebuilt character. The buffs are stored as accumulated deltas
|
||||
// (diffTurnBuff produced them against that character's state at cast time), so
|
||||
|
||||
@@ -454,23 +454,15 @@ func TestApplySessionBuffs(t *testing.T) {
|
||||
func TestSeedCombatSessionOneShots(t *testing.T) {
|
||||
// Arcane Ward is the one resource normally live at fight start.
|
||||
s := &CombatSession{}
|
||||
if !seedCombatSessionOneShots(s, CombatSeatSetup{Mods: CombatModifiers{ArcaneWardHP: 15}}) {
|
||||
if !seedCombatSessionOneShots(s, CombatModifiers{ArcaneWardHP: 15}) {
|
||||
t.Error("seed should report true when Arcane Ward is present")
|
||||
}
|
||||
if s.Statuses.ArcaneWardHP != 15 {
|
||||
t.Errorf("ArcaneWardHP = %d, want 15", s.Statuses.ArcaneWardHP)
|
||||
}
|
||||
// The armed ability rides along, so a rebuild mid-fight can re-apply it.
|
||||
armed := &CombatSession{}
|
||||
if !seedCombatSessionOneShots(armed, CombatSeatSetup{ArmedAbility: "rage"}) {
|
||||
t.Error("seed should report true when an ability was armed")
|
||||
}
|
||||
if armed.Statuses.ArmedAbility != "rage" {
|
||||
t.Errorf("ArmedAbility = %q, want rage", armed.Statuses.ArmedAbility)
|
||||
}
|
||||
// Nothing to seed → false, statuses untouched.
|
||||
empty := &CombatSession{}
|
||||
if seedCombatSessionOneShots(empty, CombatSeatSetup{}) {
|
||||
if seedCombatSessionOneShots(empty, CombatModifiers{}) {
|
||||
t.Error("seed should report false with no fight-start resources")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,33 +157,12 @@ func DerivePlayerStats(
|
||||
}
|
||||
}
|
||||
|
||||
// Pet modifiers. Two pets each contribute at half weight — the combat mods
|
||||
// are an average over the active pets, so a pair reads as roughly one full
|
||||
// pet (flavor-forward, not a stat spike). A lone pet averages over one
|
||||
// element, so x/1==x and 0.0+x==x reproduce the former values exactly,
|
||||
// keeping the single-pet combat golden byte-identical. The engines roll one
|
||||
// attack/deflect/whiff off these mods per round regardless of pet count, so
|
||||
// the RNG draw order is unchanged too.
|
||||
pets := make([]struct{ level, armor int }, 0, 2)
|
||||
// Pet modifiers
|
||||
if char.HasPet() {
|
||||
pets = append(pets, struct{ level, armor int }{char.PetLevel, char.PetArmorTier})
|
||||
}
|
||||
if char.HasPet2() {
|
||||
pets = append(pets, struct{ level, armor int }{char.Pet2Level, char.Pet2ArmorTier})
|
||||
}
|
||||
if n := len(pets); n > 0 {
|
||||
var atk, defl, whiff float64
|
||||
levelSum := 0
|
||||
for _, pt := range pets {
|
||||
atk += petAttackChance(pt.level)
|
||||
defl += petDeflectChance(pt.level, pt.armor)
|
||||
whiff += 0.01 + float64(pt.level)*0.005
|
||||
levelSum += pt.level
|
||||
}
|
||||
mods.PetAttackProc = atk / float64(n)
|
||||
mods.PetDeflectProc = defl / float64(n)
|
||||
mods.PetWhiffProc = whiff / float64(n)
|
||||
mods.PetAttackDmg = 3 + (levelSum*2+n)/(2*n) // 3 + rounded average level
|
||||
mods.PetAttackProc = petAttackChance(char.PetLevel)
|
||||
mods.PetAttackDmg = 3 + char.PetLevel // per-attack variance added in engine
|
||||
mods.PetDeflectProc = petDeflectChance(char.PetLevel, char.PetArmorTier)
|
||||
mods.PetWhiffProc = 0.01 + float64(char.PetLevel)*0.005
|
||||
}
|
||||
if char.PetMorningDefense {
|
||||
mods.DamageReduct *= 0.95 // 5% less damage
|
||||
|
||||
@@ -414,11 +414,10 @@ func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) {
|
||||
te.stepPlayerTurn(action)
|
||||
te.stampSeat(0, acting)
|
||||
case CombatPhaseEnemyTurn:
|
||||
// stepEnemyTurn self-stamps each attack-action to the seat it targeted: a
|
||||
// party's enemy re-targets across the roster within one turn, so there is
|
||||
// no single seat the whole phase lands on. Solo stamps seat 0 throughout,
|
||||
// exactly as the old blanket stamp did.
|
||||
te.stepEnemyTurn()
|
||||
// stepEnemyTurn seats its target before anything resolves, and the whole
|
||||
// phase lands on that one character.
|
||||
te.stampSeat(0, te.st.seatIdx)
|
||||
case CombatPhaseRoundEnd:
|
||||
te.stepRoundEnd()
|
||||
case CombatPhaseOver:
|
||||
@@ -616,39 +615,34 @@ func (te *turnEngine) stepEnemyTurn() {
|
||||
return
|
||||
}
|
||||
te.st.seat(target)
|
||||
// A control spell cast last phase forfeits the enemy's whole turn (every
|
||||
// attack-action) this round — unless the enemy is fear_immune, in which case
|
||||
// the control fizzled and it acts as normal.
|
||||
// A control spell cast last phase forfeits the enemy's attack this round —
|
||||
// unless the enemy is fear_immune, in which case the control fizzled and it
|
||||
// acts as normal.
|
||||
if te.st.enemySkipFirst {
|
||||
te.st.enemySkipFirst = false
|
||||
mark := len(te.st.events)
|
||||
if enemyImmuneToControl(te.enemy, te.st) {
|
||||
te.st.events = append(te.st.events, CombatEvent{
|
||||
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "fear_resist",
|
||||
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
|
||||
})
|
||||
te.stampSeat(mark, target)
|
||||
} else {
|
||||
te.st.events = append(te.st.events, CombatEvent{
|
||||
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held",
|
||||
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
|
||||
})
|
||||
te.stampSeat(mark, target)
|
||||
te.advance()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Monster ability fires once, at the top of the enemy turn, against the
|
||||
// initial target. cleave / lifesteal resolve their own damage and stand in
|
||||
// for the enemy's first attack-action this round; every other effect (poison
|
||||
// / stun / enrage / armor_break) is a rider that leaves the multiattack below
|
||||
// intact. applyAbility returns true when the player went down without a save.
|
||||
// Monster ability fires at the top of the enemy turn. cleave / lifesteal
|
||||
// resolve their own damage and stand in for the attack action this round;
|
||||
// every other effect (poison / stun / enrage / armor_break) is a rider that
|
||||
// leaves the multiattack below intact. applyAbility returns true when the
|
||||
// player went down without a save.
|
||||
abilityDealtDamage := false
|
||||
if te.enemy.Ability != nil && turnAbilityFires(te.enemy.Ability, te.st, te.enemy) {
|
||||
mark := len(te.st.events)
|
||||
if applyAbility(te.st, te.st.c, te.enemy, &turnCombatPhase, te.result) {
|
||||
te.stampSeat(mark, target)
|
||||
// The target went down without a save. The fight only ends if it
|
||||
// took the last member with it; otherwise the enemy has spent its
|
||||
// turn on that kill.
|
||||
@@ -659,90 +653,57 @@ func (te *turnEngine) stepEnemyTurn() {
|
||||
}
|
||||
return
|
||||
}
|
||||
te.stampSeat(mark, target)
|
||||
switch te.enemy.Ability.Effect {
|
||||
case "cleave", "lifesteal":
|
||||
abilityDealtDamage = true
|
||||
}
|
||||
}
|
||||
|
||||
// The enemy takes enemyActionsThisRound() attack-actions, each its full
|
||||
// SRD multiattack, re-targeting a fresh standing seat per action so a party's
|
||||
// damage is spread across the roster rather than pinning the first target.
|
||||
// A solo roster is exactly one action against its one seat — no re-target is
|
||||
// drawn — so its event stream and RNG draws are unchanged. When the ability
|
||||
// already dealt damage it was the first action, so one fewer follows; for solo
|
||||
// that collapses to the old "the ability stood in for the attack" skip.
|
||||
actions, reuseFirstTarget := enemyActionPlan(te.st, abilityDealtDamage)
|
||||
for a := 0; a < actions; a++ {
|
||||
if !(reuseFirstTarget && a == 0) {
|
||||
tgt, alive := te.enemyTarget()
|
||||
if !alive {
|
||||
te.finish(CombatStatusLost)
|
||||
if !abilityDealtDamage {
|
||||
// Pet defensive procs are a single proc per enemy turn: roll once, then
|
||||
// spend it on the first swing only. Whiff makes that one swing a
|
||||
// guaranteed miss; deflect halves its damage. Against a multiattack the
|
||||
// remaining swings resolve normally — a single proc shouldn't nullify a
|
||||
// boss's whole multiattack round. (This deliberately diverges from the
|
||||
// auto-resolve engine's apply-to-all model.)
|
||||
petWhiff := te.st.c.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.st.c.Mods.PetWhiffProc
|
||||
petDeflect := te.st.c.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.st.c.Mods.PetDeflectProc
|
||||
if petDeflect {
|
||||
te.result.PetDeflected = true
|
||||
}
|
||||
|
||||
// SRD multiattack: each profile entry is one attack roll resolved
|
||||
// through the shared primitive. A registered elite/boss swings its full
|
||||
// profile; everyone else gets a single attack from the template stats.
|
||||
// resolveEnemyAttack returns true when the fight is decided — either the
|
||||
// player went down without a death save, or a reflect consumable killed
|
||||
// the enemy. Disambiguate by inspecting HP.
|
||||
for i, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) {
|
||||
swing := *te.enemy
|
||||
swing.Stats.Attack = atk.Damage
|
||||
swing.Stats.AttackBonus = atk.AttackBonus
|
||||
// Spend the proc on the first swing only; later swings see false.
|
||||
swingWhiff := petWhiff && i == 0
|
||||
swingDeflect := petDeflect && i == 0
|
||||
decided := resolveEnemyAttack(te.st, te.st.c, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false)
|
||||
if te.st.playerHP <= 0 {
|
||||
// The target is down. The enemy stops swinging at a corpse; the
|
||||
// fight ends only if the roster is empty.
|
||||
if !te.st.anyAlive() {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
if decided || te.st.enemyHP <= 0 {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
te.st.seat(tgt)
|
||||
target = tgt
|
||||
}
|
||||
if te.enemyAttackAction(target) {
|
||||
return
|
||||
}
|
||||
}
|
||||
te.advance()
|
||||
}
|
||||
|
||||
// enemyAttackAction resolves one enemy attack-action — the full SRD multiattack
|
||||
// profile — against the seat the cursor already points at, and stamps every event
|
||||
// it emits to that seat (the party's enemy re-targets across the roster within one
|
||||
// turn, so the whole-turn blanket stamp in step() no longer holds). Registered
|
||||
// elites/bosses swing their full profile; everyone else gets a single attack from
|
||||
// the template stats.
|
||||
//
|
||||
// Returns true only when it decided the fight (te.finish has already been called),
|
||||
// so the caller ends the enemy turn. It returns false when the target dropped but
|
||||
// the roster is still alive — this action is over, and the caller re-targets the
|
||||
// next one.
|
||||
func (te *turnEngine) enemyAttackAction(seat int) (finished bool) {
|
||||
mark := len(te.st.events)
|
||||
// Pet defensive procs are a single proc per attack-action: roll once, then
|
||||
// spend on the first swing only. Whiff makes that one swing a guaranteed miss;
|
||||
// deflect halves its damage. Against a multiattack the remaining swings resolve
|
||||
// normally — a single proc shouldn't nullify a boss's whole multiattack.
|
||||
// (This deliberately diverges from the auto-resolve engine's apply-to-all.)
|
||||
petWhiff := te.st.c.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.st.c.Mods.PetWhiffProc
|
||||
petDeflect := te.st.c.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.st.c.Mods.PetDeflectProc
|
||||
if petDeflect {
|
||||
te.result.PetDeflected = true
|
||||
}
|
||||
|
||||
for i, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) {
|
||||
swing := *te.enemy
|
||||
swing.Stats.Attack = atk.Damage
|
||||
swing.Stats.AttackBonus = atk.AttackBonus
|
||||
swingWhiff := petWhiff && i == 0
|
||||
swingDeflect := petDeflect && i == 0
|
||||
decided := resolveEnemyAttack(te.st, te.st.c, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false)
|
||||
if te.st.playerHP <= 0 {
|
||||
te.stampSeat(mark, seat)
|
||||
// The target is down. The enemy stops swinging at a corpse; the fight
|
||||
// ends only if the roster is empty, otherwise this action is over and
|
||||
// the caller re-targets.
|
||||
if !te.st.anyAlive() {
|
||||
te.finish(CombatStatusLost)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if decided || te.st.enemyHP <= 0 {
|
||||
te.stampSeat(mark, seat)
|
||||
te.finish(CombatStatusWon)
|
||||
return true
|
||||
}
|
||||
}
|
||||
te.stampSeat(mark, seat)
|
||||
return false
|
||||
}
|
||||
|
||||
// turnAbilityFires decides whether a monster ability triggers this enemy turn.
|
||||
// The auto-resolve engine gates abilities on its named phase clock (Opening /
|
||||
// Clash / Decisive); the turn-based duel has no phase clock, so the phase tag
|
||||
@@ -795,9 +756,7 @@ func (te *turnEngine) stepRoundEnd() {
|
||||
// Concentration aura (Spirit Guardians et al.): the lingering spell bites
|
||||
// the enemy each round it stays up. Concentration is per-caster, so every
|
||||
// seat holding one pulses. Ticks before enemy regen so a lethal pulse
|
||||
// settles the fight before the enemy knits its wounds back — and before
|
||||
// Misty's crowd swings, so a caster whose aura would end the round is not
|
||||
// robbed of the win by an end-of-round debuff.
|
||||
// settles the fight before the enemy knits its wounds back.
|
||||
for i := range st.actors {
|
||||
st.seat(i)
|
||||
if st.concentrationDmg <= 0 || st.enemyHP <= 0 || st.playerHP <= 0 {
|
||||
@@ -813,24 +772,6 @@ func (te *turnEngine) stepRoundEnd() {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Misty's crowd, then Misty's heal — per seat, after the round's other
|
||||
// damage (including the concentration pulse above) has landed so the heal
|
||||
// can answer it, which is the order endOfRoundForSeat uses. Both no-op (and
|
||||
// draw no RNG) for a character with no Misty history, which is every
|
||||
// simulated one.
|
||||
for i := range st.actors {
|
||||
st.seat(i)
|
||||
if st.playerHP <= 0 {
|
||||
continue
|
||||
}
|
||||
mark := len(st.events)
|
||||
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, te.result)
|
||||
te.stampSeat(mark, i)
|
||||
if over {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
}
|
||||
}
|
||||
st.seat(0)
|
||||
// Regenerate (monster ability): the enemy knits its wounds at round end.
|
||||
if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP {
|
||||
|
||||
@@ -332,22 +332,8 @@ 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
|
||||
@@ -360,7 +346,7 @@ func saveDnDCharacterExec(ex sqlExecer, c *DnDCharacter) error {
|
||||
if c.OnboardingSent {
|
||||
onboard = 1
|
||||
}
|
||||
_, err := ex.Exec(`
|
||||
_, err := db.Get().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,
|
||||
|
||||
@@ -410,52 +410,22 @@ func trySimAutoArm(c *DnDCharacter) string {
|
||||
return ab.Name
|
||||
}
|
||||
|
||||
// consumeArmedAbility disarms the character, returning the ability id that was
|
||||
// armed. It is the *mutating* half of arming: the flag is cleared and saved, so
|
||||
// it must run exactly once per fight, at fight start.
|
||||
//
|
||||
// It is split from applyAbilityByID because a turn-based fight rebuilds its
|
||||
// combatants from the DB on every player command. A rebuild that consumed would
|
||||
// fire the ability on round 1, clear the flag, and then hand every later round a
|
||||
// character with no ability at all — the player pays the resource for one round
|
||||
// of a buff that is supposed to span the fight. So the fight consumes once and
|
||||
// persists the id on the session; each rebuild re-applies it from there.
|
||||
//
|
||||
// An id that is no longer in the ability table is disarmed and reported as "".
|
||||
func consumeArmedAbility(c *DnDCharacter) string {
|
||||
// applyArmedAbility checks for a pre-armed ability on the character and
|
||||
// applies its effect to the player's CombatModifiers, then clears the armed
|
||||
// flag. Called from combat_bridge.go before SimulateCombat.
|
||||
func applyArmedAbility(c *DnDCharacter, mods *CombatModifiers) (string, bool) {
|
||||
if c == nil || c.ArmedAbility == "" {
|
||||
return ""
|
||||
}
|
||||
armed := c.ArmedAbility
|
||||
c.ArmedAbility = ""
|
||||
_ = SaveDnDCharacter(c)
|
||||
if _, ok := dndActiveAbilities[armed]; !ok {
|
||||
return ""
|
||||
}
|
||||
return armed
|
||||
}
|
||||
|
||||
// applyAbilityByID folds an ability's effect into a freshly-derived set of
|
||||
// CombatModifiers. It is pure with respect to persistence — no DB write, no
|
||||
// disarm — so it is safe to call on every rebuild of an in-flight fight.
|
||||
//
|
||||
// abilityID is what consumeArmedAbility returned at fight start. Empty (nobody
|
||||
// armed anything) and unknown ids are both no-ops.
|
||||
func applyAbilityByID(c *DnDCharacter, abilityID string, mods *CombatModifiers) (string, bool) {
|
||||
if c == nil || abilityID == "" {
|
||||
return "", false
|
||||
}
|
||||
ab, ok := dndActiveAbilities[abilityID]
|
||||
ab, ok := dndActiveAbilities[c.ArmedAbility]
|
||||
if !ok {
|
||||
c.ArmedAbility = ""
|
||||
_ = SaveDnDCharacter(c)
|
||||
return "", false
|
||||
}
|
||||
ab.Apply(c, mods)
|
||||
return ab.Name, true
|
||||
}
|
||||
|
||||
// armAbilityForFight consumes whatever the character armed and applies it in one
|
||||
// step, for the auto-resolve callers that build a combatant and immediately
|
||||
// fight with it. A turn-based fight must not use this — see consumeArmedAbility.
|
||||
func armAbilityForFight(c *DnDCharacter, mods *CombatModifiers) (string, bool) {
|
||||
return applyAbilityByID(c, consumeArmedAbility(c), mods)
|
||||
firedName := ab.Name
|
||||
c.ArmedAbility = ""
|
||||
_ = SaveDnDCharacter(c)
|
||||
return firedName, true
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ func TestSpendAndRefreshResource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestArmAbilityForFight_FighterSecondWind(t *testing.T) {
|
||||
func TestApplyArmedAbility_FighterSecondWind(t *testing.T) {
|
||||
setupAbilitiesTestDB(t)
|
||||
uid := id.UserID("@arm_fighter:example")
|
||||
c := &DnDCharacter{
|
||||
@@ -162,7 +162,7 @@ func TestArmAbilityForFight_FighterSecondWind(t *testing.T) {
|
||||
}
|
||||
|
||||
mods := CombatModifiers{}
|
||||
name, fired := armAbilityForFight(c, &mods)
|
||||
name, fired := applyArmedAbility(c, &mods)
|
||||
if !fired {
|
||||
t.Fatal("ability did not fire")
|
||||
}
|
||||
@@ -181,7 +181,7 @@ func TestArmAbilityForFight_FighterSecondWind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestArmAbilityForFight_MageMagicMissile(t *testing.T) {
|
||||
func TestApplyArmedAbility_MageMagicMissile(t *testing.T) {
|
||||
setupAbilitiesTestDB(t)
|
||||
uid := id.UserID("@arm_mage:example")
|
||||
c := &DnDCharacter{
|
||||
@@ -192,7 +192,7 @@ func TestArmAbilityForFight_MageMagicMissile(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mods := CombatModifiers{}
|
||||
_, fired := armAbilityForFight(c, &mods)
|
||||
_, fired := applyArmedAbility(c, &mods)
|
||||
if !fired {
|
||||
t.Fatal("magic missile did not fire")
|
||||
}
|
||||
@@ -202,10 +202,10 @@ func TestArmAbilityForFight_MageMagicMissile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestArmAbilityForFight_NoArm(t *testing.T) {
|
||||
func TestApplyArmedAbility_NoArm(t *testing.T) {
|
||||
c := &DnDCharacter{Class: ClassFighter, ArmedAbility: ""}
|
||||
mods := CombatModifiers{}
|
||||
_, fired := armAbilityForFight(c, &mods)
|
||||
_, fired := applyArmedAbility(c, &mods)
|
||||
if fired {
|
||||
t.Error("fired with no armed ability")
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
//
|
||||
// Bypassed deliberately (Phase 0 simplifying constraints, doc §2):
|
||||
//
|
||||
// - DB-touching layers: applyMagicItemEffects, armAbilityForFight, and
|
||||
// - DB-touching layers: applyMagicItemEffects, applyArmedAbility, and
|
||||
// the SaveDnDCharacter inside applyPendingCast. The harness is pure
|
||||
// Go; tests run without a sqlite instance.
|
||||
// - Race passives beyond Human (+1 all): neutral baseline, again per §2.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
@@ -306,13 +305,6 @@ func ensureDnDCharacterForCombat(userID id.UserID, char *AdventureCharacter) (*D
|
||||
c := autoBuildCharacter(userID, char)
|
||||
c.AutoMigrated = true
|
||||
c.PendingSetup = false
|
||||
// Seed the canonical player_meta row before handing back a confirmed
|
||||
// character. Auto-migration otherwise mints a player_meta-less character
|
||||
// (the camcast straggler) that fails every legacy-layer command with
|
||||
// "sql: no rows". No-op for legacy players who already have the row.
|
||||
if err := ensurePlayerMetaSeed(userID); err != nil {
|
||||
return nil, false, fmt.Errorf("seed player_meta: %w", err)
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
@@ -489,23 +481,10 @@ func formatN(n int, word string) string {
|
||||
// wound layered with the fresh equipment cushion. There is no scale
|
||||
// conversion; persistDnDHPAfterCombat is the inverse direct copy.
|
||||
func applyDnDHPScaling(stats *CombatStats, c *DnDCharacter) {
|
||||
if c == nil || c.HPMax <= 0 {
|
||||
if c == nil || c.HPMax <= 0 || c.HPCurrent >= c.HPMax {
|
||||
return
|
||||
}
|
||||
// Well-rested temporary HP (from a long rest at a T2+ home) is a cushion
|
||||
// layered above the fight's MaxHP. It is dormant (0) for anyone not
|
||||
// currently rested — including every scenario in the balance corpus and
|
||||
// the class-balance harness, which never sets it — so the full-HP fast
|
||||
// path below stays byte-identical for them and the golden does not move.
|
||||
if c.TempHP > 0 {
|
||||
stats.MaxHP += c.TempHP
|
||||
}
|
||||
if c.HPCurrent >= c.HPMax {
|
||||
// Full HP: StartHP=0 sentinel already means "enter at MaxHP", which
|
||||
// now includes the +TempHP cushion.
|
||||
return
|
||||
}
|
||||
startHP := c.HPCurrent + stats.HPBonus + c.TempHP
|
||||
startHP := c.HPCurrent + stats.HPBonus
|
||||
if startHP < 1 {
|
||||
startHP = 1
|
||||
}
|
||||
|
||||
@@ -160,9 +160,6 @@ 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),
|
||||
@@ -227,28 +224,6 @@ func getActiveExpedition(userID id.UserID) (*Expedition, error) {
|
||||
return e, err
|
||||
}
|
||||
|
||||
// ownedLiveExpedition returns the expedition this player owns and has not yet
|
||||
// finished with — 'active' or 'extracting'. An extracted expedition is not
|
||||
// over: it holds its roster for the whole resume window (releaseParty is not
|
||||
// called on it), so the owner is still the only person who can close it.
|
||||
//
|
||||
// Active rows sort first, so a leader who owns both — extracted from one, then
|
||||
// started another — resolves to the one they are standing in.
|
||||
func ownedLiveExpedition(userID id.UserID) (*Expedition, error) {
|
||||
row := db.Get().QueryRow(`
|
||||
SELECT`+expeditionSelectCols+`
|
||||
FROM dnd_expedition e
|
||||
WHERE e.user_id = ?
|
||||
AND e.status IN ('active', 'extracting')
|
||||
ORDER BY (e.status = 'active') DESC, e.start_date DESC
|
||||
LIMIT 1`, string(userID))
|
||||
e, err := scanExpedition(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return e, err
|
||||
}
|
||||
|
||||
// getExpedition fetches by ID regardless of status. Test/admin use.
|
||||
func getExpedition(id string) (*Expedition, error) {
|
||||
row := db.Get().QueryRow(`
|
||||
@@ -346,42 +321,6 @@ func updateSupplies(expID string, s ExpeditionSupplies) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// withExpeditionSupplies serializes one read-modify-write of the shared supply
|
||||
// pool. updateSupplies rewrites supplies_json wholesale, so a caller that folds
|
||||
// its delta onto an *Expedition it read earlier silently discards anything that
|
||||
// landed in between — a member's pooled packs, another writer's spend. Handlers
|
||||
// run one goroutine per event, so those writers genuinely interleave.
|
||||
//
|
||||
// fn is handed a freshly-read expedition under the pool's own lock and returns
|
||||
// the supplies to persist. Callers that keep using their own *Expedition
|
||||
// afterwards must copy the returned pool back onto it.
|
||||
//
|
||||
// advUserLock cannot stand in here: it is keyed by sender, so two members
|
||||
// racing the same expedition row take two different mutexes and exclude nobody.
|
||||
func (p *AdventurePlugin) withExpeditionSupplies(
|
||||
expID string, fn func(fresh *Expedition) (ExpeditionSupplies, error),
|
||||
) (ExpeditionSupplies, error) {
|
||||
mu := p.advExpeditionLock(expID)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
fresh, err := getExpedition(expID)
|
||||
if err != nil {
|
||||
return ExpeditionSupplies{}, err
|
||||
}
|
||||
if fresh == nil {
|
||||
return ExpeditionSupplies{}, fmt.Errorf("expedition %s not found", expID)
|
||||
}
|
||||
next, err := fn(fresh)
|
||||
if err != nil {
|
||||
return ExpeditionSupplies{}, err
|
||||
}
|
||||
if err := updateSupplies(expID, next); err != nil {
|
||||
return ExpeditionSupplies{}, err
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// updateCamp persists camp state. Pass nil to break camp.
|
||||
func updateCamp(expID string, c *CampState) error {
|
||||
var arg any
|
||||
@@ -410,18 +349,9 @@ func setExpeditionRunID(expID, runID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// abandonExpedition flags the player's live expedition as abandoned. Idempotent.
|
||||
//
|
||||
// It spans 'extracting' as well as 'active': an extracted expedition still owns
|
||||
// its roster, so abandoning is the leader's only way to free their party
|
||||
// without paying to `!resume` first. Character reset (dnd_setup) leans on this
|
||||
// too — a rerolled leader who left an extracted party behind would otherwise
|
||||
// strand every member until the sweeper reaped the row.
|
||||
//
|
||||
// One row per call, active first, so the run-spawn rollback in expeditionCmdStart
|
||||
// tears down the expedition it just created rather than an older extracted one.
|
||||
// abandonExpedition flags the active expedition as abandoned. Idempotent.
|
||||
func abandonExpedition(userID id.UserID) error {
|
||||
e, err := ownedLiveExpedition(userID)
|
||||
e, err := getActiveExpedition(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -190,18 +190,10 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
|
||||
}
|
||||
}
|
||||
|
||||
// The affordability check above ran against `exp` as it was read, and
|
||||
// nightRolloverBurn may have moved the pool since. Deduct against the pool
|
||||
// as it stands — the same arithmetic, just not onto a stale snapshot.
|
||||
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||
next := fresh.Supplies
|
||||
next.Current -= cost
|
||||
return next, nil
|
||||
})
|
||||
if err != nil {
|
||||
exp.Supplies.Current -= cost
|
||||
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't deduct supplies: "+err.Error())
|
||||
}
|
||||
exp.Supplies = pooled
|
||||
camp := &CampState{
|
||||
Active: true,
|
||||
Type: kind,
|
||||
|
||||
@@ -283,12 +283,6 @@ 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 {
|
||||
@@ -328,37 +322,6 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
"You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
||||
zone.Display, existing.CurrentDay))
|
||||
}
|
||||
// A leader who extracted still holds their roster for the resume window, and
|
||||
// `!resume` only ever reaches the *newest* extracted row. Starting fresh on
|
||||
// top of one would orphan it: unreachable, un-reapable until the sweeper
|
||||
// catches it, with every member still seated and refused a run of their own.
|
||||
//
|
||||
// Only a row with a roster blocks. A solo extraction strands nobody, so
|
||||
// walking away from it stays a normal thing to do.
|
||||
if pending, _ := getResumableExpedition(ctx.Sender); pending != nil {
|
||||
switch {
|
||||
case extractionLapsed(pending, time.Now().UTC()):
|
||||
// Past the window — reap it here rather than make them wait an hour
|
||||
// for the sweeper, and let the new expedition proceed. Route through
|
||||
// the shared reap so the freed members hear about it, same as the
|
||||
// sweeper and `!expedition abandon` do.
|
||||
if err := p.reapLapsedExtraction(pending); err != nil {
|
||||
slog.Warn("expedition: reap lapsed on start", "expedition", pending.ID, "err", err)
|
||||
}
|
||||
default:
|
||||
// A roster still holds; block. On a roster-read error, assume it is
|
||||
// occupied and refuse — proceeding would orphan a party we could not
|
||||
// confirm was empty, the one outcome this guard exists to prevent. A
|
||||
// solo extraction (n == 1) strands nobody, so walking away is fine.
|
||||
n, err := partySize(pending.ID)
|
||||
if err != nil || n > 1 {
|
||||
zone, _ := getZone(pending.ZoneID)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You extracted from **%s** on Day %d and your party is still waiting on you. `!resume` to lead them back in, or `!expedition abandon` to let it go — until you do one or the other, none of them can start a run of their own.",
|
||||
zone.Display, pending.CurrentDay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cost := float64(purchase.Cost())
|
||||
if p.euro == nil {
|
||||
@@ -384,9 +347,6 @@ 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).
|
||||
@@ -650,15 +610,6 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
}
|
||||
if exp == nil {
|
||||
// An extracted expedition is still the owner's to close — it holds the
|
||||
// roster until the resume window lapses. Without this, a leader who
|
||||
// wanted out had to pay to `!resume` first just to abandon.
|
||||
if exp, err = getResumableExpedition(ctx.Sender); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
}
|
||||
isLeader = exp != nil
|
||||
}
|
||||
if exp == nil {
|
||||
return p.SendDM(ctx.Sender, "No active expedition to abandon.")
|
||||
}
|
||||
@@ -668,37 +619,15 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
|
||||
"Only your party leader can abandon the expedition. `!expedition leave` to walk out alone.")
|
||||
}
|
||||
zone, _ := getZone(exp.ZoneID)
|
||||
extracted := exp.Status == ExpeditionStatusExtracting
|
||||
audience := expeditionAudience(exp) // read before abandonExpedition disbands the roster
|
||||
if err := abandonExpedition(ctx.Sender); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error())
|
||||
}
|
||||
markActedToday(ctx.Sender)
|
||||
_ = retireAllRegionRuns(exp)
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "")
|
||||
// An extracted party is standing in town, not in the dungeon: their supplies
|
||||
// are already spent and their loot is already banked. Say the true thing.
|
||||
body := fmt.Sprintf(
|
||||
if err := p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.",
|
||||
zone.Display, exp.CurrentDay)
|
||||
if extracted {
|
||||
body = fmt.Sprintf(
|
||||
"You let the expedition in **%s** go. Day %d is where it ends — loot, XP, and coins are kept. The dungeon remembers.",
|
||||
zone.Display, exp.CurrentDay)
|
||||
}
|
||||
// The roster is being disbanded out from under the members; they hear it from
|
||||
// their leader rather than discovering it the next time a command works again.
|
||||
for _, uid := range audience {
|
||||
if uid == ctx.Sender {
|
||||
continue
|
||||
}
|
||||
if err := p.SendDM(uid, fmt.Sprintf(
|
||||
"Your leader called off the expedition in **%s** on Day %d. You're free to start a run of your own.",
|
||||
zone.Display, exp.CurrentDay)); err != nil {
|
||||
slog.Warn("expedition: abandon DM failed", "user", uid, "expedition", exp.ID, "err", err)
|
||||
}
|
||||
}
|
||||
if err := p.SendDM(ctx.Sender, body); err != nil {
|
||||
zone.Display, exp.CurrentDay)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Emergence seam: see maybeRollPetArrivalOnEmerge.
|
||||
|
||||
@@ -82,39 +82,34 @@ type nightRolloverResult struct {
|
||||
// to finish the rollover; legacy deliverBriefing interleaves processOvernightCamp
|
||||
// between the two so a fortified camp's −5 lands before drift's +3.
|
||||
func (p *AdventurePlugin) nightRolloverBurn(e *Expedition) (float32, error) {
|
||||
// D5-c: Ranger forage runs before the daily burn so the +SU lands on
|
||||
// today's supplies, not tomorrow's. Logged so the end-of-day digest
|
||||
// can surface the gain; pure no-op for non-Ranger characters.
|
||||
if c, err := LoadDnDCharacter(id.UserID(e.UserID)); err == nil && c != nil {
|
||||
if gain := applyRangerForage(e, c, nil); gain > 0 {
|
||||
_ = appendExpeditionLog(e.ID, e.CurrentDay, "forage",
|
||||
fmt.Sprintf("ranger forage +%g SU", gain),
|
||||
flavor.Pick(flavor.HarvestForageSuccess))
|
||||
}
|
||||
}
|
||||
burnOverride := applyZoneTemporalPreBurn(e, e.CurrentDay+1)
|
||||
var newSupplies ExpeditionSupplies
|
||||
var burn float32
|
||||
// Forage and burn land in one write, so they resolve together against the
|
||||
// pool as it stands now — not as `e` last saw it.
|
||||
newSupplies, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||
// D5-c: Ranger forage runs before the daily burn so the +SU lands on
|
||||
// today's supplies, not tomorrow's. Logged so the end-of-day digest
|
||||
// can surface the gain; pure no-op for non-Ranger characters.
|
||||
if c, cerr := LoadDnDCharacter(id.UserID(fresh.UserID)); cerr == nil && c != nil {
|
||||
if gain := applyRangerForage(fresh, c, nil); gain > 0 {
|
||||
_ = appendExpeditionLog(fresh.ID, fresh.CurrentDay, "forage",
|
||||
fmt.Sprintf("ranger forage +%g SU", gain),
|
||||
flavor.Pick(flavor.HarvestForageSuccess))
|
||||
}
|
||||
if burnOverride.Multiplier > 0 {
|
||||
// The temporal override replaces the harsh/siege multiplier, not the
|
||||
// roster's: a party still eats N × 0.8 rations inside a time-warped zone.
|
||||
burn = e.Supplies.DailyBurn * burnOverride.Multiplier * float32(expeditionBurnRatePct(e.ID)) / 100
|
||||
newSupplies = e.Supplies
|
||||
newSupplies.Current -= burn
|
||||
if newSupplies.Current < 0 {
|
||||
newSupplies.Current = 0
|
||||
}
|
||||
burnOverride := applyZoneTemporalPreBurn(fresh, fresh.CurrentDay+1)
|
||||
if burnOverride.Multiplier > 0 {
|
||||
// The temporal override replaces the harsh/siege multiplier, not the
|
||||
// roster's: a party still eats N × 0.8 rations inside a time-warped zone.
|
||||
burn = fresh.Supplies.DailyBurn * burnOverride.Multiplier * float32(expeditionBurnRatePct(fresh.ID)) / 100
|
||||
next := fresh.Supplies
|
||||
next.Current -= burn
|
||||
if next.Current < 0 {
|
||||
next.Current = 0
|
||||
}
|
||||
next.ForagedToday = false
|
||||
return next, nil
|
||||
}
|
||||
harsh := fresh.ThreatLevel > 60 || zoneTemporalHarsh(fresh)
|
||||
next, b := applyExpeditionDailyBurn(fresh, harsh, fresh.SiegeMode)
|
||||
burn = b
|
||||
return next, nil
|
||||
})
|
||||
if err != nil {
|
||||
newSupplies.ForagedToday = false
|
||||
} else {
|
||||
harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e)
|
||||
newSupplies, burn = applyExpeditionDailyBurn(e, harsh, e.SiegeMode)
|
||||
}
|
||||
if err := updateSupplies(e.ID, newSupplies); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := advanceExpeditionDay(e.ID); err != nil {
|
||||
@@ -400,9 +395,6 @@ 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"
|
||||
}
|
||||
@@ -516,9 +508,6 @@ 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"
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ import (
|
||||
const (
|
||||
extractResumeWindow = 7 * 24 * time.Hour
|
||||
forcedExtractCoinTaxFrac = 0.20
|
||||
extractionSweepInterval = time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -170,14 +169,7 @@ func (p *AdventurePlugin) finalizeExpeditionOnZoneClear(userID id.UserID, runID
|
||||
exp.Status = ExpeditionStatusComplete
|
||||
_ = retireAllRegionRuns(exp)
|
||||
p.rollZoneTreasure(userID, exp.ZoneID, advTreasureWeightZoneClear)
|
||||
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
|
||||
return p.AwardCompletionMilestones(exp, false)
|
||||
}
|
||||
|
||||
// midZoneRegionClear reports whether the just-completed run (runID) is the
|
||||
@@ -226,94 +218,6 @@ func getResumableExpedition(userID id.UserID) (*Expedition, error) {
|
||||
return e, err
|
||||
}
|
||||
|
||||
// extractionLapsed reports whether an 'extracting' row is past its resume
|
||||
// window and should be reaped. A NULL completed_at counts as lapsed: the column
|
||||
// is the only clock the window has, and a row without one can never expire.
|
||||
func extractionLapsed(e *Expedition, now time.Time) bool {
|
||||
return e.CompletedAt == nil || now.Sub(*e.CompletedAt) > extractResumeWindow
|
||||
}
|
||||
|
||||
// loadLapsedExtractions returns every extracted expedition whose resume window
|
||||
// has closed, regardless of owner.
|
||||
func loadLapsedExtractions(now time.Time) ([]*Expedition, error) {
|
||||
rows, err := db.Get().Query(`
|
||||
SELECT`+expeditionSelectCols+`
|
||||
FROM dnd_expedition e
|
||||
WHERE e.status = 'extracting'
|
||||
AND (e.completed_at IS NULL OR e.completed_at < ?)`,
|
||||
now.Add(-extractResumeWindow))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanExpeditionRows(rows)
|
||||
}
|
||||
|
||||
// sweepLapsedExtractions closes out every extraction whose seven-day window has
|
||||
// run out, which releases its roster.
|
||||
//
|
||||
// Without this the window is a promise the code never keeps. `handleResumeCmd`
|
||||
// expires a lapsed row lazily, but only the *leader* can call `!resume` — so a
|
||||
// leader who quits, forgets, or simply starts a different expedition leaves the
|
||||
// row 'extracting' forever, and releaseParty (which fires only on a terminal
|
||||
// status) never runs. Every member stays seated, and assertNotAdventuring keeps
|
||||
// refusing them a run of their own. `!expedition leave` is their escape, but a
|
||||
// player should not have to find it.
|
||||
func (p *AdventurePlugin) sweepLapsedExtractions(now time.Time) {
|
||||
exps, err := loadLapsedExtractions(now)
|
||||
if err != nil {
|
||||
slog.Error("expedition: load lapsed extractions", "err", err)
|
||||
return
|
||||
}
|
||||
for _, e := range exps {
|
||||
if err := p.reapLapsedExtraction(e); err != nil {
|
||||
slog.Warn("expedition: expire lapsed extraction", "expedition", e.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reapLapsedExtraction closes one lapsed extraction and tells its roster the
|
||||
// way back has shut. It is the single reap-with-notify path: the hourly
|
||||
// sweeper loops it, and expeditionCmdStart calls it when a player starts a new
|
||||
// expedition on top of one whose window has already closed — so a member is
|
||||
// never silently unseated by whichever path happens to reach the row first.
|
||||
//
|
||||
// The audience is read before the close-out: completeExpedition disbands the
|
||||
// roster, and expeditionAudience reads that roster.
|
||||
func (p *AdventurePlugin) reapLapsedExtraction(e *Expedition) error {
|
||||
audience := expeditionAudience(e)
|
||||
if err := completeExpedition(e.ID, ExpeditionStatusFailed); err != nil {
|
||||
return err
|
||||
}
|
||||
zone, _ := getZone(e.ZoneID)
|
||||
body := fmt.Sprintf(
|
||||
"🕯 **The way back has closed — %s**\n\nYour extracted expedition sat past its seven-day resume window, and the dungeon reshaped without you. Day %d is where it ends.\n\nThe loot, XP, and coins you carried out are still yours. `!expedition list` when you're ready for the next one.",
|
||||
zone.Display, e.CurrentDay)
|
||||
for _, uid := range audience {
|
||||
if err := p.SendDM(uid, body); err != nil {
|
||||
slog.Warn("expedition: lapsed-extraction DM failed", "user", uid, "expedition", e.ID, "err", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// expeditionExtractionSweepTicker reaps lapsed extractions hourly. The window is
|
||||
// seven days, so the cadence only bounds how long a freed member waits to hear
|
||||
// about it.
|
||||
func (p *AdventurePlugin) expeditionExtractionSweepTicker() {
|
||||
ticker := time.NewTicker(extractionSweepInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.sweepLapsedExtractions(time.Now().UTC())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resumeExpedition flips an extracting row back to 'active' with fresh
|
||||
// supplies. Threat/temporal/region state are preserved as-is.
|
||||
func resumeExpedition(expID string, supplies ExpeditionSupplies) error {
|
||||
@@ -419,9 +323,8 @@ func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error
|
||||
if exp == nil {
|
||||
return p.SendDM(ctx.Sender, "No extracted expedition to resume. Use `!expedition start <zone>` to begin a new one.")
|
||||
}
|
||||
if extractionLapsed(exp, time.Now().UTC()) {
|
||||
// Expire it so it doesn't keep resurfacing. The hourly sweeper would get
|
||||
// here on its own; this keeps the refusal and the reap in one breath.
|
||||
if exp.CompletedAt == nil || time.Since(*exp.CompletedAt) > extractResumeWindow {
|
||||
// Expire it so it doesn't keep resurfacing.
|
||||
_ = completeExpedition(exp.ID, ExpeditionStatusFailed)
|
||||
return p.SendDM(ctx.Sender,
|
||||
"That extraction is past its 7-day resume window — the dungeon has reshaped without you. Start a new expedition.")
|
||||
|
||||
@@ -463,9 +463,6 @@ 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{
|
||||
|
||||
@@ -176,30 +176,20 @@ const twoWeeksCacheSize = 3
|
||||
func (p *AdventurePlugin) grantTwoWeeksCache(e *Expedition) string {
|
||||
var lines []string
|
||||
|
||||
if e.Supplies.DailyBurn > 0 {
|
||||
var restocked bool
|
||||
pooled, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||
s := fresh.Supplies
|
||||
if s.DailyBurn <= 0 {
|
||||
return s, nil
|
||||
if s := e.Supplies; s.DailyBurn > 0 {
|
||||
s.Current += twoWeeksRestockDays * s.DailyBurn
|
||||
if s.Max > 0 && s.Current > s.Max {
|
||||
s.Current = s.Max
|
||||
}
|
||||
if s.Current > e.Supplies.Current {
|
||||
if err := updateSupplies(e.ID, s); err != nil {
|
||||
slog.Error("milestone: two weeks restock failed",
|
||||
"expedition", e.ID, "err", err)
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"📦 Supplies restocked — %.1f of %.1f.", s.Current, s.Max))
|
||||
e.Supplies = s
|
||||
}
|
||||
s.Current += twoWeeksRestockDays * s.DailyBurn
|
||||
if s.Max > 0 && s.Current > s.Max {
|
||||
s.Current = s.Max
|
||||
}
|
||||
restocked = s.Current > fresh.Supplies.Current
|
||||
return s, nil
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
slog.Error("milestone: two weeks restock failed",
|
||||
"expedition", e.ID, "err", err)
|
||||
case restocked:
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"📦 Supplies restocked — %.1f of %.1f.", pooled.Current, pooled.Max))
|
||||
e.Supplies = pooled
|
||||
default:
|
||||
e.Supplies = pooled
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,17 +143,13 @@ func (p *AdventurePlugin) regionCmdTravel(ctx MessageContext, exp *Expedition) e
|
||||
// on both paths.
|
||||
func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition, cur, next ExpeditionRegion) (string, error) {
|
||||
// Burn one day of supplies (transit day).
|
||||
var burned float32
|
||||
newSupplies, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||
harsh := fresh.ThreatLevel > 60 || zoneTemporalHarsh(fresh)
|
||||
next, b := applyExpeditionDailyBurn(fresh, harsh, fresh.SiegeMode)
|
||||
burned = b
|
||||
return next, nil
|
||||
})
|
||||
if err != nil {
|
||||
siege := exp.SiegeMode
|
||||
harsh := exp.ThreatLevel > 60 || zoneTemporalHarsh(exp)
|
||||
newSupplies, burned := applyExpeditionDailyBurn(exp, harsh, siege)
|
||||
exp.Supplies = newSupplies
|
||||
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
|
||||
return "", fmt.Errorf("apply transit supply burn: %w", err)
|
||||
}
|
||||
exp.Supplies = newSupplies
|
||||
if err := advanceExpeditionDay(exp.ID); err != nil {
|
||||
return "", fmt.Errorf("advance expedition day: %w", err)
|
||||
}
|
||||
|
||||
@@ -117,14 +117,6 @@ 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,16 +161,6 @@ 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))
|
||||
|
||||
@@ -222,15 +222,8 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error {
|
||||
innPaid = true
|
||||
}
|
||||
|
||||
// Well-rested buff: only when resting at your own home (not the inn), and
|
||||
// only from tier 2 up — a tier-1 shack and a rented inn room grant nothing.
|
||||
restTier := 0
|
||||
if hasHousing {
|
||||
restTier = house.Tier
|
||||
}
|
||||
|
||||
c.HPCurrent = c.HPMax
|
||||
c.TempHP = wellRestedTempHP(c.HPMax, restTier)
|
||||
c.TempHP = 0
|
||||
c.ShortRestCharges = c.Level
|
||||
// Phase 10 SUB2a — long rest clears one level of exhaustion (5e: a
|
||||
// long rest clears one). For Berserker who racks up exhaustion via
|
||||
@@ -248,9 +241,8 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error {
|
||||
}
|
||||
markActedToday(ctx.Sender)
|
||||
_ = refreshAllResources(ctx.Sender)
|
||||
// Phase 9: spell slots refresh on long rest. A home rest also folds in the
|
||||
// well-rested bonus slots (no-op for non-casters and inn/tier-1 rests).
|
||||
slotBonus, _ := applyLongRestSpellSlots(c, restTier)
|
||||
// Phase 9: spell slots refresh on long rest.
|
||||
_ = refreshSpellSlots(ctx.Sender)
|
||||
// Phase 9: Cleric prep flags reset (SP4) — until SP4 ships, default
|
||||
// Cleric grants are already prepared=1 so this is a no-op for them.
|
||||
// Voluntary concentration ends at long rest (mage_armor's 8h is exactly
|
||||
@@ -271,13 +263,5 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error {
|
||||
if line := dndRestLongFlavorLine(hasHousing); line != "" {
|
||||
msg += "\n\n_" + line + "_"
|
||||
}
|
||||
if bits := wellRestedSummary(c.TempHP, slotBonus); bits != "" {
|
||||
homeName := "home"
|
||||
if def := houseTierByTier(restTier); def != nil {
|
||||
homeName = def.Name
|
||||
}
|
||||
msg += fmt.Sprintf("\n\n🛏️ _Well-rested at your %s — %s until your next long rest._",
|
||||
strings.ToLower(homeName), bits)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, msg)
|
||||
}
|
||||
|
||||
@@ -79,20 +79,7 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta,
|
||||
if adv != nil && adv.DisplayName != "" {
|
||||
name = adv.DisplayName
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", name, c.Level, ri.Display, ci.Display))
|
||||
if c.Subclass != "" {
|
||||
if si, ok := subclassInfo(c.Subclass); ok {
|
||||
b.WriteString(fmt.Sprintf(" _%s_\n", si.Display))
|
||||
@@ -225,9 +212,6 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta,
|
||||
if adv.PetName != "" {
|
||||
b.WriteString(fmt.Sprintf(" Pet: %s the %s (lv %d)\n", adv.PetName, adv.PetType, adv.PetLevel))
|
||||
}
|
||||
if adv.Pet2Name != "" {
|
||||
b.WriteString(fmt.Sprintf(" Pet: %s the %s (lv %d)\n", adv.Pet2Name, adv.Pet2Type, adv.Pet2Level))
|
||||
}
|
||||
if house.Tier > 0 {
|
||||
b.WriteString(fmt.Sprintf(" Housing: tier %d\n", house.Tier))
|
||||
}
|
||||
|
||||
@@ -310,13 +310,6 @@ type turnSpellOutcome struct {
|
||||
PlayerHeal int
|
||||
EnemySkip bool
|
||||
Desc string
|
||||
|
||||
// GrimHarvestSlot / GrimHarvestNecrotic are the Necromancy Mage's kill-heal
|
||||
// stash for this cast, zero for everyone else. Unlike the fields above they
|
||||
// outlive the casting round: the caller parks them on the seat's
|
||||
// ActorStatuses and the close-out decides whether the cast was lethal.
|
||||
GrimHarvestSlot int
|
||||
GrimHarvestNecrotic bool
|
||||
}
|
||||
|
||||
// resolveTurnSpell resolves a spell as a turn-based player action, reusing the
|
||||
@@ -351,8 +344,6 @@ func resolveTurnSpell(c *DnDCharacter, spell SpellDefinition, slotLevel int, ene
|
||||
out.EnemyDamage = mods.SpellPreDamage
|
||||
out.EnemySkip = mods.SpellEnemySkipFirst
|
||||
out.Desc = mods.SpellPreDamageDesc
|
||||
out.GrimHarvestSlot = mods.GrimHarvestSlot
|
||||
out.GrimHarvestNecrotic = mods.GrimHarvestNecrotic
|
||||
if out.Desc == "" {
|
||||
out.Desc = spell.Name
|
||||
}
|
||||
|
||||
@@ -564,48 +564,6 @@ func refreshSpellSlots(userID id.UserID) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// applyLongRestSpellSlots resets a caster's slot pool to its base (class +
|
||||
// subclass) total with used=0, then folds in any well-rested bonus slots for a
|
||||
// home rest of the given tier. The bonus lives in `total`, so it is spent like
|
||||
// an ordinary slot and is wiped by the *next* long rest's reset — expiry needs
|
||||
// no separate bookkeeping. It is safe to call on every long rest because
|
||||
// ensureSpellsForCharacter already keeps `total` == slotsForCharacter(c) on
|
||||
// load, so the base reset is a no-op except for clearing a prior bonus.
|
||||
//
|
||||
// Non-casters have an empty pool; the function falls back to the plain
|
||||
// used=0 refresh and grants nothing. Returns the bonus actually granted (nil if
|
||||
// none) so the caller can name it in the rest message.
|
||||
func applyLongRestSpellSlots(c *DnDCharacter, restTier int) (map[int]int, error) {
|
||||
pool := slotsForCharacter(c)
|
||||
if len(pool) == 0 {
|
||||
return nil, refreshSpellSlots(c.UserID)
|
||||
}
|
||||
bonus := wellRestedSlotBonus(restTier, pool)
|
||||
tx, err := db.Get().Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`DELETE FROM dnd_spell_slots WHERE user_id = ?`, string(c.UserID)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for lvl, total := range pool {
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO dnd_spell_slots (user_id, slot_level, total, used)
|
||||
VALUES (?, ?, ?, 0)`,
|
||||
string(c.UserID), lvl, total+bonus[lvl]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(bonus) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return bonus, nil
|
||||
}
|
||||
|
||||
// partialRefreshSpellSlots restores spell slots on short rest. All L1 slots
|
||||
// come back, plus floor(charLevel/4) additional slots distributed
|
||||
// lowest-first across tiers ≥2. Returns slot_level→count restored so the
|
||||
|
||||
@@ -934,22 +934,11 @@ func grimHarvestHeal(c *DnDCharacter, result CombatResult, mods CombatModifiers)
|
||||
if !result.PlayerWon || mods.GrimHarvestSlot <= 0 {
|
||||
return 0
|
||||
}
|
||||
// The spell killed the enemy iff the spell_cast event itself dropped EnemyHP
|
||||
// to 0. If a later round-event (a weapon swing, the pet, a concentration
|
||||
// aura re-tick) finished the kill, no heal.
|
||||
//
|
||||
// It is the *last* spell_cast that has to be lethal, not the first. The
|
||||
// auto-resolve path casts once, pre-combat, so the two coincide there; the
|
||||
// turn-based path can cast every round, and there the stash on the seat's
|
||||
// statuses describes that last cast. Reading the first event would let a
|
||||
// non-lethal opening cantrip veto a heal the killing spell had earned.
|
||||
//
|
||||
// No spell_cast event at all cannot happen while the stash is set — both
|
||||
// surfaces emit one for every damaging cast — so the loop falling through
|
||||
// is not a case, just an absence.
|
||||
for i := len(result.Events) - 1; i >= 0; i-- {
|
||||
if result.Events[i].Action == "spell_cast" {
|
||||
if result.Events[i].EnemyHP > 0 {
|
||||
// The pre-combat spell killed the enemy iff the spell_cast event itself
|
||||
// dropped EnemyHP to 0. If a later round-event finished the kill, no heal.
|
||||
for _, ev := range result.Events {
|
||||
if ev.Action == "spell_cast" {
|
||||
if ev.EnemyHP > 0 {
|
||||
return 0
|
||||
}
|
||||
break
|
||||
|
||||
@@ -127,25 +127,14 @@ func (p *AdventurePlugin) grantDnDXP(userID id.UserID, amount int) ([]LevelUpEve
|
||||
events = append(events, LevelUpEvent{NewLevel: c.Level, HPGain: gain})
|
||||
}
|
||||
|
||||
// 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
|
||||
// Cap at L20 — overflow XP is silently dropped.
|
||||
if c.Level >= dndMaxLevel {
|
||||
c.XP = 0
|
||||
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 err := SaveDnDCharacter(c); err != nil {
|
||||
return events, err
|
||||
}
|
||||
|
||||
if len(events) > 0 {
|
||||
// Phase 10 SUB3a-ii — Battle Master Relentless (L15) raises the
|
||||
|
||||
@@ -471,12 +471,6 @@ type advanceResult struct {
|
||||
// harvest ran (Entry/Trap/Elite/Boss rooms, forks, stops).
|
||||
harvest autoHarvestResult
|
||||
harvestFooter string
|
||||
// zoneCleared is true only when this step was a *full* zone clear
|
||||
// (reason == stopComplete and not a mid-zone region clear). The K
|
||||
// event anchor reads it so a foreground `!zone advance` clear can roll
|
||||
// the grind-loop player's mid-day event — a mid-zone region clear does
|
||||
// not, since the run continues and it would fire per region.
|
||||
zoneCleared bool
|
||||
}
|
||||
|
||||
// zoneCmdAdvance resolves the room the player is currently standing in,
|
||||
@@ -501,16 +495,7 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, err.Error())
|
||||
}
|
||||
sendErr := p.streamOrSend(ctx.Sender, res.preStream, res.intro, res.phases, res.final)
|
||||
// K fourth event anchor: a foreground single-day zone clear is a presence
|
||||
// moment for the grind-loop player the other three anchors miss. Rolled
|
||||
// after the clear DM so any triggered event lands behind it, and only here
|
||||
// — zoneCmdAdvance is the foreground path; autopilot walks go through
|
||||
// runAutopilotWalk and never reach this.
|
||||
if res.zoneCleared {
|
||||
p.maybeFireAnchoredEvent(ctx.Sender, advEventChanceZoneClear)
|
||||
}
|
||||
return sendErr
|
||||
return p.streamOrSend(ctx.Sender, res.preStream, res.intro, res.phases, res.final)
|
||||
}
|
||||
|
||||
// advanceOnce runs the single-room advance pipeline and returns a
|
||||
@@ -752,8 +737,7 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin
|
||||
// and point at the next one — "Cleared {zone}. Run complete." reads
|
||||
// wrong right before the auto-advance transit block (and is shared with
|
||||
// manual `!region travel`, which advances next).
|
||||
region, next, midZone := midZoneRegionClear(ctx.Sender, run.RunID)
|
||||
if midZone {
|
||||
if region, next, midZone := midZoneRegionClear(ctx.Sender, run.RunID); midZone {
|
||||
b.WriteString(fmt.Sprintf("🏁 **Cleared %s.** The way to %s opens ahead.\n\n", region.Name, next.Name))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Run complete.\n\n", zone.Display))
|
||||
@@ -779,12 +763,11 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin
|
||||
}
|
||||
}
|
||||
return advanceResult{
|
||||
preStream: preStream,
|
||||
intro: intro,
|
||||
phases: phases,
|
||||
final: b.String(),
|
||||
reason: stopComplete,
|
||||
zoneCleared: !midZone,
|
||||
preStream: preStream,
|
||||
intro: intro,
|
||||
phases: phases,
|
||||
final: b.String(),
|
||||
reason: stopComplete,
|
||||
}, nil
|
||||
}
|
||||
if forkMsg != "" {
|
||||
@@ -988,25 +971,6 @@ 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.
|
||||
@@ -1023,14 +987,6 @@ 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
|
||||
@@ -1130,13 +1086,6 @@ 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,52 +202,6 @@ 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,11 +454,7 @@ 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 {
|
||||
chance := advIngredientDropChance
|
||||
if m := activeOmen().ConsumableChanceMult; m > 1.0 { // N7/B3 the Omen
|
||||
chance *= m
|
||||
}
|
||||
if rand.Float64() >= chance {
|
||||
if rand.Float64() >= advIngredientDropChance {
|
||||
return nil
|
||||
}
|
||||
act := advIngredientActivities[rand.IntN(len(advIngredientActivities))]
|
||||
@@ -521,13 +517,6 @@ 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)
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
//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 }
|
||||
events := []ambientEvent{
|
||||
return []ambientEvent{
|
||||
ambientEventMonologue(),
|
||||
{
|
||||
Kind: "small_find",
|
||||
@@ -280,19 +280,6 @@ 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
|
||||
@@ -362,14 +349,12 @@ func (p *AdventurePlugin) applyAmbientEffect(e *Expedition, ev ambientEvent) str
|
||||
return "Something out there is paying attention now."
|
||||
case "pack_rat":
|
||||
drain := float32(0.2) + float32(rng.IntN(2))*float32(0.1) // 0.2 or 0.3
|
||||
if _, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||
ns := fresh.Supplies
|
||||
ns.Current -= drain
|
||||
if ns.Current < 0 {
|
||||
ns.Current = 0
|
||||
}
|
||||
return ns, nil
|
||||
}); err != nil {
|
||||
ns := e.Supplies
|
||||
ns.Current -= drain
|
||||
if ns.Current < 0 {
|
||||
ns.Current = 0
|
||||
}
|
||||
if err := updateSupplies(e.ID, ns); err != nil {
|
||||
slog.Warn("expedition: ambient supply drain", "expedition", e.ID, "err", err)
|
||||
return ""
|
||||
}
|
||||
@@ -408,39 +393,6 @@ 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 ""
|
||||
}
|
||||
|
||||
@@ -250,15 +250,10 @@ func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision
|
||||
if exp.Supplies.Current < cost {
|
||||
return "", fmt.Errorf("insufficient supplies (have %.1f, need %.1f)", exp.Supplies.Current, cost)
|
||||
}
|
||||
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||
next := fresh.Supplies
|
||||
next.Current -= cost
|
||||
return next, nil
|
||||
})
|
||||
if err != nil {
|
||||
exp.Supplies.Current -= cost
|
||||
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
|
||||
return "", err
|
||||
}
|
||||
exp.Supplies = pooled
|
||||
camp := &CampState{
|
||||
Active: true,
|
||||
Type: d.Kind,
|
||||
|
||||
@@ -44,14 +44,11 @@ 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") {
|
||||
@@ -118,12 +115,6 @@ 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 ""
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// seedExtraction inserts an 'extracting' row whose window closed `age` ago.
|
||||
// A negative age is a live extraction.
|
||||
func seedExtraction(t *testing.T, expeditionID string, owner id.UserID, age time.Duration) {
|
||||
t.Helper()
|
||||
seedExpedition(t, expeditionID, owner, ExpeditionStatusExtracting)
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE dnd_expedition SET completed_at = ? WHERE expedition_id = ?`,
|
||||
time.Now().UTC().Add(-extractResumeWindow-age), expeditionID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func seatedOn(t *testing.T, member id.UserID) string {
|
||||
t.Helper()
|
||||
e, err := seatedExpeditionFor(member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.ID
|
||||
}
|
||||
|
||||
// The window is the only thing bounding how long a leader's roster holds their
|
||||
// party. Nothing else flips an 'extracting' row terminal unless the leader
|
||||
// personally types !resume, so if the sweep misses, members are stuck forever.
|
||||
func TestSweepLapsedExtractions_ReleasesTheRosterItStranded(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader := id.UserID("@sweep-leader:example.org")
|
||||
member := id.UserID("@sweep-member:example.org")
|
||||
|
||||
seedExtraction(t, "exp-lapsed", leader, time.Hour)
|
||||
if err := joinParty("exp-lapsed", member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := seatedOn(t, member); got != "exp-lapsed" {
|
||||
t.Fatalf("member seated on %q before sweep, want exp-lapsed", got)
|
||||
}
|
||||
|
||||
(&AdventurePlugin{}).sweepLapsedExtractions(time.Now().UTC())
|
||||
|
||||
e, err := getExpedition("exp-lapsed")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.Status != ExpeditionStatusFailed {
|
||||
t.Errorf("status = %q, want failed", e.Status)
|
||||
}
|
||||
if got := seatedOn(t, member); got != "" {
|
||||
t.Errorf("member still seated on %q after sweep", got)
|
||||
}
|
||||
if n, _ := partySize("exp-lapsed"); n != 1 {
|
||||
t.Errorf("roster survived the sweep: partySize = %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The mirror: a leader who extracted an hour ago still has six days to !resume,
|
||||
// and their party must still be there when they do.
|
||||
func TestSweepLapsedExtractions_LeavesALiveExtractionAlone(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader := id.UserID("@sweep-live-leader:example.org")
|
||||
member := id.UserID("@sweep-live-member:example.org")
|
||||
|
||||
seedExtraction(t, "exp-live", leader, -6*24*time.Hour) // one day in
|
||||
if err := joinParty("exp-live", member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
(&AdventurePlugin{}).sweepLapsedExtractions(time.Now().UTC())
|
||||
|
||||
e, _ := getExpedition("exp-live")
|
||||
if e.Status != ExpeditionStatusExtracting {
|
||||
t.Errorf("status = %q, want extracting — the resume window is still open", e.Status)
|
||||
}
|
||||
if got := seatedOn(t, member); got != "exp-live" {
|
||||
t.Errorf("member unseated from a resumable expedition (seated on %q)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// completed_at is the only clock the window has. A row without one can never
|
||||
// lapse, so it must read as already lapsed rather than as immortal.
|
||||
func TestExtractionLapsed_NullCompletedAtIsLapsed(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
if !extractionLapsed(&Expedition{}, now) {
|
||||
t.Error("nil completed_at should read as lapsed")
|
||||
}
|
||||
fresh := now.Add(-time.Hour)
|
||||
if extractionLapsed(&Expedition{CompletedAt: &fresh}, now) {
|
||||
t.Error("an hour-old extraction is not lapsed")
|
||||
}
|
||||
old := now.Add(-extractResumeWindow - time.Second)
|
||||
if !extractionLapsed(&Expedition{CompletedAt: &old}, now) {
|
||||
t.Error("a row past the window is lapsed")
|
||||
}
|
||||
}
|
||||
|
||||
// A leader must be able to let an extracted expedition go without paying to
|
||||
// !resume it first — that is the escape hatch !expedition start points them at.
|
||||
func TestAbandonExpedition_ReachesAnExtractedRowAndFreesTheParty(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader := id.UserID("@abandon-extracted-leader:example.org")
|
||||
member := id.UserID("@abandon-extracted-member:example.org")
|
||||
|
||||
seedExtraction(t, "exp-extracted", leader, -24*time.Hour) // live, resumable
|
||||
if err := joinParty("exp-extracted", member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := abandonExpedition(leader); err != nil {
|
||||
t.Fatalf("abandonExpedition on an extracting row: %v", err)
|
||||
}
|
||||
e, _ := getExpedition("exp-extracted")
|
||||
if e.Status != ExpeditionStatusAbandoned {
|
||||
t.Errorf("status = %q, want abandoned", e.Status)
|
||||
}
|
||||
if got := seatedOn(t, member); got != "" {
|
||||
t.Errorf("member still seated on %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// expeditionCmdStart's run-spawn rollback calls abandonExpedition to tear down
|
||||
// the row it just created. A leader who also owns an older extracted row must
|
||||
// not have that one reaped instead.
|
||||
func TestAbandonExpedition_PrefersTheActiveRowOverAnExtractedOne(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader := id.UserID("@abandon-prefers-active:example.org")
|
||||
|
||||
seedExtraction(t, "exp-old-extracted", leader, -24*time.Hour)
|
||||
seedExpedition(t, "exp-new-active", leader, ExpeditionStatusActive)
|
||||
|
||||
if err := abandonExpedition(leader); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e, _ := getExpedition("exp-new-active"); e.Status != ExpeditionStatusAbandoned {
|
||||
t.Errorf("active row status = %q, want abandoned", e.Status)
|
||||
}
|
||||
if e, _ := getExpedition("exp-old-extracted"); e.Status != ExpeditionStatusExtracting {
|
||||
t.Errorf("extracted row was collateral damage: status = %q", e.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbandonExpedition_NoLiveRow(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
if err := abandonExpedition(id.UserID("@abandon-nothing:example.org")); err != ErrNoActiveExpedition {
|
||||
t.Errorf("err = %v, want ErrNoActiveExpedition", err)
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,18 @@ func partySize(expeditionID string) (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// openParty seats the leader, converting a solo expedition row into a party of
|
||||
// one. It is idempotent: a second call on the same expedition is a no-op, so
|
||||
// the invite path can call it unconditionally before adding a member.
|
||||
func openParty(expeditionID string, leaderID id.UserID) error {
|
||||
_, err := db.Get().Exec(`
|
||||
INSERT INTO expedition_party (expedition_id, user_id, role)
|
||||
VALUES (?, ?, 'leader')
|
||||
ON CONFLICT (expedition_id, user_id) DO NOTHING`,
|
||||
expeditionID, string(leaderID))
|
||||
return err
|
||||
}
|
||||
|
||||
// joinParty seats a member. It refuses a full roster, a duplicate, and a player
|
||||
// who already leads or rides an expedition of their own — the "one active
|
||||
// expedition per user" rule that startExpedition enforces in code, extended to
|
||||
@@ -159,11 +171,9 @@ func joinParty(expeditionID string, userID id.UserID) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// seatLeader converts a solo expedition row into a party of one, inside a
|
||||
// caller's transaction. It reads the owner off the expedition row rather than
|
||||
// trusting a passed-in id, so the roster's leader can never disagree with
|
||||
// dnd_expedition.user_id. Idempotent (ON CONFLICT DO NOTHING), so the invite
|
||||
// path can call it unconditionally before adding a member.
|
||||
// seatLeader is openParty inside a caller's transaction: it reads the owner off
|
||||
// the expedition row rather than trusting a passed-in id, so the roster's leader
|
||||
// can never disagree with dnd_expedition.user_id.
|
||||
func seatLeader(tx *sql.Tx, expeditionID string) error {
|
||||
var owner string
|
||||
err := tx.QueryRow(
|
||||
|
||||
@@ -178,13 +178,21 @@ func (p *AdventurePlugin) expeditionCmdAccept(ctx MessageContext, c *DnDCharacte
|
||||
if isHol, _ := isHolidayToday(); isHol {
|
||||
suppliesPurchase.StandardPacks++
|
||||
}
|
||||
// `exp` was fetched before the coin debit, and a second invitee accepting —
|
||||
// or the leader's day-burn tick — may have rewritten the row since. Folding
|
||||
// onto that stale snapshot would silently drop their packs or resurrect
|
||||
// spent SU, so the fold happens against a fresh read under the pool's lock.
|
||||
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||
return addSupplyPurchase(fresh.Supplies, suppliesPurchase), nil
|
||||
})
|
||||
// updateSupplies overwrites supplies_json wholesale, so the pool has to be
|
||||
// re-read under the expedition's own lock: `exp` was fetched before the coin
|
||||
// debit, and a second invitee accepting — or the leader's day-burn tick —
|
||||
// may have rewritten the row since. Folding onto that stale snapshot would
|
||||
// silently drop their packs or resurrect spent SU.
|
||||
expMu := p.advExpeditionLock(exp.ID)
|
||||
expMu.Lock()
|
||||
fresh, err := getExpedition(exp.ID)
|
||||
if err != nil || fresh == nil {
|
||||
expMu.Unlock()
|
||||
return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool.")
|
||||
}
|
||||
pooled := addSupplyPurchase(fresh.Supplies, suppliesPurchase)
|
||||
err = updateSupplies(exp.ID, pooled)
|
||||
expMu.Unlock()
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool: "+err.Error())
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user