mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Compare commits
30 Commits
n3-p8-enem
...
f4a4c0d30b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4a4c0d30b | ||
|
|
1634bb1970 | ||
|
|
ae7ff38996 | ||
|
|
b42beec348 | ||
|
|
0c4c4757d3 | ||
|
|
fedd357a29 | ||
|
|
59319ede9d | ||
|
|
3f4b4ece5c | ||
|
|
a6f1de4e74 | ||
|
|
1a47a2fdee | ||
|
|
aaa45eab14 | ||
|
|
38a3693832 | ||
|
|
401b17c3bb | ||
|
|
3026fe6012 | ||
|
|
e2c0c3359b | ||
|
|
127d252982 | ||
|
|
c3e122694b | ||
|
|
cc165bed1f | ||
|
|
81b2359109 | ||
|
|
3103b519fb | ||
|
|
c37c95a3e3 | ||
|
|
9b6c1ff9a4 | ||
|
|
aab7a7bad0 | ||
|
|
fd7803b13c | ||
|
|
57a0ea90f2 | ||
|
|
27d6bfd361 | ||
|
|
476384206e | ||
|
|
272f59aa32 | ||
|
|
1de2004f26 | ||
|
|
d01d3e277a |
@@ -46,6 +46,8 @@ func main() {
|
||||
days = flag.Int("days", 0, "stop after N synthetic day rollovers (0 = unbounded; the -cap safety net still applies)")
|
||||
dataDir = flag.String("data", "", "data dir for the temp sqlite db (default: OS tempdir; ignored in matrix mode)")
|
||||
userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)")
|
||||
|
||||
realUser = flag.String("real-user", "", "run an EXISTING character loaded from -data's DB instead of building a synthetic one. Pass the real mxid (e.g. @holymachina:parodia.dev). Requires -data to point at a (copy of a) populated gogobee.db dir. Heals the char to full + tops up the bankroll; leaves race/class/level/gear/spells as-is.")
|
||||
logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)")
|
||||
|
||||
matrix = flag.Bool("matrix", false, "matrix mode — sweep over classes × levels × zones × runs")
|
||||
@@ -90,9 +92,49 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
if *realUser != "" {
|
||||
if *dataDir == "" {
|
||||
fail("-real-user requires -data pointing at a dir containing a populated gogobee.db")
|
||||
}
|
||||
if *party != 1 {
|
||||
fail("-real-user does not support -party yet (would need every seat to be a real, tier-eligible char)")
|
||||
}
|
||||
runReal(*realUser, *zone, *dataDir, *bank, *cap, *days, *logFlag)
|
||||
return
|
||||
}
|
||||
|
||||
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag, followers)
|
||||
}
|
||||
|
||||
// runReal drives an existing character loaded from dataDir's gogobee.db through
|
||||
// a real expedition. dataDir should be a COPY of prod — db.Init runs additive
|
||||
// migrations against it, and the run mutates HP/coin/inventory. Never point this
|
||||
// at the live prod file or at ./data.
|
||||
func runReal(userTag, zone, dataDir string, bank float64, cap, days int, includeLog bool) {
|
||||
runner, err := plugin.NewSimRunner(dataDir)
|
||||
if err != nil {
|
||||
fail("init runner:", err)
|
||||
}
|
||||
defer runner.Close()
|
||||
|
||||
uid := id.UserID(userTag)
|
||||
c, err := runner.PrepareRealCharacter(uid, bank)
|
||||
if err != nil {
|
||||
fail("prepare real character:", err)
|
||||
}
|
||||
res, err := runner.RunExpedition(uid, plugin.ZoneID(zone), cap, days)
|
||||
if res != nil {
|
||||
res.Class = string(c.Class) // real subclass/race aren't in SimResult; class is the useful key
|
||||
if !includeLog {
|
||||
res.Log = nil
|
||||
}
|
||||
emitIndented(res)
|
||||
}
|
||||
if err != nil {
|
||||
fail("run:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// followerClasses expands -party / -party-classes into the class of each
|
||||
// follower seat. An explicit list must name every seat: a party of 3 whose
|
||||
// second follower silently defaulted to the leader's class would quietly
|
||||
|
||||
@@ -357,6 +357,50 @@ 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 {
|
||||
@@ -572,6 +616,15 @@ func RunMaintenance() {
|
||||
{"urban_cache", `DELETE FROM urban_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
|
||||
{"url_cache", `DELETE FROM url_cache WHERE cached_at < ?`, []interface{}{cutoff30d}},
|
||||
|
||||
// Pete adventure-news queue — reap delivered rows (kept only for
|
||||
// idempotency); undelivered rows stay so the sender can retry/park them.
|
||||
{"pete_emit_queue", `DELETE FROM pete_emit_queue WHERE sent_at IS NOT NULL AND sent_at < ?`, []interface{}{cutoff7d}},
|
||||
// ...and reap permanently-parked rows: the sender exhausts its retries
|
||||
// within a few hours, so anything still unsent after 30 days is dead
|
||||
// weight the drain query already skips — drop it so a durable outage
|
||||
// can't accrete rows forever.
|
||||
{"pete_emit_queue_parked", `DELETE FROM pete_emit_queue WHERE sent_at IS NULL AND created_at < ?`, []interface{}{cutoff30d}},
|
||||
|
||||
// Rate limits — purge entries older than today
|
||||
{"rate_limits", `DELETE FROM rate_limits WHERE date < ?`, []interface{}{today}},
|
||||
|
||||
@@ -903,6 +956,46 @@ CREATE TABLE IF NOT EXISTS shade_optout (
|
||||
user_id TEXT PRIMARY KEY
|
||||
);
|
||||
|
||||
-- Pete adventure-news seam: durable outbound queue of game-event facts sent to
|
||||
-- the Pete news bot. Emit enqueues (INSERT OR IGNORE on guid = idempotency); a
|
||||
-- background sender drains rows where sent_at IS NULL. Keyed on the fact guid so
|
||||
-- retries and duplicate emits collapse to one row.
|
||||
CREATE TABLE IF NOT EXISTS pete_emit_queue (
|
||||
guid TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
||||
sent_at INTEGER
|
||||
);
|
||||
|
||||
-- Players who opted out of being named in Pete's adventure news. Enforced at
|
||||
-- emit time (anonymize, never delete). Mirrors shade_optout.
|
||||
CREATE TABLE IF NOT EXISTS news_optout (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
opted_out_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
-- Realm-first ledger for adventure news: the first (kind,target) occurrence
|
||||
-- across all players. INSERT OR IGNORE returns rows-affected>0 exactly once, so
|
||||
-- the first clear of a zone/boss fires as a PRIORITY realm-first and everyone
|
||||
-- after as a BULLETIN personal clear. Seeded by the cold-start backfill so a
|
||||
-- fresh deploy doesn't re-flag historically-cleared content as first-ever.
|
||||
CREATE TABLE IF NOT EXISTS news_realm_firsts (
|
||||
kind TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
first_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (kind, target)
|
||||
);
|
||||
|
||||
-- Durable runtime config for adventure news (e.g. the emission kill-switch).
|
||||
-- Deliberately NOT stored in api_cache: RunMaintenance prunes that table after
|
||||
-- 7 days, which would silently revert an operator's !news off back to on.
|
||||
CREATE TABLE IF NOT EXISTS news_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Birthdays
|
||||
CREATE TABLE IF NOT EXISTS birthdays (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
@@ -1376,6 +1469,21 @@ 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,
|
||||
@@ -1559,6 +1667,20 @@ CREATE TABLE IF NOT EXISTS adventure_rival_challenges (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rival_challenges_user ON adventure_rival_challenges(challenged_id, expires_at);
|
||||
|
||||
-- Duels (N6/C2): player-initiated, staked, no-death combat bouts. Both stakes
|
||||
-- are escrowed while the row lives; expiry/decline refunds the challenger. W/L
|
||||
-- history reuses adventure_rival_records. No bootstrap — absent row == no duel.
|
||||
CREATE TABLE IF NOT EXISTS adventure_duel_challenges (
|
||||
challenge_id TEXT PRIMARY KEY,
|
||||
challenger_id TEXT NOT NULL,
|
||||
challenged_id TEXT NOT NULL,
|
||||
stake INTEGER NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_duel_challenges_user ON adventure_duel_challenges(challenged_id, expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_duel_challenges_expiry ON adventure_duel_challenges(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS community_pot (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
balance INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -2164,6 +2286,73 @@ CREATE TABLE IF NOT EXISTS expedition_invite (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_expedition_invite_user
|
||||
ON expedition_invite(user_id);
|
||||
|
||||
-- ── N6/D3 — the Shadow: a simulated rival adventurer ──────────────────────
|
||||
-- One row per player, born lazily the first midnight after their character
|
||||
-- exists. The Shadow "runs" the same zone progression on a simulated schedule
|
||||
-- (~1.3x the player's own clear pace), advanced once per UTC day by the
|
||||
-- midnight ticker. Pure theatre: no combat, no punishment — only race
|
||||
-- pressure surfaced in the morning briefing and a payoff at each zone clear.
|
||||
--
|
||||
-- Absent == the player has no Shadow yet, which is true of every row that
|
||||
-- existed before N6, so there is nothing to backfill. The ticker mints the
|
||||
-- row on first advance. This table is deliberately NOT part of the
|
||||
-- player_meta save fan-out: the ticker owns it, so a character save can never
|
||||
-- clobber the Shadow's advance (the same isolation journal_pages earns by
|
||||
-- being grant-only, made structural here).
|
||||
--
|
||||
-- name: the rival's proper name, seeded deterministically from the
|
||||
-- player's display name at birth.
|
||||
-- progress: cumulative zone-units the Shadow has run (fractional, so a
|
||||
-- slow player still sees it creep between clears).
|
||||
-- zones_cleared: floor(progress) at the last advance — the committed count,
|
||||
-- stored so the next advance can tell which zones were newly
|
||||
-- finished.
|
||||
-- pending_mask: zones (by progression index bit) the Shadow cleared before
|
||||
-- the player did — a journal page waits in each until the
|
||||
-- player clears that zone's boss.
|
||||
-- crowed_mask: zones the player beat the Shadow to and has already been
|
||||
-- crowed a bonus for — set-once, so re-running a zone the
|
||||
-- Shadow hasn't reached can't farm the crow XP.
|
||||
-- day_counter: the Shadow's own day count, for flavour variety.
|
||||
-- last_advanced: UTC date of the last advance; the per-day idempotency guard.
|
||||
CREATE TABLE IF NOT EXISTS adventure_shadow (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
progress REAL NOT NULL DEFAULT 0,
|
||||
zones_cleared INTEGER NOT NULL DEFAULT 0,
|
||||
pending_mask INTEGER NOT NULL DEFAULT 0,
|
||||
crowed_mask INTEGER NOT NULL DEFAULT 0,
|
||||
day_counter INTEGER NOT NULL DEFAULT 0,
|
||||
last_advanced TEXT NOT NULL DEFAULT '',
|
||||
born_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- World boss (N6/C3) — the monthly communal "Siege". One event at a time is
|
||||
-- live (status='active'); the ticker resolves it at hp_current<=0 (defeated) or
|
||||
-- after ends_at (survived). History rows are retained (autoincrement id), so
|
||||
-- world_boss_contrib keys on boss_id. Deliberately its own tables, outside the
|
||||
-- player_meta save fan-out, so a character save can't clobber the shared pool.
|
||||
CREATE TABLE IF NOT EXISTS world_boss (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
tier INTEGER NOT NULL DEFAULT 5,
|
||||
hp_max INTEGER NOT NULL,
|
||||
hp_current INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
starts_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ends_at DATETIME NOT NULL,
|
||||
resolved_at DATETIME
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS world_boss_contrib (
|
||||
boss_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
fights INTEGER NOT NULL DEFAULT 0,
|
||||
damage INTEGER NOT NULL DEFAULT 0,
|
||||
last_fight_date TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (boss_id, user_id)
|
||||
);
|
||||
`
|
||||
|
||||
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
||||
|
||||
230
internal/peteclient/client.go
Normal file
230
internal/peteclient/client.go
Normal file
@@ -0,0 +1,230 @@
|
||||
// Package peteclient is gogobee's outbound seam to the Pete news bot.
|
||||
//
|
||||
// gogobee is the source of game-event *facts* and owns delivery; Pete owns
|
||||
// voice, authoring, and publishing. This package carries structured facts (not
|
||||
// prose) to Pete's ingest endpoint over the tailnet, bearer-authed.
|
||||
//
|
||||
// Delivery is durable: Emit writes the fact to a SQLite queue and returns
|
||||
// immediately, so a game-loop hook never blocks on the network and a Pete
|
||||
// restart loses nothing. A background sender drains the queue with retry.
|
||||
// Idempotency is on the fact GUID, so retries and duplicate emits are no-ops.
|
||||
package peteclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// Fact is the flat, pre-sanitized payload gogobee POSTs to Pete. Names must be
|
||||
// character names only (never Matrix handles); Actors is the allow-list of the
|
||||
// only names permitted to appear in Pete's rendered output. See
|
||||
// pete_adventure_news_voice.md for the field contract.
|
||||
type Fact struct {
|
||||
GUID string `json:"guid"` // stable idempotency key, e.g. "death:<token>:<ts>"; prefix == event_type
|
||||
EventType string `json:"event_type"`
|
||||
Tier string `json:"tier"` // "priority" | "bulletin"
|
||||
Actors []string `json:"actors"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Opponent string `json:"opponent,omitempty"`
|
||||
Boss string `json:"boss,omitempty"`
|
||||
Zone string `json:"zone,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Level int `json:"level,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Stakes string `json:"stakes,omitempty"`
|
||||
ClassRace string `json:"class_race,omitempty"`
|
||||
Milestone string `json:"milestone,omitempty"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
NoPush bool `json:"no_push,omitempty"` // backfill: suppress Pete web-push
|
||||
}
|
||||
|
||||
// Config controls the seam. Enabled=false makes Emit a durable no-op (nothing
|
||||
// queued), matching the FEATURE_PETE_NEWS master switch that kills emission at
|
||||
// the source.
|
||||
type Config struct {
|
||||
IngestURL string
|
||||
Token string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// Client is the transport half. It is a package singleton initialized by Init,
|
||||
// so emit hooks scattered across plugins (and free functions like
|
||||
// markAdventureDead) can call Emit without threading a handle through.
|
||||
type Client struct {
|
||||
cfg Config
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
var std *Client
|
||||
|
||||
// Tuning for the background sender.
|
||||
const (
|
||||
senderTick = 15 * time.Second
|
||||
senderBatch = 20
|
||||
maxAttempts = 8 // ~ up to a few hours of backoff, then park
|
||||
backoffBase = 30 * time.Second
|
||||
backoffCapSec = 3600
|
||||
sendTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// Init wires the singleton from the environment. Mirrors the per-plugin config
|
||||
// pattern (email_nag.go): PETE_INGEST_URL, PETE_INGEST_TOKEN, FEATURE_PETE_NEWS.
|
||||
func Init() {
|
||||
cfg := Config{
|
||||
IngestURL: strings.TrimRight(os.Getenv("PETE_INGEST_URL"), "/"),
|
||||
Token: os.Getenv("PETE_INGEST_TOKEN"),
|
||||
Enabled: strings.EqualFold(os.Getenv("FEATURE_PETE_NEWS"), "true"),
|
||||
}
|
||||
if cfg.Enabled && (cfg.IngestURL == "" || cfg.Token == "") {
|
||||
slog.Warn("peteclient: FEATURE_PETE_NEWS=true but PETE_INGEST_URL/PETE_INGEST_TOKEN unset — disabling")
|
||||
cfg.Enabled = false
|
||||
}
|
||||
std = &Client{cfg: cfg, http: &http.Client{Timeout: sendTimeout}}
|
||||
if cfg.Enabled {
|
||||
slog.Info("peteclient: adventure news emission enabled", "ingest", cfg.IngestURL)
|
||||
} else {
|
||||
slog.Info("peteclient: adventure news emission disabled (set FEATURE_PETE_NEWS=true)")
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether emission is on. Callers can skip building an
|
||||
// (expensive) fact when it would be dropped anyway.
|
||||
func Enabled() bool { return std != nil && std.cfg.Enabled }
|
||||
|
||||
// Emit durably queues a fact for delivery to Pete. It never blocks on the
|
||||
// network. A no-op (but safe) when the seam is disabled or the GUID was already
|
||||
// queued — idempotency is on the GUID primary key.
|
||||
func Emit(f Fact) {
|
||||
if !Enabled() {
|
||||
return
|
||||
}
|
||||
if f.GUID == "" {
|
||||
slog.Error("peteclient: refusing to queue fact with empty guid", "event_type", f.EventType)
|
||||
return
|
||||
}
|
||||
payload, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
slog.Error("peteclient: marshal fact", "guid", f.GUID, "err", err)
|
||||
return
|
||||
}
|
||||
// OR IGNORE gives GUID-idempotency: a re-emit of the same event is dropped.
|
||||
db.Exec("pete emit enqueue",
|
||||
`INSERT OR IGNORE INTO pete_emit_queue (guid, payload, created_at, attempts, next_attempt_at)
|
||||
VALUES (?, ?, unixepoch(), 0, 0)`,
|
||||
f.GUID, string(payload))
|
||||
}
|
||||
|
||||
// StartSender launches the background drain loop. It runs until ctx is
|
||||
// canceled. Safe to call when disabled — it simply idles.
|
||||
func StartSender(ctx context.Context) {
|
||||
if std == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
t := time.NewTicker(senderTick)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if std.cfg.Enabled {
|
||||
std.drain(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// drain sends up to senderBatch due rows, one at a time.
|
||||
func (c *Client) drain(ctx context.Context) {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT guid, payload FROM pete_emit_queue
|
||||
WHERE sent_at IS NULL AND attempts < ? AND next_attempt_at <= unixepoch()
|
||||
ORDER BY created_at LIMIT ?`,
|
||||
maxAttempts, senderBatch)
|
||||
if err != nil {
|
||||
slog.Error("peteclient: drain query", "err", err)
|
||||
return
|
||||
}
|
||||
type item struct{ guid, payload string }
|
||||
var batch []item
|
||||
for rows.Next() {
|
||||
var it item
|
||||
if err := rows.Scan(&it.guid, &it.payload); err != nil {
|
||||
slog.Error("peteclient: drain scan", "err", err)
|
||||
continue
|
||||
}
|
||||
batch = append(batch, it)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, it := range batch {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := c.send(ctx, []byte(it.payload)); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
// Shutdown canceled the in-flight send — Pete didn't reject
|
||||
// anything. Don't burn a durable retry attempt; the row is picked
|
||||
// up on the next boot's drain.
|
||||
return
|
||||
}
|
||||
db.Exec("pete emit retry",
|
||||
`UPDATE pete_emit_queue
|
||||
SET attempts = attempts + 1, next_attempt_at = unixepoch() + ?
|
||||
WHERE guid = ?`,
|
||||
backoffSec(it.guid), it.guid)
|
||||
slog.Warn("peteclient: emit failed, will retry", "guid", it.guid, "err", err)
|
||||
continue
|
||||
}
|
||||
db.Exec("pete emit sent",
|
||||
`UPDATE pete_emit_queue SET sent_at = unixepoch() WHERE guid = ?`, it.guid)
|
||||
}
|
||||
}
|
||||
|
||||
// send POSTs one payload to Pete's ingest endpoint with bearer auth. Mirrors the
|
||||
// bearer-POST pattern in email_nag.go:sendCode.
|
||||
func (c *Client) send(ctx context.Context, payload []byte) error {
|
||||
url := c.cfg.IngestURL + "/api/ingest/adventure"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("pete ingest status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// backoffSec computes the retry delay for a row. It re-reads the current attempt
|
||||
// count so the delay grows geometrically without needing it passed in.
|
||||
func backoffSec(guid string) int {
|
||||
var attempts int
|
||||
_ = db.Get().QueryRow(`SELECT attempts FROM pete_emit_queue WHERE guid = ?`, guid).Scan(&attempts)
|
||||
// attempts is the count *before* this failure's increment; delay off it.
|
||||
delay := int(backoffBase.Seconds()) << attempts
|
||||
if delay > backoffCapSec {
|
||||
delay = backoffCapSec
|
||||
}
|
||||
return delay
|
||||
}
|
||||
91
internal/peteclient/client_test.go
Normal file
91
internal/peteclient/client_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package peteclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// TestEmitDrainRoundTrip proves the durable path: Emit queues a fact, the sender
|
||||
// POSTs it to Pete with bearer auth, and the row is marked sent (so it won't
|
||||
// re-send), while a duplicate GUID collapses to one delivery.
|
||||
func TestEmitDrainRoundTrip(t *testing.T) {
|
||||
if err := db.Init(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var got []Fact
|
||||
var gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/ingest/adventure" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var f Fact
|
||||
_ = json.Unmarshal(body, &f)
|
||||
mu.Lock()
|
||||
got = append(got, f)
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
std = &Client{cfg: Config{IngestURL: srv.URL, Token: "tok", Enabled: true}, http: srv.Client()}
|
||||
|
||||
Emit(Fact{GUID: "death:a:1", EventType: "death", Tier: "priority", Subject: "Brannigan", OccurredAt: 1})
|
||||
Emit(Fact{GUID: "death:a:1", EventType: "death", Tier: "priority", Subject: "Brannigan", OccurredAt: 1}) // dup guid
|
||||
|
||||
std.drain(context.Background())
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("delivered %d facts, want 1 (dup should collapse)", len(got))
|
||||
}
|
||||
if got[0].Subject != "Brannigan" {
|
||||
t.Errorf("subject = %q", got[0].Subject)
|
||||
}
|
||||
if gotAuth != "Bearer tok" {
|
||||
t.Errorf("auth header = %q", gotAuth)
|
||||
}
|
||||
|
||||
var pending int
|
||||
if err := db.Get().QueryRow(`SELECT count(*) FROM pete_emit_queue WHERE sent_at IS NULL`).Scan(&pending); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pending != 0 {
|
||||
t.Errorf("%d rows still pending after successful drain", pending)
|
||||
}
|
||||
|
||||
// A second drain re-sends nothing.
|
||||
std.drain(context.Background())
|
||||
if len(got) != 1 {
|
||||
t.Errorf("re-drained a sent row: %d deliveries", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitDisabledNoQueue: when disabled, Emit is a durable no-op.
|
||||
func TestEmitDisabledNoQueue(t *testing.T) {
|
||||
if err := db.Init(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
std = &Client{cfg: Config{Enabled: false}, http: http.DefaultClient}
|
||||
Emit(Fact{GUID: "disabled-guid", EventType: "death", OccurredAt: 1})
|
||||
var n int
|
||||
// db.Init is a process singleton, so this may share state with other tests;
|
||||
// scope the check to this fact's guid.
|
||||
if err := db.Get().QueryRow(`SELECT count(*) FROM pete_emit_queue WHERE guid = 'disabled-guid'`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("disabled Emit queued %d rows, want 0", n)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
@@ -101,7 +102,7 @@ func (p *AchievementsPlugin) checkAndGrant(userID id.UserID) {
|
||||
}
|
||||
|
||||
func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID string) {
|
||||
_, err := d.Exec(
|
||||
res, err := d.Exec(
|
||||
`INSERT INTO achievements (user_id, achievement_id) VALUES (?, ?) ON CONFLICT DO NOTHING`,
|
||||
string(userID), achievementID,
|
||||
)
|
||||
@@ -109,7 +110,43 @@ func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID st
|
||||
slog.Error("achievements: grant", "user", userID, "achievement", achievementID, "err", err)
|
||||
return
|
||||
}
|
||||
// Only a genuinely new grant is news; a re-check conflict (rows==0) is not.
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return
|
||||
}
|
||||
slog.Info("achievements: granted", "user", userID, "achievement", achievementID)
|
||||
p.emitMilestoneNews(d, userID, achievementID)
|
||||
}
|
||||
|
||||
// emitMilestoneNews files a BULLETIN milestone to Pete for RARE achievements
|
||||
// only — rarity-gated to a single holder (per the news selection thresholds) so
|
||||
// routine unlocks don't flood the feed. Character name only; no-op unless the
|
||||
// seam is enabled.
|
||||
func (p *AchievementsPlugin) emitMilestoneNews(d *sql.DB, userID id.UserID, achievementID string) {
|
||||
var holders int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM achievements WHERE achievement_id = ?`, achievementID).Scan(&holders); err != nil || holders != 1 {
|
||||
return
|
||||
}
|
||||
name := charName(userID)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
label := achievementID
|
||||
for _, a := range p.achievements {
|
||||
if a.ID == achievementID {
|
||||
label = a.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
ts := nowUnix()
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("milestone:%s:%s", eventToken(userID, achievementID), achievementID),
|
||||
EventType: "milestone",
|
||||
Tier: "bulletin",
|
||||
Subject: name,
|
||||
Milestone: label,
|
||||
OccurredAt: ts,
|
||||
}, userID, "")
|
||||
}
|
||||
|
||||
func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error {
|
||||
@@ -1206,9 +1243,39 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
||||
return false
|
||||
},
|
||||
},
|
||||
// N7/B4 — the Renown wing (gogobee_engagement_plan.md §B4). Passive checks
|
||||
// against the derived Renown level (N7/B2); no event hook needed.
|
||||
{
|
||||
ID: "renown_1", Name: "Beyond the Cap", Description: "Earned your first level of Renown. The story was supposed to be over.",
|
||||
Emoji: "✦",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 1) },
|
||||
},
|
||||
{
|
||||
ID: "renown_5", Name: "Storied", Description: "Reached Renown 5. They tell versions of your runs that never happened.",
|
||||
Emoji: "✦",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 5) },
|
||||
},
|
||||
{
|
||||
ID: "renown_10", Name: "Fabled", Description: "Reached Renown 10. Somewhere a bard is getting rich off your name.",
|
||||
Emoji: "✦",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return renownAtLeast(d, u, 10) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// renownAtLeast reports whether the player's derived Renown level (N7/B2) has
|
||||
// reached level, reading player_meta.renown_xp through the passed handle.
|
||||
func renownAtLeast(d *sql.DB, userID id.UserID, level int) bool {
|
||||
var xp int
|
||||
if err := d.QueryRow(
|
||||
`SELECT renown_xp FROM player_meta WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&xp); err != nil {
|
||||
return false
|
||||
}
|
||||
return renownLevelFor(xp) >= level
|
||||
}
|
||||
|
||||
// ── Expedition achievement helpers ──────────────────────────────────────────
|
||||
|
||||
// clearedZoneIDs returns the zones this player has beaten outright. Boss-
|
||||
|
||||
@@ -169,6 +169,7 @@ func (p *AdventurePlugin) Commands() []CommandDef {
|
||||
{Name: "rest", Description: "Rest up (`short`: quick breather, 1h cooldown — `long`: full night, 24h, needs housing or inn)", Usage: "!rest short|long", Category: "Games"},
|
||||
{Name: "arm", Description: "Ready an ability so it fires the moment your next fight starts", Usage: "!arm <ability>", Category: "Games"},
|
||||
{Name: "roll", Description: "Roll dice (NdN+M format, e.g. 2d6+3, d20, 4d6-1)", Usage: "!roll <dice>", Category: "Games"},
|
||||
{Name: "duel", Description: "Challenge another adventurer to a staked, no-death bout", Usage: "!duel @user [stake]", Category: "Games"},
|
||||
{Name: "stats", Description: "Show your ability scores", Usage: "!stats", Category: "Games"},
|
||||
{Name: "level", Description: "Show your level and XP progress", Usage: "!level", Category: "Games"},
|
||||
{Name: "cast", Description: "Cast a spell (queues for next combat, or resolves now if out of combat)", Usage: "!cast <spell> [--upcast N] [--target @user]", Category: "Games"},
|
||||
@@ -179,6 +180,11 @@ 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"},
|
||||
{Name: "news", Description: "Pete's Adventure News — opt out of being named, or opt back in", Usage: "!news [optout|optin]", Category: "Games"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +248,10 @@ func (p *AdventurePlugin) Init() error {
|
||||
// arrival roll. Both idempotent via JobCompleted gates.
|
||||
bootstrapCasterSpellBackfill()
|
||||
bootstrapGrantStarterPet()
|
||||
// Pete adventure-news cold-start: replay the back-catalogue (realm-firsts,
|
||||
// deaths, single-holder achievements) the first boot the seam is live, so
|
||||
// launch doesn't open onto an empty section. One-shot, kept (see gap #7).
|
||||
p.bootstrapPeteNewsBackfill()
|
||||
// Phase R1 orphan-archive used to run here on every Init, but it
|
||||
// over-archived: it treats any active dnd_zone_run row not linked to
|
||||
// an active expedition as a legacy `!adventure dungeon` orphan, which
|
||||
@@ -333,6 +343,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "rest") {
|
||||
return p.handleDnDRestCmd(ctx, p.GetArgs(ctx.Body, "rest"))
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "duel") {
|
||||
return p.handleDuelCmd(ctx, p.GetArgs(ctx.Body, "duel"))
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "arm") {
|
||||
return p.handleDnDArmCmd(ctx, p.GetArgs(ctx.Body, "arm"))
|
||||
}
|
||||
@@ -416,6 +429,23 @@ 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"))
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "news") {
|
||||
return p.handleNewsCmd(ctx)
|
||||
}
|
||||
|
||||
// 1. Arena commands (work in rooms and DMs)
|
||||
if p.IsCommand(ctx.Body, "bail") {
|
||||
@@ -495,6 +525,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
||||
return p.SendDM(ctx.Sender, advHelpText)
|
||||
case lower == "rivals":
|
||||
return p.handleRivalsCmd(ctx)
|
||||
case lower == "duel" || strings.HasPrefix(lower, "duel "):
|
||||
return p.handleDuelCmd(ctx, strings.TrimSpace(args[len("duel"):]))
|
||||
case lower == "babysit" || strings.HasPrefix(lower, "babysit "):
|
||||
return p.handleBabysitCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "babysit")))
|
||||
case lower == "blacksmith" || lower == "repair":
|
||||
@@ -513,6 +545,16 @@ 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.")
|
||||
@@ -530,9 +572,11 @@ 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)
|
||||
@@ -543,6 +587,9 @@ const advHelpText = `**Adventure Commands**
|
||||
` + "`!adventure recipes`" + ` — List crafting recipes known at your current Foraging level
|
||||
` + "`!adventure mastery`" + ` — Per-slot equipment mastery progress and active bonus
|
||||
` + "`!adventure treasures`" + ` — List your treasures · ` + "`treasures lock`" + ` to refuse swaps
|
||||
` + "`!adventure journal`" + ` — Read the campaign pages you've recovered
|
||||
` + "`!adventure shadow`" + ` — See how your rival's run compares to yours
|
||||
` + "`!adventure worldboss`" + ` — The monthly Siege: one communal bout a day (` + "`fight`" + ` to join)
|
||||
` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead)
|
||||
` + "`!thom`" + ` — Visit Thom Krooke (housing and loans)
|
||||
` + "`!adventure help`" + ` — This message
|
||||
@@ -607,6 +654,7 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
|
||||
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||
applyRenownBonuses(bonuses, char.RenownLevel()) // N7/B2 — activity path only, never combat
|
||||
balance := p.euro.GetBalance(char.UserID)
|
||||
|
||||
text := renderAdvMorningDM(char.UserID, equip, balance, bonuses, holName)
|
||||
@@ -757,6 +805,7 @@ func (p *AdventurePlugin) handleLeaderboard(ctx MessageContext) error {
|
||||
ForagingSkill: c.ForagingSkill,
|
||||
FishingSkill: c.FishingSkill,
|
||||
CurrentStreak: c.CurrentStreak,
|
||||
Renown: renownLevelForUser(c.UserID),
|
||||
})
|
||||
}
|
||||
text := renderAdvLeaderboard(entries)
|
||||
@@ -894,9 +943,9 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
|
||||
case "npc_encounter":
|
||||
return p.resolveNPCEncounter(ctx, interaction)
|
||||
case "pet_arrival":
|
||||
return p.resolvePetArrival(ctx)
|
||||
return p.resolvePetArrival(ctx, interaction)
|
||||
case "pet_type":
|
||||
return p.resolvePetType(ctx)
|
||||
return p.resolvePetType(ctx, interaction)
|
||||
case "pet_name":
|
||||
return p.resolvePetName(ctx, interaction)
|
||||
}
|
||||
@@ -1126,7 +1175,7 @@ func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCha
|
||||
return
|
||||
}
|
||||
|
||||
if count < advMaxTreasures {
|
||||
if count < maxTreasuresForTier(char.HouseTier) {
|
||||
// Directly save
|
||||
if err := advSaveTreasure(userID, drop.Def); err != nil {
|
||||
slog.Error("adventure: failed to save treasure", "user", userID, "err", err)
|
||||
@@ -1511,7 +1560,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))
|
||||
return p.SendDM(ctx.Sender, renderRecipesKnown(char.ForagingSkill, homeWorkshopCraftBonus(char.HouseTier)))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleTreasuresCmd(ctx MessageContext, arg string) error {
|
||||
|
||||
@@ -531,6 +531,12 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
|
||||
}
|
||||
totalXP := int(float64(run.XPAccumulated) * xpMult)
|
||||
|
||||
// N7/B3 the Omen — a payout-boosting week scales gross earnings before the
|
||||
// pot tax, so both the player's cut and the pot's rake grow proportionally.
|
||||
if m := activeOmen().ArenaPayoutMult; m > 1.0 {
|
||||
run.Earnings = int64(float64(run.Earnings) * m)
|
||||
}
|
||||
|
||||
// Arena tax: 10% of earnings to community pot.
|
||||
arenaTax := int64(math.Round(float64(run.Earnings) * 0.1))
|
||||
arenaNet := run.Earnings - arenaTax
|
||||
|
||||
@@ -36,22 +36,15 @@ 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() && 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")
|
||||
}
|
||||
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
|
||||
}
|
||||
outcome := "pet_care"
|
||||
if leveled {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
@@ -92,13 +95,44 @@ type AdventureCharacter struct {
|
||||
PetSupplyShopUnlocked bool
|
||||
PetLevel10Date string
|
||||
PetMorningDefense bool
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
|
||||
type AdvEquipment struct {
|
||||
@@ -270,6 +304,11 @@ 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
|
||||
@@ -500,6 +539,41 @@ 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,
|
||||
@@ -562,7 +636,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 = ?
|
||||
FROM adventure_inventory WHERE user_id = ? AND in_vault = 0
|
||||
ORDER BY tier DESC, value DESC`, string(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -613,7 +687,10 @@ func clearAdvInventory(userID id.UserID) ([]AdvItem, error) {
|
||||
return nil, err
|
||||
}
|
||||
d := db.Get()
|
||||
_, err = d.Exec(`DELETE FROM adventure_inventory WHERE user_id = ?`, string(userID))
|
||||
// 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))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -623,10 +700,62 @@ 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 = ?`, string(userID)).Scan(&count)
|
||||
_ = d.QueryRow(`SELECT COUNT(*) FROM adventure_inventory WHERE user_id = ? AND in_vault = 0`, 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,15 +346,34 @@ var craftingRecipes = []CraftingRecipe{
|
||||
{Result: "Voidstone Shard", Ingredients: []string{"Voidstone", "Mythril Ore"}, MinForaging: 30, Tier: 5},
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
base := 0.50
|
||||
levelsAbove := foragingLevel - minForaging
|
||||
bonus := float64(levelsAbove) / 5.0 * 0.03
|
||||
rate := base + bonus
|
||||
if rate > 0.95 {
|
||||
return 0.95
|
||||
rate := base + bonus + workshopBonus
|
||||
cap := 0.95
|
||||
if workshopBonus > 0 {
|
||||
cap = 0.98
|
||||
}
|
||||
if rate > cap {
|
||||
return cap
|
||||
}
|
||||
return rate
|
||||
}
|
||||
@@ -369,7 +388,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) string {
|
||||
func renderRecipesKnown(foragingLevel int, workshopBonus float64) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🧪 **Crafting Recipes** — Foraging Lv.%d\n\n", foragingLevel))
|
||||
|
||||
@@ -377,6 +396,9 @@ func renderRecipesKnown(foragingLevel int) 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{}
|
||||
@@ -402,7 +424,7 @@ func renderRecipesKnown(foragingLevel int) string {
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("**Tier %d** (Foraging %d+)\n", tier, recipes[0].MinForaging))
|
||||
for _, r := range recipes {
|
||||
rate := craftingSuccessRate(foragingLevel, r.MinForaging) * 100
|
||||
rate := craftingSuccessRate(foragingLevel, r.MinForaging, workshopBonus) * 100
|
||||
sb.WriteString(fmt.Sprintf(" • %s — %s (%.0f%% success)\n",
|
||||
r.Result, strings.Join(r.Ingredients, " + "), rate))
|
||||
}
|
||||
@@ -433,7 +455,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) ([]CraftResult, []AdvItem) {
|
||||
func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int, workshopBonus float64) ([]CraftResult, []AdvItem) {
|
||||
if foragingLevel < 10 {
|
||||
return nil, items
|
||||
}
|
||||
@@ -452,7 +474,7 @@ func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int)
|
||||
break
|
||||
}
|
||||
|
||||
rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging)
|
||||
rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging, workshopBonus)
|
||||
success := rand.Float64() < rate
|
||||
|
||||
if success {
|
||||
|
||||
718
internal/plugin/adventure_duel.go
Normal file
718
internal/plugin/adventure_duel.go
Normal file
@@ -0,0 +1,718 @@
|
||||
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"
|
||||
"gogobee/internal/peteclient"
|
||||
|
||||
"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)
|
||||
|
||||
// One BULLETIN dispatch per settled duel (from the winner's side, so it
|
||||
// fires once). Character names only; no-op unless the seam is enabled.
|
||||
if wn, ln := charName(winnerID), charName(loserID); wn != "" && ln != "" {
|
||||
ts := nowUnix()
|
||||
disc := fmt.Sprintf("rival:%d", ts)
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("rival_result:%s:%s:%d", eventToken(winnerID, disc), eventToken(loserID, disc), ts),
|
||||
EventType: "rival_result",
|
||||
Tier: "bulletin",
|
||||
Subject: wn,
|
||||
Opponent: ln,
|
||||
Outcome: "won",
|
||||
OccurredAt: ts,
|
||||
}, winnerID, loserID)
|
||||
}
|
||||
|
||||
p.announceDuel(ctx, winnerID, loserID, ch.Stake, winnerShare, potShare, res.decisive)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) announceDuel(ctx MessageContext, winnerID, loserID id.UserID, stake, winnerShare, potShare int, decisive bool) {
|
||||
winName, loseName := p.DisplayName(winnerID), p.DisplayName(loserID)
|
||||
flair := pickDuelFlavor(duelWinFlavor)
|
||||
if decisive {
|
||||
flair = pickDuelFlavor(duelDecisiveFlavor)
|
||||
}
|
||||
|
||||
p.SendDM(winnerID, fmt.Sprintf(
|
||||
"⚔️ **You beat %s!**\n\n*%s*\n\nYou take €%d (net +€%d). €%d went to the community pot.",
|
||||
loseName, flair, winnerShare, winnerShare-stake, potShare))
|
||||
p.SendDM(loserID, fmt.Sprintf(
|
||||
"⚔️ **%s bested you.**\n\n*%s*\n\nYou're down €%d — but you walk away, no worse for wear.",
|
||||
winName, pickDuelFlavor(duelLossFlavor), stake))
|
||||
|
||||
// Broadcast the result to the games room for bragging rights — unless the
|
||||
// duel was accepted there, in which case the SendReply below already covers
|
||||
// it and a second line would just be noise.
|
||||
if gr := gamesRoom(); gr != "" && gr != ctx.RoomID {
|
||||
verb := "defeated"
|
||||
if decisive {
|
||||
verb = "decisively defeated"
|
||||
}
|
||||
p.SendMessage(gr, fmt.Sprintf("⚔️ **%s** %s **%s** in a duel for €%d.", winName, verb, loseName, stake))
|
||||
}
|
||||
// Confirm in the room the challenge was accepted from too.
|
||||
p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("⚔️ **%s** wins the duel, taking €%d.", winName, winnerShare))
|
||||
}
|
||||
|
||||
// expireDuelChallenges refunds and clears any challenge whose 24h window lapsed.
|
||||
// Rides the 1-minute event ticker (no new goroutine); only the challenger was
|
||||
// escrowed, so only they are refunded.
|
||||
func (p *AdventurePlugin) expireDuelChallenges() {
|
||||
for _, ch := range loadExpiredDuelChallenges() {
|
||||
// Claim before refunding: if an accept/decline already took this row at
|
||||
// the 24h boundary, we must not also refund the challenger.
|
||||
if !claimDuelChallenge(ch.ChallengeID) {
|
||||
continue
|
||||
}
|
||||
p.euro.Credit(ch.ChallengerID, float64(ch.Stake), "duel_refund_expired")
|
||||
p.SendDM(ch.ChallengerID, fmt.Sprintf(
|
||||
"⌛ **%s** never answered your duel. Your €%d stake is refunded.",
|
||||
p.DisplayName(ch.ChallengedID), ch.Stake))
|
||||
}
|
||||
}
|
||||
|
||||
func duelIneligibleSelf(reason string) string {
|
||||
switch reason {
|
||||
case "dead":
|
||||
return "You can't duel while dead. Visit `!hospital` first."
|
||||
case "no adventurer":
|
||||
return "You have no adventurer. Type `!adventure` to create one."
|
||||
default:
|
||||
return fmt.Sprintf("You need to be at least level %d to duel.", duelMinLevel)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Flavor ───────────────────────────────────────────────────────────────────
|
||||
|
||||
var duelWinFlavor = []string{
|
||||
"Steel rang, and the crowd knew its champion.",
|
||||
"A clean finish — you left them nothing to answer with.",
|
||||
"They fought well. You fought better.",
|
||||
}
|
||||
|
||||
var duelDecisiveFlavor = []string{
|
||||
"It was over before it began. Utterly one-sided.",
|
||||
"You never gave them a foothold. A rout.",
|
||||
"The arena barely had time to hush.",
|
||||
}
|
||||
|
||||
var duelLossFlavor = []string{
|
||||
"You'll want that one back. Next time.",
|
||||
"A hard lesson — but only your pride is bleeding.",
|
||||
"Close, in places. Not close enough.",
|
||||
}
|
||||
|
||||
func pickDuelFlavor(pool []string) string {
|
||||
if len(pool) == 0 {
|
||||
return ""
|
||||
}
|
||||
return pool[rand.IntN(len(pool))]
|
||||
}
|
||||
274
internal/plugin/adventure_duel_test.go
Normal file
274
internal/plugin/adventure_duel_test.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func newDuelTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
db.Close()
|
||||
if err := db.Init(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
// duelSeed builds a deterministic RNG so the two-orientation resolver is
|
||||
// reproducible across runs — the production path passes nil (global RNG).
|
||||
func duelSeed(i uint64) *rand.Rand { return rand.New(rand.NewPCG(0x1d4e11, i)) }
|
||||
|
||||
func TestDuelPayout(t *testing.T) {
|
||||
// Both escrow 2000 → pool 4000 → winner 70% (2800), pot 30% (1200).
|
||||
w, pot := duelPayout(2000)
|
||||
if w != 2800 || pot != 1200 {
|
||||
t.Fatalf("duelPayout(2000) = (%d, %d), want (2800, 1200)", w, pot)
|
||||
}
|
||||
// The pot always gets exactly what the winner doesn't; nothing is minted
|
||||
// or lost against the pooled 2×stake.
|
||||
for _, stake := range []int{100, 137, 999, 6000} {
|
||||
w, pot := duelPayout(stake)
|
||||
if w+pot != stake*2 {
|
||||
t.Errorf("duelPayout(%d): winner+pot = %d, want pool %d", stake, w+pot, stake*2)
|
||||
}
|
||||
if w <= pot {
|
||||
t.Errorf("duelPayout(%d): winner %d should exceed pot %d", stake, w, pot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuelMaxStake(t *testing.T) {
|
||||
if got := duelMaxStake(5); got != 2500 {
|
||||
t.Fatalf("duelMaxStake(5) = %d, want 2500", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuelPerHitDamage(t *testing.T) {
|
||||
// 1d8 (avg 4.5) + ability mod 3 = 7.5 base, + Divine Strike 2 = 9.5,
|
||||
// × (1 + 0.25 damage bonus) = 11.875.
|
||||
pc := Combatant{
|
||||
Stats: CombatStats{
|
||||
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8},
|
||||
AbilityModForDamage: 3,
|
||||
},
|
||||
Mods: CombatModifiers{DivineStrikePerHit: 2, DamageBonus: 0.25},
|
||||
}
|
||||
got := duelPerHitDamage(pc)
|
||||
if math.Abs(got-11.875) > 1e-9 {
|
||||
t.Fatalf("duelPerHitDamage = %v, want 11.875", got)
|
||||
}
|
||||
|
||||
// Weaponless build falls back to the flat Attack stat.
|
||||
bare := Combatant{Stats: CombatStats{Attack: 9}}
|
||||
if got := duelPerHitDamage(bare); got != 9 {
|
||||
t.Fatalf("weaponless duelPerHitDamage = %v, want 9", got)
|
||||
}
|
||||
|
||||
// A build that computes to <1 is floored to 1 (a fight always deals damage).
|
||||
weak := Combatant{Stats: CombatStats{Attack: 0}}
|
||||
if got := duelPerHitDamage(weak); got != 1 {
|
||||
t.Fatalf("floored duelPerHitDamage = %v, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynthDuelEnemy(t *testing.T) {
|
||||
pc := Combatant{
|
||||
Name: "Aria",
|
||||
IsPlayer: true,
|
||||
Stats: CombatStats{
|
||||
MaxHP: 123,
|
||||
AC: 17,
|
||||
AttackBonus: 8,
|
||||
Defense: 5,
|
||||
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8},
|
||||
AbilityModForDamage: 3,
|
||||
},
|
||||
Mods: CombatModifiers{ExtraAttacks: 1}, // two swings/round
|
||||
}
|
||||
e := synthDuelEnemy(pc)
|
||||
|
||||
if e.IsPlayer {
|
||||
t.Error("synthesised enemy must not be flagged IsPlayer")
|
||||
}
|
||||
if e.Ability != nil {
|
||||
t.Error("v1 synth enemy carries no MonsterAbility")
|
||||
}
|
||||
// HP / AC / to-hit carry over verbatim: to-hit stays faithful both ways.
|
||||
if e.Stats.MaxHP != 123 || e.Stats.AC != 17 || e.Stats.AttackBonus != 8 {
|
||||
t.Errorf("stat carryover wrong: got HP=%d AC=%d AB=%d", e.Stats.MaxHP, e.Stats.AC, e.Stats.AttackBonus)
|
||||
}
|
||||
// Damage: per-hit (7.5) × swings (2) folded into one flat Attack = 15.
|
||||
if e.Stats.Attack != 15 {
|
||||
t.Errorf("folded Attack = %d, want 15 (7.5 × 2 swings)", e.Stats.Attack)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveDuel_StrongerWinsRegardlessOfRole pins that a clearly superior
|
||||
// build wins whether it issues or receives the challenge — the two-orientation
|
||||
// resolver must not hand the win to the attacker seat.
|
||||
func TestResolveDuel_StrongerWinsRegardlessOfRole(t *testing.T) {
|
||||
strong := Combatant{
|
||||
Name: "Champion", IsPlayer: true,
|
||||
Stats: CombatStats{
|
||||
MaxHP: 220, AC: 19, AttackBonus: 12,
|
||||
Weapon: &WeaponProfile{DamageCount: 2, DamageSides: 8}, AbilityModForDamage: 5, WeaponProficient: true,
|
||||
},
|
||||
}
|
||||
weak := Combatant{
|
||||
Name: "Novice", IsPlayer: true,
|
||||
Stats: CombatStats{
|
||||
MaxHP: 45, AC: 11, AttackBonus: 3,
|
||||
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 4}, AbilityModForDamage: 0, WeaponProficient: true,
|
||||
},
|
||||
}
|
||||
|
||||
for i := uint64(0); i < 50; i++ {
|
||||
if r := resolveDuel(strong, weak, duelSeed(i)); r.draw || !r.challengerWins {
|
||||
t.Fatalf("seed %d: strong-as-challenger should win, got %+v", i, r)
|
||||
}
|
||||
if r := resolveDuel(weak, strong, duelSeed(i)); r.draw || r.challengerWins {
|
||||
t.Fatalf("seed %d: strong-as-challenged should win, got %+v", i, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveDuel_IdenticalFightersAreUnbiased is the fairness guarantee that
|
||||
// justifies running both orientations: with two identical builds neither the
|
||||
// challenger nor the challenged seat may have a systematic edge. A single-
|
||||
// orientation model (attacker uses full kit, defender is a stat block) would
|
||||
// skew hard toward the challenger; the both-orientations margin cancels it.
|
||||
func TestResolveDuel_IdenticalFightersAreUnbiased(t *testing.T) {
|
||||
fighter := Combatant{
|
||||
Name: "Mirror", IsPlayer: true,
|
||||
Stats: CombatStats{
|
||||
MaxHP: 110, AC: 15, AttackBonus: 7,
|
||||
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8}, AbilityModForDamage: 3, WeaponProficient: true,
|
||||
},
|
||||
}
|
||||
|
||||
const n = 1200
|
||||
challengerWins, draws := 0, 0
|
||||
for i := uint64(0); i < n; i++ {
|
||||
r := resolveDuel(fighter, fighter, duelSeed(i))
|
||||
switch {
|
||||
case r.draw:
|
||||
draws++
|
||||
case r.challengerWins:
|
||||
challengerWins++
|
||||
}
|
||||
}
|
||||
decided := n - draws
|
||||
if decided == 0 {
|
||||
t.Fatal("all identical-fighter duels drew — resolver isn't discriminating")
|
||||
}
|
||||
// Unbiased by construction (challenger wins iff D1 > D2 for iid D); allow a
|
||||
// generous ±10% band so this stays a bias check, not a flaky coin-count.
|
||||
rate := float64(challengerWins) / float64(decided)
|
||||
if rate < 0.40 || rate > 0.60 {
|
||||
t.Fatalf("challenger win rate %.3f over %d decided duels — systematic bias (want ~0.5)", rate, decided)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimDuelChallenge pins the serialization primitive that guards every
|
||||
// euro path: exactly one caller may claim a challenge, so accept/decline/expire
|
||||
// can never both move money for the same row.
|
||||
func TestClaimDuelChallenge(t *testing.T) {
|
||||
newDuelTestDB(t)
|
||||
ch := &advDuelChallenge{
|
||||
ChallengeID: "duel-1",
|
||||
ChallengerID: id.UserID("@a:x"),
|
||||
ChallengedID: id.UserID("@b:x"),
|
||||
Stake: 1000,
|
||||
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||
}
|
||||
insertDuelChallenge(ch)
|
||||
|
||||
if pendingDuelForChallenged(id.UserID("@b:x")) == nil {
|
||||
t.Fatal("challenge should be pending for the challenged player before any claim")
|
||||
}
|
||||
if !duelInvolves(id.UserID("@a:x")) || !duelInvolves(id.UserID("@b:x")) {
|
||||
t.Fatal("duelInvolves should see both parties while the challenge is live")
|
||||
}
|
||||
|
||||
if !claimDuelChallenge("duel-1") {
|
||||
t.Fatal("first claim must succeed")
|
||||
}
|
||||
if claimDuelChallenge("duel-1") {
|
||||
t.Fatal("second claim must fail — the row is gone, so no double refund/payout")
|
||||
}
|
||||
if pendingDuelForChallenged(id.UserID("@b:x")) != nil {
|
||||
t.Fatal("claimed challenge must no longer be pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadExpiredDuelChallenges(t *testing.T) {
|
||||
newDuelTestDB(t)
|
||||
insertDuelChallenge(&advDuelChallenge{
|
||||
ChallengeID: "live", ChallengerID: "@a:x", ChallengedID: "@b:x",
|
||||
Stake: 500, ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||
})
|
||||
insertDuelChallenge(&advDuelChallenge{
|
||||
ChallengeID: "stale", ChallengerID: "@c:x", ChallengedID: "@d:x",
|
||||
Stake: 500, ExpiresAt: time.Now().UTC().Add(-time.Hour),
|
||||
})
|
||||
got := loadExpiredDuelChallenges()
|
||||
if len(got) != 1 || got[0].ChallengeID != "stale" {
|
||||
t.Fatalf("loadExpiredDuelChallenges = %+v, want only the stale one", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSynthDuelEnemy_PortsDefenseAndProcs pins the fidelity carry-overs added
|
||||
// after review: a tank's DamageReduct rides onto the synth enemy, and companion
|
||||
// procs fold their expected damage into its round Attack.
|
||||
func TestSynthDuelEnemy_PortsDefenseAndProcs(t *testing.T) {
|
||||
pc := Combatant{
|
||||
Stats: CombatStats{
|
||||
MaxHP: 100, AC: 15, AttackBonus: 6,
|
||||
Weapon: &WeaponProfile{DamageCount: 1, DamageSides: 8}, AbilityModForDamage: 2, // 6.5/hit
|
||||
},
|
||||
Mods: CombatModifiers{
|
||||
DamageReduct: 0.6, // Barbarian-style mitigation
|
||||
SpiritWeaponProc: 0.5, SpiritWeaponDmg: 4, // expected 0.5×(4+3)=3.5/round
|
||||
},
|
||||
}
|
||||
e := synthDuelEnemy(pc)
|
||||
if e.Mods.DamageReduct != 0.6 {
|
||||
t.Errorf("DamageReduct not ported: got %v, want 0.6", e.Mods.DamageReduct)
|
||||
}
|
||||
// One swing (6.5) + proc expectation (3.5) = 10 → rounded 10.
|
||||
if e.Stats.Attack != 10 {
|
||||
t.Errorf("folded Attack = %d, want 10 (6.5 weapon + 3.5 proc)", e.Stats.Attack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDuelStake(t *testing.T) {
|
||||
p := &AdventurePlugin{}
|
||||
const level = 5 // cap = 2500
|
||||
|
||||
// No stake → default to the cap.
|
||||
if got, err := p.parseDuelStake([]string{"@u"}, level); err != nil || got != 2500 {
|
||||
t.Fatalf("default stake = (%d, %v), want (2500, nil)", got, err)
|
||||
}
|
||||
// Explicit in-band stake, with and without the € prefix.
|
||||
if got, err := p.parseDuelStake([]string{"@u", "€1500"}, level); err != nil || got != 1500 {
|
||||
t.Fatalf("€1500 stake = (%d, %v), want (1500, nil)", got, err)
|
||||
}
|
||||
// Over the cap is rejected.
|
||||
if _, err := p.parseDuelStake([]string{"@u", "9999"}, level); err == nil {
|
||||
t.Error("stake above cap should error")
|
||||
}
|
||||
// Below the floor is rejected.
|
||||
if _, err := p.parseDuelStake([]string{"@u", "10"}, level); err == nil {
|
||||
t.Error("stake below the minimum should error")
|
||||
}
|
||||
// Garbage is rejected.
|
||||
if _, err := p.parseDuelStake([]string{"@u", "lots"}, level); err == nil {
|
||||
t.Error("non-numeric stake should error")
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,14 @@ func (p *AdventurePlugin) eventTicker() {
|
||||
// Reclaim invites nobody answered (N3/P6b). Every read already
|
||||
// filters on the TTL; this just stops the rows accumulating.
|
||||
purgeExpiredInvites()
|
||||
|
||||
// World boss (N6/C3): auto-spawn the monthly Siege on the 1st and
|
||||
// resolve one whose 72h window has lapsed. Own dedup inside.
|
||||
p.worldBossTick()
|
||||
|
||||
// Duels (N6/C2): refund and clear any challenge whose 24h accept
|
||||
// window lapsed.
|
||||
p.expireDuelChallenges()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
519
internal/plugin/adventure_flavor_campaign.go
Normal file
519
internal/plugin/adventure_flavor_campaign.go
Normal file
@@ -0,0 +1,519 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// The Hollow King campaign (N5/D1). A light serialized story threaded through
|
||||
// the zones by way of collectible journal pages. "The Hollow King" is already
|
||||
// the Forest Shadows (T2) boss; this frames him as a realm-spanning antagonist
|
||||
// whose fragments turn up wherever players fight elites and open secret rooms.
|
||||
//
|
||||
// The fragments are in-world found artifacts — journal entries, torn letters,
|
||||
// stone inscriptions — not TwinBee's voice. TwinBee's own reactions (D1b) obey
|
||||
// the first-person voice rules; this text does not.
|
||||
|
||||
type journalPage struct {
|
||||
Title string
|
||||
Text string
|
||||
}
|
||||
|
||||
// journalPages is the ordered campaign. Reading the discovered pages top to
|
||||
// bottom tells the fall of a kingdom to the thing its king became. Order is the
|
||||
// story order; players find them out of sequence, which is why the viewer marks
|
||||
// gaps.
|
||||
var journalPages = []journalPage{
|
||||
{"The Long Winter", "A crown is only a circle of gold until a man decides never to take it off. Ours decided in a winter that would not end, and the frost took its cue from him."},
|
||||
{"The Last Physician", "The court healers were sent home one by one, each promising the king had years left. The last was not sent home. We heard him thank the king for the honour."},
|
||||
{"On Shadows", "He stopped casting a shadow before he stopped casting a reflection. The steward struck both from the list of things a servant may notice aloud."},
|
||||
{"The Quiet Ledger", "Grain still left the granaries; no mouths were fed. I stopped auditing the difference the night the number began to feel like a name."},
|
||||
{"A Bargain Overheard", "Through the chapel door: the king's voice, and a second that used his own words a breath before he did. Only one of them was asking."},
|
||||
{"The Hollowing", "They call it a coronation in the records. Those of us who carried the braziers call it what it was. A king was emptied so a crown could keep wearing him."},
|
||||
{"The First Knights", "His honour guard did not die. That was the mercy offered and the price paid. They stand at the old gate yet, and they still salute a throne no living thing sits on."},
|
||||
{"Letters Home, Unsent", "\"Tell mother the pay is good and the work is guarding.\" The satchel held forty such letters, every hand different, every promise the same, none of them sent."},
|
||||
{"The Map Redrawn", "The kingdom did not fall so much as come apart at the seams — a warren here, a drowned temple there, each piece keeping a splinter of him like a tooth in a wound."},
|
||||
{"Root and Rot", "The forest north of the manor grew wrong and grew fast. The woodsmen say the trees lean toward the ruin at dusk. The woodsmen no longer go at dusk."},
|
||||
{"The Warren Below", "Even the goblins gave the deep tunnels to him and asked nothing back. When a scavenger yields ground for free, ask what it saw down there."},
|
||||
{"Water That Remembers", "The temple sank in a single night with the bells still ringing. Divers say the bells ring still, slow, as if something below is counting."},
|
||||
{"The Manor's Long Guest", "Blackspire changed hands nine times in a decade. Every deed names a different owner. Every household names the same tenant, and none will write it down."},
|
||||
{"The Forge Unbanked", "The underforge keeps a heat with no fuel and no smith. What it makes, no one has seen leave. What it makes, we are told, was promised elsewhere long ago."},
|
||||
{"Descent", "The deep roads were a trade route once. Now they are a throat. Everything the surface loses is swallowed the same direction, and the direction has a door at the end."},
|
||||
{"The Bright Country", "Past the crossing the colours are too kind and the days too long. It is the most beautiful place I have run from. He is patient there; he can afford to be."},
|
||||
{"What the Dragon Keeps", "The wyrm hoards more than gold. Deep in the lair, behind the coin, a single crown sits on no head and is guarded better than the hoard."},
|
||||
{"The Portal's Arithmetic", "The abyss gate opens outward. Everyone assumes a door lets things in. This one was built by someone who only ever intended to leave through it."},
|
||||
{"The Regent's Confession", "I ruled in his name for thirty years and never once saw him rule. I signed what the crown wanted signed. I am writing this so that one honest page exists."},
|
||||
{"The Names of the Guard", "I have set down every knight's name here so that when this is read, they are grieved as men and not feared as things. It is the only rescue left to attempt."},
|
||||
{"The Flaw in the Bargain", "The second voice took the king's life and his death both — and a thing that cannot die also cannot be finished. He is not immortal. He is unpaid, and waiting to collect."},
|
||||
{"How to Call Him", "He answers only where his fragments gather and only to one who has gathered them. Do not do this to avenge us. Do it to end the account. Come with the whole ledger or do not come."},
|
||||
{"The Empty Throne", "I have seen the seat at the heart of it all. It is not empty. It is occupied by the shape of a man who left, kept warm against his return."},
|
||||
{"Last Page", "If you are reading in order, you have walked the ruin of everything he emptied to stay. One page is missing from every telling — the one you write by going in. Bring a light. He hates the light. It is the one thing he could never hollow out."},
|
||||
}
|
||||
|
||||
// journalTotalPages is the campaign length; the drop/viewer/finale all read it
|
||||
// so the story can grow by appending to journalPages alone.
|
||||
var journalTotalPages = len(journalPages)
|
||||
|
||||
// journalPageDropChance is the per-elite-kill probability that a page turns up.
|
||||
// Deliberately modest: 24 pages is a long-horizon collection, and secret rooms
|
||||
// (D4) grant pages on top of this. Tunable.
|
||||
const journalPageDropChance = 0.22
|
||||
|
||||
// setJournalPageBit is the in-memory twin of grantJournalPageDB's bitwise OR —
|
||||
// the single definition of "page N lives in bit N-1". Out-of-range pages are a
|
||||
// no-op.
|
||||
func setJournalPageBit(mask int64, page int) int64 {
|
||||
if page < 1 || page > 63 {
|
||||
return mask
|
||||
}
|
||||
return mask | (int64(1) << (page - 1))
|
||||
}
|
||||
|
||||
func journalPageFound(mask int64, page int) bool {
|
||||
if page < 1 || page > 63 {
|
||||
return false
|
||||
}
|
||||
return mask&(int64(1)<<(page-1)) != 0
|
||||
}
|
||||
|
||||
func journalPageCount(mask int64) int {
|
||||
n := 0
|
||||
for i := 1; i <= journalTotalPages; i++ {
|
||||
if journalPageFound(mask, i) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func journalComplete(mask int64) bool {
|
||||
return journalPageCount(mask) >= journalTotalPages
|
||||
}
|
||||
|
||||
// pickUnfoundJournalPage returns a random not-yet-found page number, or 0 when
|
||||
// the campaign is already complete.
|
||||
func pickUnfoundJournalPage(mask int64, rng *rand.Rand) int {
|
||||
var missing []int
|
||||
for i := 1; i <= journalTotalPages; i++ {
|
||||
if !journalPageFound(mask, i) {
|
||||
missing = append(missing, i)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return 0
|
||||
}
|
||||
return missing[rngIntN(rng, len(missing))]
|
||||
}
|
||||
|
||||
// maybeDropJournalPage rolls a page reward for an elite kill or secret room and,
|
||||
// on a hit, grants a random unfound page and returns its narration line. Empty
|
||||
// string means no drop (missed the roll, DB error, or campaign already
|
||||
// complete). The roll draws from the same RNG the surrounding loot rolls use
|
||||
// and never touches SimulateCombat's stream, so the combat golden is unmoved.
|
||||
func (p *AdventurePlugin) maybeDropJournalPage(userID id.UserID, rng *rand.Rand) string {
|
||||
if rngFloat(rng) >= journalPageDropChance {
|
||||
return ""
|
||||
}
|
||||
return p.grantJournalPage(userID, rng)
|
||||
}
|
||||
|
||||
// grantJournalPage grants a random unfound page unconditionally (used by secret
|
||||
// rooms, which award a page for certain). Returns "" when already complete or on
|
||||
// error.
|
||||
func (p *AdventurePlugin) grantJournalPage(userID id.UserID, rng *rand.Rand) string {
|
||||
mask, err := loadJournalPages(userID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
page := pickUnfoundJournalPage(mask, rng)
|
||||
if page == 0 {
|
||||
return ""
|
||||
}
|
||||
if err := grantJournalPageDB(userID, page); err != nil {
|
||||
return ""
|
||||
}
|
||||
// If the page turned up mid-expedition, drop a log beat so the end-of-day
|
||||
// digest can have TwinBee react to it. No expedition (legacy !zone, or a
|
||||
// secret room opened outside a run) simply means no digest to react in.
|
||||
if exp, err := getActiveExpedition(userID); err == nil && exp != nil {
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "journal",
|
||||
journalPages[page-1].Title, fmt.Sprintf("page %d", page))
|
||||
}
|
||||
return fmt.Sprintf("📖 A torn journal page — _%s_ (page %d of %d). See `!adventure journal`.",
|
||||
journalPages[page-1].Title, page, journalTotalPages)
|
||||
}
|
||||
|
||||
// --- N5/D4 secret content pass ---------------------------------------------
|
||||
//
|
||||
// Every NodeKindSecret room resolves as a no-combat treasure cache (see
|
||||
// resolveSecretRoom in dnd_zone_combat.go). Finding it — via the perception /
|
||||
// stat check on the fork, or a cross-zone key — is the reward: a guaranteed
|
||||
// journal page, a LootBias-weighted treasure roll, and a guaranteed consumable
|
||||
// cache. The flavor and the key catalog live here with the rest of the
|
||||
// campaign; the resolution mechanics live next to the other room resolvers.
|
||||
|
||||
// secretRoomCacheCount is how many zone-tier consumables a secret room always
|
||||
// hands over, so the room pays out something visible even for a player who has
|
||||
// every page and misses the treasure roll.
|
||||
const secretRoomCacheCount = 2
|
||||
|
||||
// crossZoneKey pairs a source secret room with the key item it grants. The key
|
||||
// is a plain inventory item; a LockKey edge in the destination zone matches its
|
||||
// lower-cased Name against lock_data.key_id (see evaluateEdgeLock). Keys persist
|
||||
// in inventory, so the unlock is permanent once earned.
|
||||
type crossZoneKey struct {
|
||||
item AdvItem
|
||||
unlocksIn ZoneID // destination zone, for the grant narration
|
||||
unlockHint string // what the key opens, surfaced when it's granted
|
||||
}
|
||||
|
||||
// secretRoomKeys — the two cross-zone keys (N5/D4). A Sunken Temple sigil opens
|
||||
// a sealed reliquary in Manor Blackspire; an Underforge seal opens a vault deep
|
||||
// in the Underdark throne approach. Keyed by the source secret's node ID.
|
||||
var secretRoomKeys = map[string]crossZoneKey{
|
||||
"sunken_temple.coral_reliquary": {
|
||||
item: AdvItem{Name: "Sunken Sigil", Type: "key", Tier: 2},
|
||||
unlocksIn: ZoneManorBlackspire,
|
||||
unlockHint: "a drowned god's mark — Blackspire has a door that remembers it",
|
||||
},
|
||||
"underforge.forge_vault": {
|
||||
item: AdvItem{Name: "Underforge Seal", Type: "key", Tier: 3},
|
||||
unlocksIn: ZoneUnderdark,
|
||||
unlockHint: "the forge's own brand — something in the deep roads was promised its work",
|
||||
},
|
||||
}
|
||||
|
||||
// grantSecretRoomKey hands over the cross-zone key a secret room carries, if it
|
||||
// carries one and the player isn't already holding it. Idempotent: re-finding
|
||||
// the room on a later run won't stack duplicate keys. Returns the narration line
|
||||
// (empty when the room grants no key, the player already has it, or on error).
|
||||
func (p *AdventurePlugin) grantSecretRoomKey(userID id.UserID, node ZoneNode) string {
|
||||
key, ok := secretRoomKeys[node.NodeID]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
items, err := loadAdvInventory(userID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
want := strings.ToLower(key.item.Name)
|
||||
for _, it := range items {
|
||||
if strings.ToLower(it.Name) == want {
|
||||
return "" // already earned — don't stack it
|
||||
}
|
||||
}
|
||||
if err := addAdvInventoryItem(userID, key.item); err != nil {
|
||||
slog.Error("secret room: grant key", "user", userID, "key", key.item.Name, "err", err)
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("🗝 **%s** — %s.", key.item.Name, key.unlockHint)
|
||||
}
|
||||
|
||||
// secretRoomDiscovery is the bespoke in-world discovery line for a secret room,
|
||||
// keyed by node ID. Not TwinBee's voice — the same in-world register the journal
|
||||
// pages use. Nodes without an entry fall back to a generic line built from the
|
||||
// node label.
|
||||
var secretRoomDiscovery = map[string]string{
|
||||
"crypt_valdris.secret_chamber": "🔍 **A sealed chamber.** The other grave-robbers pried at the wrong wall. This one gives — Valdris kept something back even from his own tomb.",
|
||||
"forest_shadows.sapling_shrine": "🔍 **The Sapling Shrine.** A ring of young antlers grown from the dirt, and something votive left at the centre — an offering the King's shell never came back to claim.",
|
||||
"sunken_temple.coral_reliquary": "🔍 **The Coral Reliquary.** Behind a lattice of living coral, a niche the flood sealed rather than drowned. What the temple hid, it hid well.",
|
||||
"manor_blackspire.hidden_oratory": "🔍 **The Hidden Oratory.** A chapel with no door in the floor plan, kept for a tenant the deeds refuse to name.",
|
||||
"manor_blackspire.sealed_reliquary": "🔍 **The Sealed Reliquary.** The drowned god's sigil bites into the lock and turns. Blackspire kept a piece of the temple's secret behind a door only the temple could open.",
|
||||
"underforge.forge_vault": "🔍 **The Forge Vault.** A strongroom off the unbanked heat, its ledger of commissions all addressed elsewhere and long overdue.",
|
||||
"underdark.lost_reliquary": "🔍 **A lost reliquary.** A shrine the deep roads swallowed whole, still lit, still waiting on a procession that stopped coming a long age ago.",
|
||||
"underdark.sealed_forge_vault": "🔍 **The Sealed Vault.** The Underforge's brand fits the seam, and the deep-road door gives up what the forge sent ahead — payment on the old account.",
|
||||
"feywild_crossing.illusion_garden": "🔍 **The Illusion Garden.** A too-kind grove that isn't there when you look straight at it. He can afford beauty here; it costs him nothing to keep.",
|
||||
"dragons_lair.hoard_pillar": "🔍 **A pillar of hoard.** Coin drifted to the ceiling around a single column the wyrm guards more than gold — as if something at its heart were worth more.",
|
||||
"abyss_portal.reality_seam": "🔍 **A seam in the real.** The gate's arithmetic frays here, and through the frayed place a hand once reached to leave a thing behind on the way out.",
|
||||
}
|
||||
|
||||
// secretRoomDiscoveryLine returns the discovery flavor for a secret room,
|
||||
// falling back to a label-driven generic when the node has no bespoke entry.
|
||||
func secretRoomDiscoveryLine(node ZoneNode) string {
|
||||
if line, ok := secretRoomDiscovery[node.NodeID]; ok {
|
||||
return line
|
||||
}
|
||||
label := node.Label
|
||||
if label == "" {
|
||||
label = "a hidden room"
|
||||
}
|
||||
return fmt.Sprintf("🔍 **%s** — a room the others walked past. Something was left here for whoever looked closer.", label)
|
||||
}
|
||||
|
||||
// bossEpilogues ties each zone boss's death to the Hollow King arc: a 2-3
|
||||
// sentence capstone appended to the boss-down moment. Forest of Shadows is the
|
||||
// King himself — but what falls there is a shell he shed, which is why the arc
|
||||
// (and the finale) outlives it. In-world narration, not TwinBee's voice.
|
||||
var bossEpilogues = map[ZoneID]string{
|
||||
ZoneGoblinWarrens: "Grol dies clutching a coin no goblin minted — a king's face worn smooth by handling. Whatever paid the warren to give up its deep tunnels, it paid in a currency older than these hills.",
|
||||
ZoneCryptValdris: "Valdris tried to cheat the grave and managed only to furnish it. In his last rattle he says a name that isn't his — _hollow, hollow_ — as if warning you of a colleague who did it better.",
|
||||
ZoneForestShadows: "The Hollow King falls without weight, a coat slipped from its peg — and the woods do not go quiet. What you felled here was a thing he shed, not the thing he is. Somewhere, the account he owes goes on accruing.",
|
||||
ZoneSunkenTemple: "The Aboleth's dream breaks and the drowned bells still at last. In the silence you understand what they were counting toward — and that the count did not begin with this temple, and does not end with it.",
|
||||
ZoneManorBlackspire: "Aldric was hollowed the same way, by the same hand, and made a poor imitation: a lord kept past his death to hold a house for a guest who never came. He thanks you. It is the first thing he has meant in a century.",
|
||||
ZoneUnderforge: "Thyrak's fires gutter out, and the half-made things on the anvils cool into what they were always going to be — regalia, and soldiers, and a crown with no head to fit. The forge was filling an order placed a long time ago.",
|
||||
ZoneUnderdark: "Ilvaras ruled the throat that swallows everything downward, toward the door at the bottom of the world. She dies certain she served a queen. She served a direction, and the direction has a name it never told her.",
|
||||
ZoneFeywildCrossing: "The Thornmother's garden was the loveliest cage on the road, tended for a patient guest. He can afford patience; you are learning why. She wilts, and the too-kind light dims by exactly one degree.",
|
||||
ZoneDragonsLair: "Behind Infernax's hoard, past the last of the gold, a single crown rests on no head — guarded better than the treasure, because it was the one thing here he was ever paid to keep. The dragon dies never knowing what it was.",
|
||||
ZoneAbyssPortal: "Belaxath guarded a door that opens outward, built by someone who only ever meant to leave through it. As the demon falls, the gate does not close. It was never meant to keep things out — only to let one thing come home.",
|
||||
}
|
||||
|
||||
// bossEpilogueLine returns the campaign capstone for a zone boss, or "" for
|
||||
// zones with none (and for the synthetic arena, which has no ZoneID entry).
|
||||
func bossEpilogueLine(zoneID ZoneID) string {
|
||||
return bossEpilogues[zoneID]
|
||||
}
|
||||
|
||||
// writeBossEpilogue appends a zone's campaign capstone (if any) to a
|
||||
// victory narration. Shared by every boss-down render path — the manual
|
||||
// turn-based finishes (finishCombatSession, finishPartyWin) and the compact
|
||||
// autopilot boss resolve — so the D1b epilogue fires no matter how the boss
|
||||
// was cleared. Caller gates on "this was a boss, not an elite".
|
||||
func writeBossEpilogue(b *strings.Builder, zoneID ZoneID) {
|
||||
if ep := bossEpilogueLine(zoneID); ep != "" {
|
||||
b.WriteString("\n" + ep + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// twinBeeJournalReactions are TwinBee's morning/digest reactions to pages found
|
||||
// during the day — first-person, implicit subject, he/him, one line, curious,
|
||||
// never expository (feedback_twinbee_voice, feedback_twinbee_is_male). Picked
|
||||
// deterministically so a re-rendered digest reads the same.
|
||||
var twinBeeJournalReactions = []string{
|
||||
"📖 Found a torn page in your kit tonight — been reading it by the fire while you sleep. This king of theirs was not a well man.",
|
||||
"📖 Another page. Keep turning them up and I keep piecing him together, and I do not much like the shape.",
|
||||
"📖 Read the new page twice. Whoever wrote it was frightened of something patient. I think we are walking toward it.",
|
||||
"📖 Slipped the day's page into the others. The story's filling in at the edges, and none of the edges are kind.",
|
||||
}
|
||||
|
||||
// twinBeeJournalReaction picks one reaction line deterministically from the day
|
||||
// and the number of pages found, so the digest is stable across re-renders.
|
||||
func twinBeeJournalReaction(day, pagesToday int) string {
|
||||
if len(twinBeeJournalReactions) == 0 || pagesToday <= 0 {
|
||||
return ""
|
||||
}
|
||||
idx := (day + pagesToday) % len(twinBeeJournalReactions)
|
||||
if idx < 0 {
|
||||
idx = -idx
|
||||
}
|
||||
return twinBeeJournalReactions[idx]
|
||||
}
|
||||
|
||||
// handleJournalCmd renders the player's collected campaign pages.
|
||||
func (p *AdventurePlugin) handleJournalCmd(ctx MessageContext) error {
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` to create one first.")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, renderJournal(char.JournalPages))
|
||||
}
|
||||
|
||||
// renderJournal builds the `!adventure journal` view: discovered pages in story
|
||||
// order, with runs of missing pages collapsed to a single "…".
|
||||
func renderJournal(mask int64) string {
|
||||
found := journalPageCount(mask)
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("📖 **The Hollow King** — Journal (%d / %d pages)\n",
|
||||
found, journalTotalPages))
|
||||
|
||||
if found == 0 {
|
||||
b.WriteString("\nYou carry no pages yet. They surface where the realm's fragments gather — in the hands of elites, and behind doors most adventurers walk past.")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
gapOpen := false
|
||||
for i := 1; i <= journalTotalPages; i++ {
|
||||
if journalPageFound(mask, i) {
|
||||
gapOpen = false
|
||||
jp := journalPages[i-1]
|
||||
b.WriteString(fmt.Sprintf("\n**%s. %s**\n%s\n", romanNumeral(i), jp.Title, jp.Text))
|
||||
} else if !gapOpen {
|
||||
gapOpen = true
|
||||
b.WriteString("\n…\n")
|
||||
}
|
||||
}
|
||||
|
||||
if journalComplete(mask) {
|
||||
b.WriteString("\nThe ledger is whole. Clear both Tier-5 zones and you can follow him to the end — `!expedition start epilogue`.")
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("\n_%d pages still scattered._", journalTotalPages-found))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// romanNumeral renders 1..24 as upper-case Roman numerals for the page headers.
|
||||
// The campaign never exceeds a couple dozen pages, so a small table beats a
|
||||
// general algorithm here.
|
||||
func romanNumeral(n int) string {
|
||||
if n < 1 || n > len(romanNumerals) {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
return romanNumerals[n-1]
|
||||
}
|
||||
|
||||
// ── D1c: the finale ─────────────────────────────────────────────────────────
|
||||
|
||||
// finaleTitle is the unique role awarded for the first finale clear.
|
||||
const finaleTitle = "Kingsbane"
|
||||
|
||||
const epilogueEntryFlavor = "You bring the whole ledger to the Empty Throne. The shape kept warm against his return stirs, and stands, and is finally, fully here."
|
||||
|
||||
// hollowKingFinaleMonster is the finale stat block: Belaxath's block — the abyss
|
||||
// boss whose gate "was meant to let one thing come home" — re-dressed as the
|
||||
// King returned in full, with HP bumped for a capstone. A variant stat block
|
||||
// with no new mechanics, per the plan. Built on the fly (not registered in
|
||||
// dndBestiary) because runZoneCombat takes the template directly, exactly as the
|
||||
// arena bosses do.
|
||||
func hollowKingFinaleMonster() DnDMonsterTemplate {
|
||||
m := dndBestiary["boss_belaxath"]
|
||||
m.ID = "boss_hollow_king_finale"
|
||||
m.Name = "The Hollow King, Unhoused"
|
||||
m.HP = int(float64(m.HP) * 1.25)
|
||||
m.Notes = "Finale encounter — the Hollow King returned in full through the abyss gate. Variant of the Balor stat block; no new mechanics."
|
||||
return m
|
||||
}
|
||||
|
||||
// epilogueUnlocked gates the finale on the whole ledger plus both Tier-5 clears.
|
||||
func (p *AdventurePlugin) epilogueUnlocked(char *AdventureCharacter) (bool, string) {
|
||||
if !journalComplete(char.JournalPages) {
|
||||
return false, fmt.Sprintf(
|
||||
"The trail runs cold. You've recovered **%d of %d** journal pages — find them all before you can follow him to the end. (`!adventure journal`)",
|
||||
journalPageCount(char.JournalPages), journalTotalPages)
|
||||
}
|
||||
if !clearedEveryZoneOfTier(db.Get(), char.UserID, ZoneTier(5)) {
|
||||
return false, "You hold the whole ledger, but not the standing to use it. Clear **both** Tier-5 zones — the Dragon's Lair and the Abyss Portal — then come back to the throne."
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// handleEpilogueEncounter runs the finale: a single auto-resolved boss fight
|
||||
// against the Hollow King's true form, reached via `!expedition start epilogue`.
|
||||
// Reward drops only on the first clear (reward-once); later clears are a
|
||||
// flavour-only rematch. A loss costs a death, as any boss fight does.
|
||||
func (p *AdventurePlugin) handleEpilogueEncounter(ctx MessageContext) error {
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` first.")
|
||||
}
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "You're in no shape to face him. Mend at `!hospital` first.")
|
||||
}
|
||||
if ok, why := p.epilogueUnlocked(char); !ok {
|
||||
return p.SendDM(ctx.Sender, why)
|
||||
}
|
||||
// Busy guards — the finale is a synchronous auto-resolved fight, so refuse if
|
||||
// anything else is in flight and could collide on HP or run state.
|
||||
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil {
|
||||
return p.SendDM(ctx.Sender, "Finish your expedition before you walk to the Empty Throne.")
|
||||
}
|
||||
if run, _ := getActiveZoneRun(ctx.Sender); run != nil {
|
||||
return p.SendDM(ctx.Sender, "Finish your current zone run first.")
|
||||
}
|
||||
if sess, _ := getActiveCombatSession(ctx.Sender); sess != nil {
|
||||
return p.SendDM(ctx.Sender, "You're already in a fight. Finish it first.")
|
||||
}
|
||||
|
||||
monster := hollowKingFinaleMonster()
|
||||
displayName, _ := loadDisplayName(ctx.Sender)
|
||||
if displayName == "" {
|
||||
displayName = "You"
|
||||
}
|
||||
|
||||
preHP, _ := dndHPSnapshot(ctx.Sender)
|
||||
result, err := p.runZoneCombat(ctx.Sender, monster, 5, bossCombatPhases, 50)
|
||||
if err != nil {
|
||||
slog.Error("epilogue: combat failed", "user", ctx.Sender, "err", err)
|
||||
return p.SendDM(ctx.Sender, "Something went wrong at the throne. Try again in a moment.")
|
||||
}
|
||||
postHP, maxHP := dndHPSnapshot(ctx.Sender)
|
||||
nat20s, nat1s := countNat20sAnd1s(result)
|
||||
|
||||
intro := fmt.Sprintf("👑 **The Empty Throne — %s** (HP %d, AC %d)\n_%s_",
|
||||
monster.Name, monster.HP, monster.AC, epilogueEntryFlavor)
|
||||
phases := RenderCombatLog(result, displayName, monster.Name)
|
||||
outcome := renderBossOutcome(BossOutcomeInputs{
|
||||
// Synthetic ZoneArena keeps twinBeeLine's deterministic pickers off any
|
||||
// real zone's pool, exactly as the arena does.
|
||||
ZoneID: ZoneArena,
|
||||
RunID: "epilogue-" + string(ctx.Sender),
|
||||
RoomIdx: 0,
|
||||
Monster: monster,
|
||||
Result: result,
|
||||
PreHP: preHP,
|
||||
PostHP: postHP,
|
||||
MaxHP: maxHP,
|
||||
PhaseTwoAt: 0.40,
|
||||
Nat20s: nat20s,
|
||||
Nat1s: nat1s,
|
||||
DefeatHeadline: "💀 The Hollow King folds you into the quiet he keeps. The account goes unpaid a while longer.",
|
||||
VictoryHeadline: fmt.Sprintf("🏆 **The Hollow King falls — and this time stays down.** You finished at **%d/%d HP**.", postHP, maxHP),
|
||||
})
|
||||
|
||||
var tail string
|
||||
if !result.PlayerWon {
|
||||
markAdventureDead(ctx.Sender, "adventure", "the Empty Throne")
|
||||
} else {
|
||||
tail = p.finishEpilogueWin(ctx.Sender, char.EpilogueCleared)
|
||||
}
|
||||
|
||||
_ = p.sendZoneCombatMessages(ctx.Sender, append([]string{intro}, phases...), joinLootLines(outcome, tail))
|
||||
return nil
|
||||
}
|
||||
|
||||
// finishEpilogueWin grants the reward on a first clear and returns the reward
|
||||
// narration; a repeat clear is flavour only. The character is reloaded before
|
||||
// the title write so runZoneCombat's post-fight HP persist isn't clobbered.
|
||||
func (p *AdventurePlugin) finishEpilogueWin(userID id.UserID, alreadyCleared bool) string {
|
||||
if alreadyCleared {
|
||||
return "_The throne stays empty. You came to be sure. You are sure._"
|
||||
}
|
||||
|
||||
// Latch the once-only flag BEFORE handing out the Legendary + title, so a
|
||||
// failed write (or a crash) can never leave the reward repeatable. If the
|
||||
// latch fails, skip the grant entirely — the player re-enters uncleared and
|
||||
// can try again, rather than pocketing a second Legendary on the retry.
|
||||
if err := markEpilogueClearedDB(userID); err != nil {
|
||||
slog.Error("epilogue: mark cleared failed", "user", userID, "err", err)
|
||||
return "_The account won't quite close — the ledger jams. Try `!expedition start epilogue` again._"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("🎖️ **The account is closed.** You are named **" + finaleTitle + "** — the one who unhoused the Hollow King.\n")
|
||||
if mi, ok := pickMagicItemForRarity(RarityLegendary, nil); ok {
|
||||
if line := p.dropMagicItemLoot(userID, mi, LootTierLegendary); line != "" {
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
} else {
|
||||
slog.Error("epilogue: no legendary in registry", "user", userID)
|
||||
}
|
||||
|
||||
if fresh, err := loadAdvCharacter(userID); err == nil && fresh != nil {
|
||||
if fresh.Title != finaleTitle {
|
||||
fresh.Title = finaleTitle
|
||||
if err := saveAdvCharacter(fresh); err != nil {
|
||||
slog.Error("epilogue: title save failed", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if gr := gamesRoom(); gr != "" {
|
||||
if dn, _ := loadDisplayName(userID); dn != "" {
|
||||
p.SendMessage(id.RoomID(gr), fmt.Sprintf(
|
||||
"👑 **%s** carried the whole ledger to the Empty Throne and closed the account. The Hollow King is ended. **%s.**",
|
||||
dn, finaleTitle))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
var romanNumerals = []string{
|
||||
"I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X",
|
||||
"XI", "XII", "XIII", "XIV", "XV", "XVI", "XVII", "XVIII", "XIX", "XX",
|
||||
"XXI", "XXII", "XXIII", "XXIV", "XXV", "XXVI", "XXVII", "XXVIII", "XXIX", "XXX",
|
||||
}
|
||||
300
internal/plugin/adventure_flavor_campaign_test.go
Normal file
300
internal/plugin/adventure_flavor_campaign_test.go
Normal file
@@ -0,0 +1,300 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func TestJournalGrant_DBRoundTripIsAtomicOR(t *testing.T) {
|
||||
townTestDB(t)
|
||||
u := id.UserID("@page:test.invalid")
|
||||
|
||||
// No row yet → zero pages.
|
||||
if mask, err := loadJournalPages(u); err != nil || mask != 0 {
|
||||
t.Fatalf("fresh player: mask=%d err=%v, want 0/nil", mask, err)
|
||||
}
|
||||
|
||||
// First grant creates the row (ON CONFLICT INSERT path).
|
||||
if err := grantJournalPageDB(u, 5); err != nil {
|
||||
t.Fatalf("grant page 5: %v", err)
|
||||
}
|
||||
// Second grant ORs into the existing row without clobbering the first.
|
||||
if err := grantJournalPageDB(u, 2); err != nil {
|
||||
t.Fatalf("grant page 2: %v", err)
|
||||
}
|
||||
// Re-granting a found page is idempotent.
|
||||
if err := grantJournalPageDB(u, 5); err != nil {
|
||||
t.Fatalf("re-grant page 5: %v", err)
|
||||
}
|
||||
|
||||
mask, err := loadJournalPages(u)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if !journalPageFound(mask, 2) || !journalPageFound(mask, 5) {
|
||||
t.Fatalf("pages 2 and 5 should be found, mask=%b", mask)
|
||||
}
|
||||
if journalPageCount(mask) != 2 {
|
||||
t.Fatalf("count=%d, want 2 (mask=%b)", journalPageCount(mask), mask)
|
||||
}
|
||||
|
||||
// Overlay populates the character field the viewer reads.
|
||||
var c AdventureCharacter
|
||||
c.UserID = u
|
||||
applyPlayerMetaOverlay(&c)
|
||||
if c.JournalPages != mask {
|
||||
t.Fatalf("overlay JournalPages=%b, want %b", c.JournalPages, mask)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJournalCatalog_WellFormed(t *testing.T) {
|
||||
if journalTotalPages != len(journalPages) {
|
||||
t.Fatalf("journalTotalPages %d != len(journalPages) %d", journalTotalPages, len(journalPages))
|
||||
}
|
||||
if journalTotalPages < 1 || journalTotalPages > 63 {
|
||||
t.Fatalf("journal must fit an int64 bitmask (1..63); got %d", journalTotalPages)
|
||||
}
|
||||
if journalTotalPages > len(romanNumerals) {
|
||||
t.Fatalf("more pages (%d) than roman numerals (%d)", journalTotalPages, len(romanNumerals))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, jp := range journalPages {
|
||||
if strings.TrimSpace(jp.Title) == "" {
|
||||
t.Errorf("page %d has empty title", i+1)
|
||||
}
|
||||
if strings.TrimSpace(jp.Text) == "" {
|
||||
t.Errorf("page %d (%q) has empty text", i+1, jp.Title)
|
||||
}
|
||||
if seen[jp.Title] {
|
||||
t.Errorf("duplicate page title %q", jp.Title)
|
||||
}
|
||||
seen[jp.Title] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestJournalBitmask_GrantQueryCount(t *testing.T) {
|
||||
var mask int64
|
||||
if journalPageCount(mask) != 0 {
|
||||
t.Fatalf("empty mask should count 0")
|
||||
}
|
||||
if journalPageFound(mask, 1) {
|
||||
t.Fatalf("page 1 should be unfound on empty mask")
|
||||
}
|
||||
mask = setJournalPageBit(mask, 3)
|
||||
mask = setJournalPageBit(mask, 1)
|
||||
if !journalPageFound(mask, 3) || !journalPageFound(mask, 1) {
|
||||
t.Fatalf("pages 1 and 3 should be found")
|
||||
}
|
||||
if journalPageFound(mask, 2) {
|
||||
t.Fatalf("page 2 should still be unfound")
|
||||
}
|
||||
if got := journalPageCount(mask); got != 2 {
|
||||
t.Fatalf("count = %d, want 2", got)
|
||||
}
|
||||
// Re-setting a found page is idempotent.
|
||||
if again := setJournalPageBit(mask, 3); again != mask {
|
||||
t.Fatalf("re-granting page 3 changed the mask")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickUnfoundJournalPage_ExhaustsToZero(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(1, 2))
|
||||
var mask int64
|
||||
found := map[int]bool{}
|
||||
for i := 0; i < journalTotalPages; i++ {
|
||||
page := pickUnfoundJournalPage(mask, rng)
|
||||
if page < 1 || page > journalTotalPages {
|
||||
t.Fatalf("pick %d out of range", page)
|
||||
}
|
||||
if found[page] {
|
||||
t.Fatalf("pick returned already-found page %d", page)
|
||||
}
|
||||
found[page] = true
|
||||
mask = setJournalPageBit(mask, page)
|
||||
}
|
||||
if !journalComplete(mask) {
|
||||
t.Fatalf("mask should be complete after %d picks", journalTotalPages)
|
||||
}
|
||||
if page := pickUnfoundJournalPage(mask, rng); page != 0 {
|
||||
t.Fatalf("pick on complete mask = %d, want 0", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderJournal_EmptyPartialFull(t *testing.T) {
|
||||
// Empty.
|
||||
empty := renderJournal(0)
|
||||
if !strings.Contains(empty, "0 / ") {
|
||||
t.Errorf("empty journal should show 0 found:\n%s", empty)
|
||||
}
|
||||
if strings.Contains(empty, "…") {
|
||||
t.Errorf("empty journal should not render a gap marker:\n%s", empty)
|
||||
}
|
||||
|
||||
// Partial with a gap: pages 1 and 3 found, 2 missing → exactly one "…".
|
||||
var partial int64
|
||||
partial = setJournalPageBit(partial, 1)
|
||||
partial = setJournalPageBit(partial, 3)
|
||||
pv := renderJournal(partial)
|
||||
if !strings.Contains(pv, journalPages[0].Title) || !strings.Contains(pv, journalPages[2].Title) {
|
||||
t.Errorf("partial journal missing a found page title:\n%s", pv)
|
||||
}
|
||||
if strings.Contains(pv, journalPages[1].Title) {
|
||||
t.Errorf("partial journal leaked an unfound page's text:\n%s", pv)
|
||||
}
|
||||
if !strings.Contains(pv, "…") {
|
||||
t.Errorf("partial journal with a gap should render '…':\n%s", pv)
|
||||
}
|
||||
|
||||
// Full: every title present, no gap, completion line.
|
||||
var full int64
|
||||
for i := 1; i <= journalTotalPages; i++ {
|
||||
full = setJournalPageBit(full, i)
|
||||
}
|
||||
fv := renderJournal(full)
|
||||
if strings.Contains(fv, "…") {
|
||||
t.Errorf("complete journal should have no gaps:\n%s", fv)
|
||||
}
|
||||
for _, jp := range journalPages {
|
||||
if !strings.Contains(fv, jp.Title) {
|
||||
t.Errorf("complete journal missing page %q", jp.Title)
|
||||
}
|
||||
}
|
||||
if !journalComplete(full) {
|
||||
t.Fatalf("full mask should be complete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJournalDropChance_Sane(t *testing.T) {
|
||||
if journalPageDropChance <= 0 || journalPageDropChance >= 1 {
|
||||
t.Fatalf("drop chance %v out of (0,1)", journalPageDropChance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBossEpilogues_EveryRegisteredZoneHasOne(t *testing.T) {
|
||||
for _, z := range allZones() {
|
||||
if strings.TrimSpace(bossEpilogueLine(z.ID)) == "" {
|
||||
t.Errorf("zone %s (%s) has no boss epilogue", z.ID, z.Display)
|
||||
}
|
||||
}
|
||||
// The synthetic arena is not a campaign zone — it must have none.
|
||||
if bossEpilogueLine(ZoneArena) != "" {
|
||||
t.Errorf("arena should have no boss epilogue")
|
||||
}
|
||||
if bossEpilogueLine(ZoneID("nonexistent")) != "" {
|
||||
t.Errorf("unknown zone should have no epilogue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHollowKingFinaleMonster_ClonesBelaxathWithBump(t *testing.T) {
|
||||
base := dndBestiary["boss_belaxath"]
|
||||
if base.ID == "" {
|
||||
t.Fatal("boss_belaxath missing from bestiary — finale clones it")
|
||||
}
|
||||
m := hollowKingFinaleMonster()
|
||||
if m.ID != "boss_hollow_king_finale" {
|
||||
t.Errorf("finale ID = %q", m.ID)
|
||||
}
|
||||
if m.Name == base.Name || m.Name == "" {
|
||||
t.Errorf("finale name should differ from Belaxath: %q", m.Name)
|
||||
}
|
||||
if m.HP <= base.HP {
|
||||
t.Errorf("finale HP %d should exceed base %d (capstone bump)", m.HP, base.HP)
|
||||
}
|
||||
// No new mechanics: the ability and defensive profile carry over unchanged.
|
||||
if m.Ability != base.Ability {
|
||||
t.Errorf("finale ability pointer changed — should reuse Belaxath's, no new mechanics")
|
||||
}
|
||||
if m.AC != base.AC || m.CR != base.CR {
|
||||
t.Errorf("finale AC/CR drifted from base: AC %d/%d CR %v/%v", m.AC, base.AC, m.CR, base.CR)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpilogueRewardOnce_FlagRoundTrip(t *testing.T) {
|
||||
townTestDB(t)
|
||||
u := id.UserID("@finale:test.invalid")
|
||||
|
||||
if got, err := loadEpilogueCleared(u); err != nil || got {
|
||||
t.Fatalf("fresh player: cleared=%v err=%v, want false/nil", got, err)
|
||||
}
|
||||
if err := markEpilogueClearedDB(u); err != nil {
|
||||
t.Fatalf("mark cleared: %v", err)
|
||||
}
|
||||
if got, err := loadEpilogueCleared(u); err != nil || !got {
|
||||
t.Fatalf("after mark: cleared=%v err=%v, want true/nil", got, err)
|
||||
}
|
||||
// Idempotent.
|
||||
if err := markEpilogueClearedDB(u); err != nil {
|
||||
t.Fatalf("re-mark: %v", err)
|
||||
}
|
||||
|
||||
var c AdventureCharacter
|
||||
c.UserID = u
|
||||
applyPlayerMetaOverlay(&c)
|
||||
if !c.EpilogueCleared {
|
||||
t.Fatalf("overlay did not carry EpilogueCleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpilogueUnlocked_Gates(t *testing.T) {
|
||||
townTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
|
||||
// Incomplete journal → refused, page-count message.
|
||||
partial := &AdventureCharacter{UserID: id.UserID("@u1:test.invalid")}
|
||||
partial.JournalPages = setJournalPageBit(0, 1)
|
||||
ok, why := p.epilogueUnlocked(partial)
|
||||
if ok {
|
||||
t.Fatalf("incomplete journal should not unlock")
|
||||
}
|
||||
if !strings.Contains(why, "journal pages") {
|
||||
t.Errorf("expected page-count refusal, got: %s", why)
|
||||
}
|
||||
|
||||
// Complete journal but no Tier-5 clears → refused, T5 message.
|
||||
full := &AdventureCharacter{UserID: id.UserID("@u2:test.invalid")}
|
||||
for i := 1; i <= journalTotalPages; i++ {
|
||||
full.JournalPages = setJournalPageBit(full.JournalPages, i)
|
||||
}
|
||||
ok, why = p.epilogueUnlocked(full)
|
||||
if ok {
|
||||
t.Fatalf("no T5 clears should not unlock")
|
||||
}
|
||||
if !strings.Contains(why, "Tier-5") {
|
||||
t.Errorf("expected Tier-5 refusal, got: %s", why)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishEpilogueWin_RepeatIsFlavorOnly(t *testing.T) {
|
||||
// The already-cleared branch is pure flavour: no reward, no client calls.
|
||||
p := &AdventurePlugin{}
|
||||
repeat := p.finishEpilogueWin(id.UserID("@u:test.invalid"), true)
|
||||
if strings.Contains(repeat, finaleTitle) {
|
||||
t.Errorf("a repeat clear must not re-award the title: %s", repeat)
|
||||
}
|
||||
if strings.TrimSpace(repeat) == "" {
|
||||
t.Errorf("a repeat clear should still say something")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwinBeeJournalReaction_DeterministicAndGuarded(t *testing.T) {
|
||||
if twinBeeJournalReaction(3, 0) != "" {
|
||||
t.Errorf("zero pages should produce no reaction")
|
||||
}
|
||||
first := twinBeeJournalReaction(3, 2)
|
||||
if first == "" {
|
||||
t.Fatalf("a found page should produce a reaction")
|
||||
}
|
||||
if again := twinBeeJournalReaction(3, 2); again != first {
|
||||
t.Errorf("reaction not deterministic: %q vs %q", first, again)
|
||||
}
|
||||
// Voice guard: TwinBee never speaks of himself in the third person
|
||||
// (feedback_twinbee_voice). None of the pooled lines may contain "TwinBee".
|
||||
for _, line := range twinBeeJournalReactions {
|
||||
if strings.Contains(line, "TwinBee") {
|
||||
t.Errorf("third-person TwinBee reference in reaction: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,31 @@ var mistyAcceptLines = []string{
|
||||
|
||||
const mistyDeclineLine = "She nods once and walks away. She doesn't look back."
|
||||
|
||||
// ── Misty — Arc Beats (D2) ─────────────────────────────────────────────────
|
||||
//
|
||||
// Three deepening dialogue beats keyed to MistyEncounterCount (5 / 15 / 30),
|
||||
// prepended to the encounter opening the one time the counter lands on a
|
||||
// threshold. Fiction only: nothing here connects a donation to a combat
|
||||
// outcome — the arena effects are a hidden discovery mechanic and stay hidden.
|
||||
var mistyArcBeats = map[int]string{
|
||||
5: "It's you again. I was hoping it would be. I know your face now -- you're one of the few who ever stops. " +
|
||||
"Most people look right through me. You never have.",
|
||||
|
||||
15: "Can I tell you something? I wasn't always out here asking strangers on the road. " +
|
||||
"I had a home once. A garden that got away from me every summer. Someone who used to wait up. " +
|
||||
"It's gone now, all of it. But you keep reminding me there are still kind people in the world. That's not nothing.",
|
||||
|
||||
30: "I don't think of you as a stranger anymore. Is it strange to say that? " +
|
||||
"I catch myself watching the road for you now -- not for the gold, though I won't pretend it doesn't help. " +
|
||||
"Just to see you're still standing. Whatever's out there, I hope it knows better than to take you from me too.",
|
||||
}
|
||||
|
||||
// mistyArcBeat returns the deepening dialogue for the exact encounter count at
|
||||
// which a Misty stage lands, or "" for every other encounter.
|
||||
func mistyArcBeat(encounterCount int) string {
|
||||
return mistyArcBeats[encounterCount]
|
||||
}
|
||||
|
||||
// ── Arina — Opening Lines ──────────────────────────────────────────────────
|
||||
|
||||
var arinaOpenings = []string{
|
||||
|
||||
@@ -40,6 +40,11 @@ var robbieMasterworkAlreadyHas = "Oh. I see you already have one of these. Excel
|
||||
var robbieAllShopGear = "Nothing fancy today but that's alright. Clean inventory is its own reward. " +
|
||||
"Well. The €%d is the reward. The clean inventory is a bonus. Cheerio!"
|
||||
|
||||
// robbieLeftConsumable is appended when Robbie leaves a little something on his
|
||||
// every-10th-visit (D2). Takes the item name.
|
||||
var robbieLeftConsumable = "Oh -- one more thing. I tucked a %s into your bag on the way out. " +
|
||||
"You've had me round enough times now that it felt rude not to. For the trouble, eh? _winks_"
|
||||
|
||||
// ── Room Announcements ───────────────────────────────────────────────────────
|
||||
|
||||
var robbieRoomStandard = "🎩 Robbie paid %s a visit and collected %d item(s) from their inventory. " +
|
||||
|
||||
200
internal/plugin/adventure_gifting.go
Normal file
200
internal/plugin/adventure_gifting.go
Normal file
@@ -0,0 +1,200 @@
|
||||
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))
|
||||
}
|
||||
145
internal/plugin/adventure_gifting_test.go
Normal file
145
internal/plugin/adventure_gifting_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
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,12 +99,16 @@ type advPendingHouseDownPayment struct {
|
||||
|
||||
type advPendingHouseAutopay struct{}
|
||||
|
||||
type advPendingPetArrival 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 advPendingPetType struct{}
|
||||
type advPendingPetType struct{ Slot int }
|
||||
|
||||
type advPendingPetName struct {
|
||||
PetType string
|
||||
Slot int
|
||||
}
|
||||
|
||||
// ── Thom Krooke Greeting ───────────────────────────────────────────────────
|
||||
@@ -142,6 +146,18 @@ var thomFreezePet = "Ten missed payments. I've stopped trying for now. The house
|
||||
var thomPaidOffNoPet = "Paid off. Noted. The next tier is available when you're ready. I'll be here."
|
||||
var thomPaidOffPet = "Paid off. Good. Come in when you're ready for the next tier. I've been thinking about what %s would need in a better space. I have thoughts."
|
||||
|
||||
// thomPetTreatItem is the keepsake Thom leaves for the pet on the final
|
||||
// mortgage payoff. Inert (Type "card" like the medical-debt card): not a
|
||||
// consumable, not gear, and Robbie's sweep skips it.
|
||||
func thomPetTreatItem() AdvItem {
|
||||
return AdvItem{
|
||||
Name: "Krooke's Finest Pet Treat",
|
||||
Type: "card",
|
||||
Tier: 0,
|
||||
Value: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// thomGreeting returns an appropriate greeting based on pet state.
|
||||
func thomGreeting(char *AdventureCharacter) string {
|
||||
if char.HasPet() && char.PetLevel >= 10 {
|
||||
@@ -221,11 +237,19 @@ func thomShopView(char *AdventureCharacter, balance float64) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Pet supply shop (unlocks 1 week after pet level 10)
|
||||
if char.PetSupplyShopUnlocked && char.HasPet() {
|
||||
// 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()) {
|
||||
sb.WriteString("\n---\n")
|
||||
sb.WriteString(fmt.Sprintf("🐾 **Pet Supplies** — for %s\n\n", char.PetName))
|
||||
sb.WriteString(petArmorShopView(char))
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
@@ -269,8 +293,11 @@ 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.handlePetArmorBuy(ctx, char, strings.TrimSpace(args[7:]))
|
||||
return p.petArmorBuyForSlot(ctx, strings.TrimSpace(args[7:]), 1)
|
||||
|
||||
default:
|
||||
return p.SendDM(ctx.Sender, "Unknown command. Type `!thom` to visit Krooke Realty.")
|
||||
@@ -538,11 +565,22 @@ func (p *AdventurePlugin) processMortgagePayments() {
|
||||
freshChar.HouseLoanBalance = 0
|
||||
freshChar.HouseMissedPayments = 0
|
||||
_ = saveAdvCharacter(freshChar)
|
||||
// The last house: no next tier means Thom is done with this
|
||||
// player. A letter, and a treat in the bag if they keep a pet.
|
||||
finalPayoff := houseNextTier(freshChar.HouseTier) == nil
|
||||
if finalPayoff && freshChar.HasPet() {
|
||||
_ = addAdvInventoryItem(freshChar.UserID, thomPetTreatItem())
|
||||
}
|
||||
userMu.Unlock()
|
||||
// Paid off!
|
||||
if freshChar.HasPet() {
|
||||
switch {
|
||||
case finalPayoff && freshChar.HasPet():
|
||||
letter := strings.ReplaceAll(thomFinalLetterPet, "{pet}", freshChar.PetName)
|
||||
_ = p.SendDM(freshChar.UserID, "🏠 "+letter)
|
||||
case finalPayoff:
|
||||
_ = p.SendDM(freshChar.UserID, "🏠 "+thomFinalLetterNoPet)
|
||||
case freshChar.HasPet():
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomPaidOffPet, freshChar.PetName)))
|
||||
} else {
|
||||
default:
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", thomPaidOffNoPet))
|
||||
}
|
||||
continue
|
||||
@@ -673,18 +711,18 @@ func petArmorDefs(petType string) []PetArmorDef {
|
||||
return petCatArmor
|
||||
}
|
||||
|
||||
func petArmorShopView(char *AdventureCharacter) string {
|
||||
func petArmorShopView(petType string, armorTier int, buyCmd string) string {
|
||||
var sb strings.Builder
|
||||
defs := petArmorDefs(char.PetType)
|
||||
defs := petArmorDefs(petType)
|
||||
|
||||
if char.PetArmorTier >= 5 {
|
||||
if armorTier >= 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 <= char.PetArmorTier {
|
||||
if def.Tier == char.PetArmorTier {
|
||||
if def.Tier <= armorTier {
|
||||
if def.Tier == armorTier {
|
||||
sb.WriteString(fmt.Sprintf("🟢 %s (T%d) — Currently equipped\n", def.Name, def.Tier))
|
||||
}
|
||||
continue
|
||||
@@ -692,11 +730,14 @@ func petArmorShopView(char *AdventureCharacter) 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 petbuy <tier>` (e.g., `!thom petbuy %d`)", char.PetArmorTier+1))
|
||||
sb.WriteString(fmt.Sprintf("\nTo buy: `!thom %s <tier>` (e.g., `!thom %s %d`)", buyCmd, buyCmd, armorTier+1))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureCharacter, tierStr string) error {
|
||||
// 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 {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
@@ -706,8 +747,16 @@ func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureC
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
if !char.HasPet() {
|
||||
return p.SendDM(ctx.Sender, "You don't have a pet.")
|
||||
// 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.PetSupplyShopUnlocked {
|
||||
return p.SendDM(ctx.Sender, "Pet supplies aren't available yet.")
|
||||
@@ -716,19 +765,19 @@ func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureC
|
||||
tier := 0
|
||||
for _, c := range tierStr {
|
||||
if c < '0' || c > '9' {
|
||||
return p.SendDM(ctx.Sender, "Usage: `!thom petbuy <tier>`")
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Usage: `!thom %s <tier>`", buyCmd))
|
||||
}
|
||||
tier = tier*10 + int(c-'0')
|
||||
}
|
||||
|
||||
if tier != char.PetArmorTier+1 {
|
||||
if tier <= char.PetArmorTier {
|
||||
if tier != curTier+1 {
|
||||
if tier <= curTier {
|
||||
return p.SendDM(ctx.Sender, "That's not an upgrade.")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You need to buy tier %d first.", char.PetArmorTier+1))
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You need to buy tier %d first.", curTier+1))
|
||||
}
|
||||
|
||||
defs := petArmorDefs(char.PetType)
|
||||
defs := petArmorDefs(petType)
|
||||
var def *PetArmorDef
|
||||
for i := range defs {
|
||||
if defs[i].Tier == tier {
|
||||
@@ -742,18 +791,26 @@ func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureC
|
||||
|
||||
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.", char.PetName, def.Price, balance))
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("%s deserves better, but you can't afford €%d. Balance: €%.0f.", petName, def.Price, balance))
|
||||
}
|
||||
|
||||
if !p.euro.Debit(ctx.Sender, float64(def.Price), "pet_armor_"+tierStr) {
|
||||
reason := "pet_armor_" + tierStr
|
||||
if slot == 2 {
|
||||
reason = "pet2_armor_" + tierStr
|
||||
}
|
||||
if !p.euro.Debit(ctx.Sender, float64(def.Price), reason) {
|
||||
return p.SendDM(ctx.Sender, "Transaction failed.")
|
||||
}
|
||||
|
||||
char.PetArmorTier = tier
|
||||
if slot == 2 {
|
||||
char.Pet2ArmorTier = tier
|
||||
} else {
|
||||
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, char.PetName, def.DeflectBonus*100, char.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, petName, def.DeflectBonus*100, petName))
|
||||
}
|
||||
|
||||
@@ -285,8 +285,7 @@ func TestPetArmorDefs_BothTypes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPetArmorShopView_HasUpgrades(t *testing.T) {
|
||||
char := &AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true, PetArmorTier: 0}
|
||||
text := petArmorShopView(char)
|
||||
text := petArmorShopView("dog", 0, "petbuy")
|
||||
|
||||
if !strings.Contains(text, "⬆️") {
|
||||
t.Error("should show upgrade indicators")
|
||||
@@ -297,8 +296,7 @@ func TestPetArmorShopView_HasUpgrades(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPetArmorShopView_MaxTier(t *testing.T) {
|
||||
char := &AdventureCharacter{PetType: "cat", PetName: "Luna", PetArrived: true, PetArmorTier: 5}
|
||||
text := petArmorShopView(char)
|
||||
text := petArmorShopView("cat", 5, "petbuy")
|
||||
|
||||
if !strings.Contains(text, "Max pet armor") {
|
||||
t.Error("should show max tier message")
|
||||
|
||||
@@ -492,8 +492,33 @@ 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.")
|
||||
|
||||
91
internal/plugin/adventure_npc_arcs_test.go
Normal file
91
internal/plugin/adventure_npc_arcs_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// D2 NPC arcs — pure-helper coverage. None of these touch combat, so the
|
||||
// characterization golden is unaffected; these just pin the arc thresholds,
|
||||
// gift tiers, and the keepsake shape.
|
||||
|
||||
func TestMistyArcBeat_FiresOnlyAtThresholds(t *testing.T) {
|
||||
want := map[int]bool{5: true, 15: true, 30: true}
|
||||
for count := 0; count <= 40; count++ {
|
||||
got := mistyArcBeat(count)
|
||||
if want[count] {
|
||||
if got == "" {
|
||||
t.Errorf("encounter %d: expected an arc beat, got none", count)
|
||||
}
|
||||
} else if got != "" {
|
||||
t.Errorf("encounter %d: expected no arc beat, got %q", count, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMistyArcBeats_DoNotLeakBuffMechanics(t *testing.T) {
|
||||
// The arena effects are a hidden discovery mechanic; arc copy must never
|
||||
// tie a donation to a combat outcome.
|
||||
banned := []string{"arena", "damage", "buff", "combat", "attack", "heal", "bonus"}
|
||||
for count, beat := range mistyArcBeats {
|
||||
lower := strings.ToLower(beat)
|
||||
for _, b := range banned {
|
||||
if strings.Contains(lower, b) {
|
||||
t.Errorf("beat %d leaks mechanic word %q: %s", count, b, beat)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRobbieGiftTier_MatchesArenaBands(t *testing.T) {
|
||||
cases := []struct {
|
||||
level int
|
||||
tier int
|
||||
}{
|
||||
{1, 1}, {3, 1}, {4, 2}, {7, 2}, {8, 3}, {12, 3},
|
||||
{13, 4}, {17, 4}, {18, 5}, {20, 5},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := robbieGiftTier(c.level); got != c.tier {
|
||||
t.Errorf("robbieGiftTier(%d) = %d, want %d", c.level, got, c.tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRobbieGiftCadence(t *testing.T) {
|
||||
// The gift lands on every 10th visit and no other.
|
||||
for visit := 1; visit <= 35; visit++ {
|
||||
gives := visit%robbieGiftEveryNVisits == 0
|
||||
if gives != (visit == 10 || visit == 20 || visit == 30) {
|
||||
t.Errorf("visit %d: unexpected gift cadence", visit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomPetTreat_IsRobbieSafeKeepsake(t *testing.T) {
|
||||
treat := thomPetTreatItem()
|
||||
if treat.Name == "" {
|
||||
t.Fatal("treat has no name")
|
||||
}
|
||||
// Robbie's sweep skips "card" items, so the keepsake survives him.
|
||||
if treat.Type != "card" {
|
||||
t.Errorf("treat type = %q, want card so Robbie's sweep skips it", treat.Type)
|
||||
}
|
||||
if treat.Value != 0 {
|
||||
t.Errorf("treat value = %d, want 0 (inert keepsake)", treat.Value)
|
||||
}
|
||||
// Confirm the sweep filter actually excludes it.
|
||||
if got := robbieQualifyingItems([]AdvItem{treat}, nil); len(got) != 0 {
|
||||
t.Errorf("Robbie would take the treat: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomFinalLetterPet_SubstitutesPetName(t *testing.T) {
|
||||
letter := strings.ReplaceAll(thomFinalLetterPet, "{pet}", "Biscuit")
|
||||
if strings.Contains(letter, "{pet}") {
|
||||
t.Error("unsubstituted {pet} placeholder remains")
|
||||
}
|
||||
if !strings.Contains(letter, "Biscuit") {
|
||||
t.Error("pet name not substituted into the letter")
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,15 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't consume the encounter — or its one-shot arc beat — if the player is
|
||||
// already mid-interaction (shop, treasure, another NPC). Bail before
|
||||
// touching MistyEncounterCount so a contended slot defers the whole
|
||||
// encounter to a later fire instead of durably advancing the counter past
|
||||
// a 5/15/30 threshold and losing that beat forever.
|
||||
if _, occupied := p.pending.Load(string(userID)); occupied {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
var opening, prompt string
|
||||
@@ -141,6 +150,12 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) {
|
||||
// Pool union: legacy mistyOpenings + D&D MistyGreeting flavor.
|
||||
mistyPool := dndMistyGreetingPool()
|
||||
opening = mistyPool[rand.IntN(len(mistyPool))]
|
||||
// D2 arc: a deepening beat lands the one time the counter hits a
|
||||
// threshold (5/15/30). MistyEncounterCount only ever increments by one
|
||||
// above, so each beat is delivered exactly once.
|
||||
if beat := mistyArcBeat(char.MistyEncounterCount); beat != "" {
|
||||
opening = beat + "\n\n" + opening
|
||||
}
|
||||
prompt = fmt.Sprintf("👤 A woman approaches you.\n\n_%s_\n\n"+
|
||||
"Reply `yes` to give €%d, or `no` to walk away.", opening, mistyCost)
|
||||
case "arina":
|
||||
@@ -159,11 +174,6 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) {
|
||||
slog.Error("player_meta: npc last_seen dual-write failed", "user", userID, "npc", npc, "err", err)
|
||||
}
|
||||
|
||||
// Don't overwrite an existing pending interaction (shop, treasure, etc.)
|
||||
if _, occupied := p.pending.Load(string(userID)); occupied {
|
||||
return
|
||||
}
|
||||
|
||||
// Set pending interaction — NPC encounters stay valid until end of UTC day
|
||||
endOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Add(24 * time.Hour)
|
||||
p.pending.Store(string(userID), &advPendingInteraction{
|
||||
|
||||
101
internal/plugin/adventure_omen.go
Normal file
101
internal/plugin/adventure_omen.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// N7/B3 — the Omen: one rotating world modifier per ISO week
|
||||
// (gogobee_engagement_plan.md §B3).
|
||||
//
|
||||
// The active omen is a pure function of the ISO (year, week): omenTable indexed
|
||||
// by (year*53 + week) % len, so it advances by exactly one entry each week and
|
||||
// needs no schema, no ticker state, and no persistence. Every seam reads
|
||||
// activeOmen() the same way isHolidayToday() is read, and a zero-valued effect
|
||||
// field means "this omen doesn't touch that seam."
|
||||
//
|
||||
// Launch-set rule (§B3): every omen is a buff-with-texture on a NON-combat
|
||||
// lever — harvest, supplies, expedition mood/threat, arena payout, ingredient
|
||||
// drops. Nothing touches SimulateCombat or the turn engine: the omen is keyed
|
||||
// on the real clock, so a combat-affecting omen would make the combat golden
|
||||
// and the balance corpus week-dependent. The plan's "elites +2 ATK" mutator is
|
||||
// deliberately omitted for exactly that reason.
|
||||
|
||||
type omen struct {
|
||||
Key string // stable id (tests, logs)
|
||||
Name string // player-facing name, e.g. "Bountiful Harvest"
|
||||
TwinBee string // first-person announce blurb (TwinBee voice)
|
||||
|
||||
// Effect fields — zero == no effect at that seam.
|
||||
HarvestYieldBonus int // + units per harvest grant
|
||||
SupplyFreebiePacks int // + complimentary standard packs at outfitting
|
||||
StartMoodBonus int // + starting DM mood on a new expedition
|
||||
ArenaPayoutMult float64 // >1 scales arena net earnings
|
||||
ConsumableChanceMult float64 // >1 scales the per-win ingredient drop chance
|
||||
ThreatDriftReduce int // subtract from the daily threat *rise* (floored at hold-steady)
|
||||
}
|
||||
|
||||
// simOmenDisabled neutralizes the weekly Omen for the balance sim, so a corpus
|
||||
// sweep's results never depend on which wall-clock week it was run in. Set true
|
||||
// by NewSimRunner (mirrors simAutoArmEnabled). Every seam reads activeOmen(),
|
||||
// which returns the no-effect omen while this is set.
|
||||
var simOmenDisabled bool
|
||||
|
||||
// omenTable is the weekly rotation. Keep it a set of distinct non-combat levers;
|
||||
// order is the rotation order. Adding an entry reshuffles the schedule but never
|
||||
// breaks determinism (still a pure function of the week).
|
||||
var omenTable = []omen{
|
||||
{
|
||||
Key: "bountiful_harvest", Name: "Bountiful Harvest",
|
||||
TwinBee: "The land's feeling generous this week — every gather I make comes up with a little extra in hand.",
|
||||
HarvestYieldBonus: 1,
|
||||
},
|
||||
{
|
||||
Key: "quartermasters_blessing", Name: "Quartermaster's Blessing",
|
||||
TwinBee: "Somebody left the storerooms unlocked. Outfitting an expedition this week? There's a free pack in it, and I'm setting out in good spirits.",
|
||||
SupplyFreebiePacks: 1,
|
||||
StartMoodBonus: 5,
|
||||
},
|
||||
{
|
||||
Key: "golden_purse", Name: "Golden Purse",
|
||||
TwinBee: "The arena crowd's flush this week — purses are paying out fat. Twenty percent over the odds, if you can win it.",
|
||||
ArenaPayoutMult: 1.20,
|
||||
},
|
||||
{
|
||||
Key: "overflowing_satchels", Name: "Overflowing Satchels",
|
||||
TwinBee: "Reagents are turning up everywhere I look — twice as often as usual. Good week to stock the crafting shelf.",
|
||||
ConsumableChanceMult: 2.0,
|
||||
},
|
||||
{
|
||||
Key: "still_waters", Name: "Still Waters",
|
||||
TwinBee: "It's quiet out there. Whatever's hunting us is slow to rouse this week — the daily dread holds steady instead of creeping up.",
|
||||
ThreatDriftReduce: 1,
|
||||
},
|
||||
}
|
||||
|
||||
// omenForWeek returns the omen for an ISO (year, week). Pure and total.
|
||||
func omenForWeek(year, week int) omen {
|
||||
idx := ((year*53)+week)%len(omenTable) + len(omenTable)
|
||||
return omenTable[idx%len(omenTable)]
|
||||
}
|
||||
|
||||
// activeOmen returns this week's omen (UTC ISO week), or a no-effect omen when
|
||||
// the balance sim has disabled it.
|
||||
func activeOmen() omen {
|
||||
if simOmenDisabled {
|
||||
return omen{Key: "none", Name: "None"}
|
||||
}
|
||||
// N7/E4 — a live season's themed omen overrides the weekly rotation. Kept
|
||||
// behind the simOmenDisabled guard above so the balance sim never sees it.
|
||||
if s, ok := activeSeason(); ok {
|
||||
return s.Omen
|
||||
}
|
||||
y, w := time.Now().UTC().ISOWeek()
|
||||
return omenForWeek(y, w)
|
||||
}
|
||||
|
||||
// omenMorningLine is the compact one-liner surfaced in the morning DM (the §B3
|
||||
// announce seam). Empty is never returned — an omen is always active — but the
|
||||
// caller may still choose when to show it.
|
||||
func omenMorningLine(o omen) string {
|
||||
return "🔮 **The Omen — " + o.Name + ".** _" + o.TwinBee + "_"
|
||||
}
|
||||
86
internal/plugin/adventure_omen_test.go
Normal file
86
internal/plugin/adventure_omen_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestOmenForWeek_Deterministic — the same ISO week always yields the same omen.
|
||||
func TestOmenForWeek_Deterministic(t *testing.T) {
|
||||
a := omenForWeek(2026, 28)
|
||||
b := omenForWeek(2026, 28)
|
||||
if a.Key != b.Key {
|
||||
t.Fatalf("omenForWeek not deterministic: %q vs %q", a.Key, b.Key)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmenForWeek_AdvancesEachWeek — consecutive weeks step by exactly one table
|
||||
// entry, so the schedule rotates rather than sticking or skipping.
|
||||
func TestOmenForWeek_AdvancesEachWeek(t *testing.T) {
|
||||
n := len(omenTable)
|
||||
for w := 1; w <= n; w++ {
|
||||
cur := omenForWeek(2026, w)
|
||||
next := omenForWeek(2026, w+1)
|
||||
wantNext := omenTable[((2026*53)+w+1)%n]
|
||||
if next.Key != wantNext.Key {
|
||||
t.Errorf("week %d→%d: got %q, want %q", w, w+1, next.Key, wantNext.Key)
|
||||
}
|
||||
if cur.Key == next.Key {
|
||||
t.Errorf("week %d and %d produced the same omen %q (should advance)", w, w+1, cur.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmenForWeek_TotalOverYearBoundary — never panics across week/year edges,
|
||||
// including ISO week 53.
|
||||
func TestOmenForWeek_TotalOverYearBoundary(t *testing.T) {
|
||||
for y := 2020; y <= 2030; y++ {
|
||||
for w := 1; w <= 53; w++ {
|
||||
o := omenForWeek(y, w)
|
||||
if o.Key == "" {
|
||||
t.Fatalf("omenForWeek(%d,%d) returned zero omen", y, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmenTable_NonCombatOnly — every omen carries at least one effect and the
|
||||
// struct has no combat lever, so the launch set can never move the golden or the
|
||||
// balance corpus. §B3.
|
||||
func TestOmenTable_NonCombatOnly(t *testing.T) {
|
||||
for _, o := range omenTable {
|
||||
hasEffect := o.HarvestYieldBonus > 0 || o.SupplyFreebiePacks > 0 ||
|
||||
o.StartMoodBonus > 0 || o.ArenaPayoutMult > 1.0 ||
|
||||
o.ConsumableChanceMult > 1.0 || o.ThreatDriftReduce > 0
|
||||
if !hasEffect {
|
||||
t.Errorf("omen %q has no active effect", o.Key)
|
||||
}
|
||||
if o.Name == "" || o.TwinBee == "" {
|
||||
t.Errorf("omen %q missing player-facing copy", o.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestActiveOmen_SimDisabled — the balance sim neutralizes the omen so corpus
|
||||
// results are wall-clock-independent. §B3.
|
||||
func TestActiveOmen_SimDisabled(t *testing.T) {
|
||||
defer func() { simOmenDisabled = false }()
|
||||
simOmenDisabled = true
|
||||
o := activeOmen()
|
||||
if o.Key != "none" {
|
||||
t.Errorf("sim-disabled omen = %q, want none", o.Key)
|
||||
}
|
||||
if o.HarvestYieldBonus != 0 || o.SupplyFreebiePacks != 0 || o.StartMoodBonus != 0 ||
|
||||
o.ArenaPayoutMult != 0 || o.ConsumableChanceMult != 0 || o.ThreatDriftReduce != 0 {
|
||||
t.Errorf("sim-disabled omen must have no effect, got %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOmenKeysUnique — no duplicate keys (a dup would make the rotation land on
|
||||
// the same omen two of every len(omenTable) weeks).
|
||||
func TestOmenKeysUnique(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, o := range omenTable {
|
||||
if seen[o.Key] {
|
||||
t.Errorf("duplicate omen key %q", o.Key)
|
||||
}
|
||||
seen[o.Key] = true
|
||||
}
|
||||
}
|
||||
180
internal/plugin/adventure_pet2_test.go
Normal file
180
internal/plugin/adventure_pet2_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
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,28 +31,37 @@ func petXPToNextLevel(level int) int {
|
||||
}
|
||||
}
|
||||
|
||||
// petGrantXP adds XP to the pet and handles level-ups. Returns true if leveled up.
|
||||
// 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.
|
||||
func petGrantXP(pet *PetState) bool {
|
||||
if !pet.HasPet() || pet.Level >= 10 {
|
||||
if !pet.HasPet() {
|
||||
return false
|
||||
}
|
||||
return advancePetLevelsFromXP(&pet.XP, &pet.Level, &pet.Level10Date, int(petXPPerAction*100))
|
||||
}
|
||||
|
||||
pet.XP += int(petXPPerAction * 100) // store as centixp for precision
|
||||
// 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
|
||||
leveled := false
|
||||
for pet.Level < 10 {
|
||||
needed := petXPToNextLevel(pet.Level) * 100
|
||||
if pet.XP < needed {
|
||||
for *level < 10 {
|
||||
needed := petXPToNextLevel(*level) * 100
|
||||
if *xp < needed {
|
||||
break
|
||||
}
|
||||
pet.XP -= needed
|
||||
pet.Level++
|
||||
*xp -= needed
|
||||
*level++
|
||||
leveled = true
|
||||
}
|
||||
|
||||
if pet.Level >= 10 && pet.Level10Date == "" {
|
||||
pet.Level10Date = time.Now().UTC().Format("2006-01-02")
|
||||
if *level >= 10 && *level10Date == "" {
|
||||
*level10Date = time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
return leveled
|
||||
}
|
||||
|
||||
@@ -206,6 +215,23 @@ 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
|
||||
@@ -221,32 +247,52 @@ func (p *AdventurePlugin) maybeRollPetArrivalOnEmerge(userID id.UserID) {
|
||||
pet, _ := loadPetState(userID)
|
||||
house, _ := loadHouseState(userID)
|
||||
if petShouldArrive(pet, house) {
|
||||
p.petArrivalDM(userID)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// petArrivalDM sends the initial "there's an animal in your house" DM.
|
||||
func (p *AdventurePlugin) petArrivalDM(userID id.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) {
|
||||
// Don't overwrite an existing pending interaction
|
||||
if _, exists := p.pending.Load(string(userID)); exists {
|
||||
return
|
||||
}
|
||||
|
||||
text := "There's an animal in your house. It looks like a...\n\n" +
|
||||
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" +
|
||||
"🚪 Chase it away\n" +
|
||||
"🍖 Feed it\n\n" +
|
||||
"Reply with `chase` or `feed`."
|
||||
|
||||
p.pending.Store(string(userID), &advPendingInteraction{
|
||||
Type: "pet_arrival",
|
||||
Data: &advPendingPetArrival{},
|
||||
Data: &advPendingPetArrival{Slot: slot},
|
||||
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) error {
|
||||
func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
@@ -256,13 +302,18 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
|
||||
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 == "🚪" {
|
||||
char.PetChasedAway = true
|
||||
char.PetReactivated = false
|
||||
if slot == 2 {
|
||||
char.Pet2ChasedAway = true
|
||||
char.Pet2Reactivated = false
|
||||
} else {
|
||||
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.")
|
||||
}
|
||||
|
||||
@@ -276,7 +327,7 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
|
||||
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_type",
|
||||
Data: &advPendingPetType{},
|
||||
Data: &advPendingPetType{Slot: slot},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
@@ -285,18 +336,19 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
|
||||
// Invalid response — re-prompt
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_arrival",
|
||||
Data: &advPendingPetArrival{},
|
||||
Data: &advPendingPetArrival{Slot: slot},
|
||||
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) error {
|
||||
func (p *AdventurePlugin) resolvePetType(ctx MessageContext, interaction *advPendingInteraction) 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 := ""
|
||||
@@ -309,7 +361,7 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error {
|
||||
if petType == "" {
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_type",
|
||||
Data: &advPendingPetType{},
|
||||
Data: &advPendingPetType{Slot: slot},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, "Reply with `dog` or `cat`.")
|
||||
@@ -317,7 +369,7 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error {
|
||||
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_name",
|
||||
Data: &advPendingPetName{PetType: petType},
|
||||
Data: &advPendingPetName{PetType: petType, Slot: slot},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||
})
|
||||
|
||||
@@ -355,24 +407,36 @@ 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.")
|
||||
}
|
||||
|
||||
char.PetType = data.PetType
|
||||
char.PetName = name
|
||||
char.PetArrived = true
|
||||
char.PetChasedAway = false
|
||||
char.PetLevel = 1
|
||||
char.PetXP = 0
|
||||
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
|
||||
}
|
||||
|
||||
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 = "🐱"
|
||||
}
|
||||
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))
|
||||
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))
|
||||
}
|
||||
|
||||
// ── Morning Pet Events ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -312,6 +312,20 @@ func renderAdvMorningDM(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment,
|
||||
sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, today's adventures come with TwinBee's blessing — new zone & expedition runs start at +5 mood, expedition outfitting includes a complimentary standard pack, and every harvest yields one extra unit.\n\n", holidayName, holidayName))
|
||||
}
|
||||
|
||||
// N7/B3 the Omen — this week's world modifier, in TwinBee's voice. Rides the
|
||||
// existing morning DM (no net-new scheduled message); persistent one-liner so
|
||||
// a mid-week arrival still learns the active omen.
|
||||
sb.WriteString(omenMorningLine(activeOmen()))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// N7/E4 — a live season adds a "what's live this week" banner under the Omen
|
||||
// line (its themed omen already rode the line above). Same morning DM, no
|
||||
// net-new scheduled message.
|
||||
if s, ok := activeSeason(); ok {
|
||||
sb.WriteString(seasonBannerLine(s))
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Pick a morning greeting
|
||||
greeting, _ := advPickFlavor(MorningDM, userID, "morning_dm")
|
||||
displayName, _ := loadDisplayName(userID)
|
||||
@@ -974,6 +988,7 @@ type AdvLeaderboardEntry struct {
|
||||
ForagingSkill int
|
||||
FishingSkill int
|
||||
CurrentStreak int
|
||||
Renown int // N7/B2 — prestige level, cosmetic marker only
|
||||
}
|
||||
|
||||
func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
@@ -987,16 +1002,21 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
Score int
|
||||
Levels string
|
||||
Streak int
|
||||
Renown int
|
||||
}
|
||||
var entries []entry
|
||||
for _, c := range chars {
|
||||
// Renown adds to the ranking score so a capped, prestigious player still
|
||||
// climbs — it's the only progression they have left past L20.
|
||||
score := (c.Level + c.MiningSkill + c.ForagingSkill + c.FishingSkill) * 10
|
||||
score += c.Renown * 10
|
||||
name, _ := loadDisplayName(c.UserID)
|
||||
entries = append(entries, entry{
|
||||
Name: name,
|
||||
Score: score,
|
||||
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d 🎣%d", c.Level, c.MiningSkill, c.ForagingSkill, c.FishingSkill),
|
||||
Streak: c.CurrentStreak,
|
||||
Renown: c.Renown,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1028,7 +1048,11 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
if e.Streak >= 7 {
|
||||
streak = fmt.Sprintf(" 🔥%d", e.Streak)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s **%s** — %s (score: %d%s)\n", prefix, e.Name, e.Levels, e.Score, streak))
|
||||
renownBadge := ""
|
||||
if m := renownMarker(e.Renown); m != "" {
|
||||
renownBadge = " " + m
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s **%s**%s — %s (score: %d%s)\n", prefix, e.Name, renownBadge, e.Levels, e.Score, streak))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
@@ -1037,8 +1061,17 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
// ── Treasure Discard Prompt ──────────────────────────────────────────────────
|
||||
|
||||
func renderAdvTreasureDiscardPrompt(newTreasure *AdvTreasureDef, existing []AdvTreasureDef) string {
|
||||
if len(TreasureInventoryCap) == 0 {
|
||||
return "You found a treasure but your inventory is full. Reply 1, 2, or 3 to discard, or `keep`."
|
||||
// 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()
|
||||
}
|
||||
|
||||
// Build substitution map with existing treasure info
|
||||
|
||||
249
internal/plugin/adventure_renown.go
Normal file
249
internal/plugin/adventure_renown.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// N7/B2 — Renown: prestige past the level cap (gogobee_engagement_plan.md §B2).
|
||||
//
|
||||
// A confirmed D&D character caps at L20 (dndMaxLevel). Before N7, grantDnDXP
|
||||
// silently dropped any XP earned past the cap. Renown reclaims that overflow as
|
||||
// a prestige track: every renownXPPerLevel of overflow is one Renown level.
|
||||
//
|
||||
// Storage is a single cumulative column, player_meta.renown_xp, written by an
|
||||
// atomic INSERT…ON CONFLICT … += (the journal_pages pattern) so there is no
|
||||
// read-modify-write race. renown_level is DERIVED (renownLevelFor) rather than
|
||||
// stored, so nothing can disagree with the XP total.
|
||||
//
|
||||
// The reward is deliberately prestige-only — a derived rank title, a cosmetic
|
||||
// marker on the sheet/leaderboard, and a small capped bundle of *activity*
|
||||
// bonuses (loot quality, XP, death avoidance). It NEVER grants combat stats:
|
||||
// the balance corpus (SimulateCombat / the golden) must stay valid, so renown
|
||||
// touches only the AdvBonusSummary activity levers and only at the activity
|
||||
// call sites, never loadCombatBonuses.
|
||||
|
||||
// renownXPPerLevel is the overflow XP that buys one Renown level. Steep by
|
||||
// design (§B2): a capped L20 player earns ~750 XP per T5 dungeon win, so a
|
||||
// Renown level is dozens of endgame clears.
|
||||
const renownXPPerLevel = 25000
|
||||
|
||||
// Renown perk ladder. One small step every renownPerkStepLevels Renown levels,
|
||||
// capped at renownPerkMaxSteps steps. At the cap the perks total +20% XP / +15%
|
||||
// loot, matching a streak-30 grant's economic half — §B2's ceiling in total
|
||||
// power.
|
||||
//
|
||||
// The perks are deliberately ONLY the two combat-neutral levers of the
|
||||
// AdvBonusSummary: combat_stats.go maps DeathModifier→Defense,
|
||||
// SuccessBonus→Attack and ExceptionalBonus→CritRate, so those *are* combat
|
||||
// stats and are off-limits (§B2: never combat-stat inflation, the balance
|
||||
// corpus must stay valid). LootQuality and XPMultiplier are read only by the
|
||||
// loot/XP economy, never by combat stat derivation — so renown can pay them out
|
||||
// even through loadCombatBonuses without moving the golden. That is why §B2's
|
||||
// suggested "−death penalty" perk is intentionally NOT granted: it would inflate
|
||||
// Defense.
|
||||
const (
|
||||
renownPerkStepLevels = 3
|
||||
renownPerkMaxSteps = 10
|
||||
)
|
||||
|
||||
// renownLevelFor derives the Renown level from cumulative overflow XP.
|
||||
func renownLevelFor(renownXP int) int {
|
||||
if renownXP <= 0 {
|
||||
return 0
|
||||
}
|
||||
return renownXP / renownXPPerLevel
|
||||
}
|
||||
|
||||
// renownXPIntoLevel returns (progress, cost) toward the next Renown level, for
|
||||
// display. cost is always renownXPPerLevel.
|
||||
func renownXPIntoLevel(renownXP int) (int, int) {
|
||||
if renownXP < 0 {
|
||||
renownXP = 0
|
||||
}
|
||||
return renownXP % renownXPPerLevel, renownXPPerLevel
|
||||
}
|
||||
|
||||
// renownRank is one rung of the derived title ladder: the first Renown level at
|
||||
// which the rank applies, and its name. A rank promotion (crossing into a new
|
||||
// rung) is what gets announced in the games room; plain level-ups only DM.
|
||||
type renownRank struct {
|
||||
MinLevel int
|
||||
Name string
|
||||
}
|
||||
|
||||
var renownRanks = []renownRank{
|
||||
{1, "Renowned"},
|
||||
{3, "Storied"},
|
||||
{5, "Illustrious"},
|
||||
{10, "Fabled"},
|
||||
{15, "Mythic"},
|
||||
{20, "Ascendant"},
|
||||
{30, "Eternal"},
|
||||
}
|
||||
|
||||
// renownRankFor returns the rank name for a Renown level, or "" below level 1.
|
||||
func renownRankFor(level int) string {
|
||||
name := ""
|
||||
for _, r := range renownRanks {
|
||||
if level >= r.MinLevel {
|
||||
name = r.Name
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// renownMarker is the cosmetic prestige badge shown next to a name on the sheet
|
||||
// and leaderboard. Empty below Renown 1.
|
||||
func renownMarker(level int) string {
|
||||
if level < 1 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("✦%d", level)
|
||||
}
|
||||
|
||||
// applyRenownBonuses folds the capped renown perks into an AdvBonusSummary. It
|
||||
// touches ONLY LootQuality and XPMultiplier — the combat-neutral economy levers
|
||||
// — so it is safe to call anywhere the summary is built, including
|
||||
// loadCombatBonuses, without changing any combat stat (see the const block).
|
||||
func applyRenownBonuses(b *AdvBonusSummary, renownLevel int) {
|
||||
if b == nil || renownLevel < renownPerkStepLevels {
|
||||
return
|
||||
}
|
||||
steps := renownLevel / renownPerkStepLevels
|
||||
if steps > renownPerkMaxSteps {
|
||||
steps = renownPerkMaxSteps
|
||||
}
|
||||
b.XPMultiplier += float64(steps) * 2 // → +20% at the cap
|
||||
b.LootQuality += float64(steps) * 1.5 // → +15% at the cap
|
||||
}
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────────
|
||||
|
||||
// renownAccrueSQL is the atomic cumulative += on player_meta.renown_xp (the
|
||||
// journal_pages pattern), returning the post-update total. Shared by the
|
||||
// standalone and transactional accrual paths so the SQL lives in one place.
|
||||
const renownAccrueSQL = `INSERT INTO player_meta (user_id, renown_xp) VALUES (?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET renown_xp = renown_xp + excluded.renown_xp
|
||||
RETURNING renown_xp`
|
||||
|
||||
// sqlQueryer is the subset of *sql.DB / *sql.Tx that accrueRenownXP needs, so
|
||||
// the accrual can run standalone or inside grantDnDXP's save transaction.
|
||||
type sqlQueryer interface {
|
||||
QueryRow(query string, args ...any) *sql.Row
|
||||
}
|
||||
|
||||
// accrueRenownXP adds delta (> 0) to renown_xp against any queryer and returns
|
||||
// the cumulative totals before and after. A single statement, safe against
|
||||
// concurrent grants — no lost update.
|
||||
func accrueRenownXP(q sqlQueryer, userID id.UserID, delta int) (before, after int, err error) {
|
||||
if err = q.QueryRow(renownAccrueSQL, string(userID), delta).Scan(&after); err != nil {
|
||||
return 0, 0, fmt.Errorf("accrue renown_xp: %w", err)
|
||||
}
|
||||
return after - delta, after, nil
|
||||
}
|
||||
|
||||
// addRenownXP is the standalone accrual (its own DB write). delta <= 0 is a
|
||||
// no-op read. grantDnDXP uses the transactional saveDnDCharacterWithOverflow
|
||||
// instead, so this is the API for callers not already saving a character.
|
||||
func addRenownXP(userID id.UserID, delta int) (before, after int, err error) {
|
||||
if delta <= 0 {
|
||||
cur, e := loadRenownXP(userID)
|
||||
return cur, cur, e
|
||||
}
|
||||
return accrueRenownXP(db.Get(), userID, delta)
|
||||
}
|
||||
|
||||
// saveDnDCharacterWithOverflow persists the character and converts overflow XP
|
||||
// (> 0) to Renown in one transaction, so a crash can neither drop the overflow
|
||||
// nor double-credit it. Returns the cumulative renown totals before/after.
|
||||
func saveDnDCharacterWithOverflow(c *DnDCharacter, overflow int) (before, after int, err error) {
|
||||
tx, err := db.Get().Begin()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = saveDnDCharacterExec(tx, c); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if before, after, err = accrueRenownXP(tx, c.UserID, overflow); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return before, after, nil
|
||||
}
|
||||
|
||||
// loadRenownXP reads the cumulative overflow XP. Absent row == 0.
|
||||
func loadRenownXP(userID id.UserID) (int, error) {
|
||||
var xp int
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT renown_xp FROM player_meta WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&xp)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
return xp, err
|
||||
}
|
||||
|
||||
// renownLevelForUser is the per-user Renown level, for callers (leaderboard)
|
||||
// that don't already hold the overlay. Errors resolve to 0.
|
||||
func renownLevelForUser(userID id.UserID) int {
|
||||
xp, err := loadRenownXP(userID)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return renownLevelFor(xp)
|
||||
}
|
||||
|
||||
// ── Announce ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// announceRenown notifies the player of Renown level-ups (DM) and, when the
|
||||
// gain crossed into a new rank rung, the games room. Event-driven off a grant,
|
||||
// not a scheduled recap. from/to are Renown levels before/after the grant.
|
||||
func (p *AdventurePlugin) announceRenown(userID id.UserID, from, to int) {
|
||||
if p == nil || to <= from {
|
||||
return
|
||||
}
|
||||
gained := to - from
|
||||
rank := renownRankFor(to)
|
||||
if p.Client != nil {
|
||||
msg := fmt.Sprintf("✦ **Renown %d** ✦\n\nYou've earned %s past the level cap — your legend grows.",
|
||||
to, pluralLevels(gained))
|
||||
if rank != "" {
|
||||
msg += fmt.Sprintf("\nRank: **%s**.", rank)
|
||||
}
|
||||
if err := p.SendDM(userID, msg); err != nil {
|
||||
slog.Error("renown: level-up DM failed", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
// Games-room shout only on a rank promotion, to keep the room quiet.
|
||||
if renownRankFor(from) == rank {
|
||||
return
|
||||
}
|
||||
gr := gamesRoom()
|
||||
if gr == "" {
|
||||
return
|
||||
}
|
||||
name, _ := loadDisplayName(userID)
|
||||
if name == "" {
|
||||
name = string(userID)
|
||||
}
|
||||
p.SendMessage(id.RoomID(gr), fmt.Sprintf(
|
||||
"✦ **%s** reached **Renown %d — %s.** A legend of the realm.", name, to, rank))
|
||||
}
|
||||
|
||||
func pluralLevels(n int) string {
|
||||
if n == 1 {
|
||||
return "a Renown level"
|
||||
}
|
||||
return fmt.Sprintf("%d Renown levels", n)
|
||||
}
|
||||
238
internal/plugin/adventure_renown_test.go
Normal file
238
internal/plugin/adventure_renown_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func newRenownTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
func TestRenownLevelFor(t *testing.T) {
|
||||
cases := []struct{ xp, want int }{
|
||||
{0, 0},
|
||||
{-100, 0},
|
||||
{renownXPPerLevel - 1, 0},
|
||||
{renownXPPerLevel, 1},
|
||||
{renownXPPerLevel*3 + 12, 3},
|
||||
{renownXPPerLevel * 30, 30},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := renownLevelFor(c.xp); got != c.want {
|
||||
t.Errorf("renownLevelFor(%d) = %d, want %d", c.xp, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenownXPIntoLevel(t *testing.T) {
|
||||
into, cost := renownXPIntoLevel(renownXPPerLevel + 500)
|
||||
if cost != renownXPPerLevel {
|
||||
t.Errorf("cost = %d, want %d", cost, renownXPPerLevel)
|
||||
}
|
||||
if into != 500 {
|
||||
t.Errorf("into = %d, want 500", into)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenownRankLadderMonotonic(t *testing.T) {
|
||||
// Rank thresholds must strictly increase so renownRankFor is well-defined.
|
||||
for i := 1; i < len(renownRanks); i++ {
|
||||
if renownRanks[i].MinLevel <= renownRanks[i-1].MinLevel {
|
||||
t.Errorf("rank %d threshold %d not > %d", i, renownRanks[i].MinLevel, renownRanks[i-1].MinLevel)
|
||||
}
|
||||
}
|
||||
if renownRankFor(0) != "" {
|
||||
t.Errorf("rank at 0 should be empty")
|
||||
}
|
||||
if renownRankFor(1) != "Renowned" {
|
||||
t.Errorf("rank at 1 = %q, want Renowned", renownRankFor(1))
|
||||
}
|
||||
if renownRankFor(4) != "Storied" {
|
||||
t.Errorf("rank at 4 = %q, want Storied (threshold 3)", renownRankFor(4))
|
||||
}
|
||||
if renownRankFor(1000) != "Eternal" {
|
||||
t.Errorf("top rank = %q, want Eternal", renownRankFor(1000))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenownMarker(t *testing.T) {
|
||||
if renownMarker(0) != "" {
|
||||
t.Errorf("marker at 0 should be empty")
|
||||
}
|
||||
if got := renownMarker(7); got != "✦7" {
|
||||
t.Errorf("marker(7) = %q, want ✦7", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRenownBonuses_CombatNeutralAndCapped — renown grants only the two
|
||||
// combat-neutral economy levers (loot/XP), capped at +15%/+20%, and must never
|
||||
// touch a lever that combat_stats.go maps to a combat stat. §B2.
|
||||
func TestApplyRenownBonuses_CombatNeutralAndCapped(t *testing.T) {
|
||||
// Below the first step: no effect.
|
||||
b := &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, 2)
|
||||
if b.XPMultiplier != 0 || b.LootQuality != 0 {
|
||||
t.Errorf("renown < step should be inert, got %+v", b)
|
||||
}
|
||||
|
||||
// One step at renown 3.
|
||||
b = &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, 3)
|
||||
if b.XPMultiplier != 2 || b.LootQuality != 1.5 {
|
||||
t.Errorf("one step wrong: %+v", b)
|
||||
}
|
||||
|
||||
// At renown 30 and beyond, capped at +20% XP / +15% loot.
|
||||
for _, lvl := range []int{30, 45, 300} {
|
||||
b = &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, lvl)
|
||||
if b.XPMultiplier != 20 || b.LootQuality != 15 {
|
||||
t.Errorf("renown %d not capped: %+v", lvl, b)
|
||||
}
|
||||
}
|
||||
|
||||
// Never combat-stat inflation: the levers combat_stats.go reads
|
||||
// (DeathModifier→Defense, SuccessBonus→Attack, ExceptionalBonus→CritRate)
|
||||
// and the skill/combat levers must all stay at zero at any renown level.
|
||||
b = &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, 300)
|
||||
if b.DeathModifier != 0 || b.SuccessBonus != 0 || b.ExceptionalBonus != 0 {
|
||||
t.Errorf("renown leaked into a combat-stat lever: %+v", b)
|
||||
}
|
||||
if b.CombatBonus != 0 || b.MiningBonus != 0 || b.ForagingBonus != 0 || b.FishingBonus != 0 {
|
||||
t.Errorf("renown leaked into combat/skill levers: %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddRenownXP_Accumulates — cumulative += with correct before/after.
|
||||
func TestAddRenownXP_Accumulates(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_add:example")
|
||||
if err := createAdvCharacter(uid, "Renowner"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
before, after, err := addRenownXP(uid, 10000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before != 0 || after != 10000 {
|
||||
t.Errorf("first add: before=%d after=%d, want 0/10000", before, after)
|
||||
}
|
||||
|
||||
before, after, err = addRenownXP(uid, 20000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before != 10000 || after != 30000 {
|
||||
t.Errorf("second add: before=%d after=%d, want 10000/30000", before, after)
|
||||
}
|
||||
|
||||
got, err := loadRenownXP(uid)
|
||||
if err != nil || got != 30000 {
|
||||
t.Errorf("loadRenownXP = %d (err %v), want 30000", got, err)
|
||||
}
|
||||
if renownLevelForUser(uid) != 1 {
|
||||
t.Errorf("renownLevelForUser = %d, want 1", renownLevelForUser(uid))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenownAtLeast — the B4 achievement gate reads the derived level correctly.
|
||||
func TestRenownAtLeast(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_ach:example")
|
||||
if err := createAdvCharacter(uid, "Achiever"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := db.Get()
|
||||
if renownAtLeast(d, uid, 1) {
|
||||
t.Errorf("no renown yet, should not meet level 1")
|
||||
}
|
||||
if _, _, err := addRenownXP(uid, renownXPPerLevel*5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !renownAtLeast(d, uid, 5) {
|
||||
t.Errorf("renown 5 should meet level 5")
|
||||
}
|
||||
if renownAtLeast(d, uid, 6) {
|
||||
t.Errorf("renown 5 should not meet level 6")
|
||||
}
|
||||
// Absent player → false, not a crash.
|
||||
if renownAtLeast(d, id.UserID("@nobody:nowhere.invalid"), 1) {
|
||||
t.Errorf("absent player should not meet any renown level")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenownOverlay — applyPlayerMetaOverlay populates RenownXP and RenownLevel.
|
||||
func TestRenownOverlay(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_overlay:example")
|
||||
if err := createAdvCharacter(uid, "Overlaid"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := addRenownXP(uid, renownXPPerLevel*5+7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := loadAdvCharacter(uid)
|
||||
if err != nil || c == nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if c.RenownXP != renownXPPerLevel*5+7 {
|
||||
t.Errorf("overlay RenownXP = %d, want %d", c.RenownXP, renownXPPerLevel*5+7)
|
||||
}
|
||||
if c.RenownLevel() != 5 {
|
||||
t.Errorf("RenownLevel() = %d, want 5", c.RenownLevel())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantDnDXP_OverflowBecomesRenown — at the cap, grantDnDXP routes overflow
|
||||
// into renown_xp and zeroes dnd_xp (the pre-N7 cap invariant is preserved).
|
||||
func TestGrantDnDXP_OverflowBecomesRenown(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_grant:example")
|
||||
if err := createAdvCharacter(uid, "Capped"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: dndMaxLevel,
|
||||
STR: 16, DEX: 13, CON: 14, INT: 8, WIS: 10, CHA: 12, ArmorClass: 16,
|
||||
}
|
||||
c.HPMax = 100
|
||||
c.HPCurrent = 100
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
events, err := p.grantDnDXP(uid, renownXPPerLevel+5000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Errorf("got %d level-up events at cap, want 0", len(events))
|
||||
}
|
||||
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.Level != dndMaxLevel {
|
||||
t.Errorf("level = %d, want %d", got.Level, dndMaxLevel)
|
||||
}
|
||||
if got.XP != 0 {
|
||||
t.Errorf("dnd_xp = %d, want 0 (overflow moved to renown)", got.XP)
|
||||
}
|
||||
if xp, _ := loadRenownXP(uid); xp != renownXPPerLevel+5000 {
|
||||
t.Errorf("renown_xp = %d, want %d", xp, renownXPPerLevel+5000)
|
||||
}
|
||||
if renownLevelForUser(uid) != 1 {
|
||||
t.Errorf("renown level = %d, want 1", renownLevelForUser(uid))
|
||||
}
|
||||
}
|
||||
@@ -164,16 +164,28 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
|
||||
gaveCard = true
|
||||
}
|
||||
|
||||
// Update visit count
|
||||
// Update visit count, and every 10th visit leave a small consumable
|
||||
// "for the trouble" (D2 NPC arc).
|
||||
var leftGift *AdvItem
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err == nil {
|
||||
char.RobbieVisitCount++
|
||||
if char.RobbieVisitCount%robbieGiftEveryNVisits == 0 {
|
||||
// Use the canonical DnD level (like the arena's tier gate), not the
|
||||
// frozen legacy CombatLevel — that snapshots at 1–3 once D&D setup
|
||||
// completes, so reading it here would peg every gift at tier 1.
|
||||
if gifts := consumableCache(robbieGiftTier(arenaDnDLevelOrZero(userID)), 1); len(gifts) > 0 {
|
||||
if err := addAdvInventoryItem(userID, gifts[0]); err == nil {
|
||||
leftGift = &gifts[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = saveAdvCharacter(char)
|
||||
_ = upsertPlayerMetaNPCState(userID, npcStateFromAdvChar(char))
|
||||
}
|
||||
|
||||
// Send DM
|
||||
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard)
|
||||
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard, leftGift)
|
||||
if err := p.SendDM(userID, dm); err != nil {
|
||||
slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err)
|
||||
}
|
||||
@@ -191,10 +203,12 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string
|
||||
func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem {
|
||||
var result []AdvItem
|
||||
for _, item := range inv {
|
||||
// Never touch Arena gear, cards, or consumables. Consumables are a
|
||||
// player-curated stockpile (crafted or dropped); selling them is an
|
||||
// explicit decision the player must make themselves.
|
||||
if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" {
|
||||
// Never touch Arena gear, cards, consumables, or keys. Consumables are
|
||||
// a player-curated stockpile (crafted or dropped); selling them is an
|
||||
// explicit decision the player must make themselves. Keys are cross-zone
|
||||
// unlock tokens (N5/D4) that must persist in inventory to open their
|
||||
// vault later — sweeping one permanently breaks that unlock.
|
||||
if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" || item.Type == "key" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -243,7 +257,27 @@ func robbiePlayerHasCard(userID id.UserID) bool {
|
||||
|
||||
// ── DM Rendering ─────────────────────────────────────────────────────────────
|
||||
|
||||
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool) string {
|
||||
// robbieGiftEveryNVisits is how often Robbie leaves a consumable behind.
|
||||
const robbieGiftEveryNVisits = 10
|
||||
|
||||
// robbieGiftTier maps a player's combat level to a consumable tier, matching
|
||||
// the arena tier bands (1–3 / 4–7 / 8–12 / 13–17 / 18+).
|
||||
func robbieGiftTier(level int) int {
|
||||
switch {
|
||||
case level >= 18:
|
||||
return 5
|
||||
case level >= 13:
|
||||
return 4
|
||||
case level >= 8:
|
||||
return 3
|
||||
case level >= 4:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool, leftGift *AdvItem) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Opening
|
||||
@@ -290,6 +324,12 @@ func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gav
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Every-10th-visit consumable (D2).
|
||||
if leftGift != nil {
|
||||
sb.WriteString(fmt.Sprintf(robbieLeftConsumable, leftGift.Name))
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Closing
|
||||
closing, _ := advPickFlavor(robbieClosings, userID, "robbie_closing")
|
||||
sb.WriteString(closing)
|
||||
|
||||
@@ -163,6 +163,7 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
||||
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||
applyRenownBonuses(bonuses, char.RenownLevel()) // N7/B2 — activity path only, never combat
|
||||
balance := p.euro.GetBalance(char.UserID)
|
||||
|
||||
holidayLabel := ""
|
||||
@@ -420,6 +421,12 @@ func (p *AdventurePlugin) midnightReset() error {
|
||||
|
||||
dmsSent := 0
|
||||
for _, char := range chars {
|
||||
// Advance this player's Shadow (N6/D3) once for the day, before any
|
||||
// streak/idle branching below — the rival runs whether or not the
|
||||
// player did, which is the whole point of the race pressure. Own table,
|
||||
// own idempotency guard; never touches char, never fails the reset.
|
||||
p.advanceShadow(&char)
|
||||
|
||||
// Died inside the window — no shame, no streak change. Covers both
|
||||
// currently-dead players and players revived at midnight (Alive
|
||||
// already flipped to true by the reminder loop before this runs).
|
||||
|
||||
199
internal/plugin/adventure_season.go
Normal file
199
internal/plugin/adventure_season.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// N7/E4 — Seasonal events (gogobee_engagement_plan.md §E4).
|
||||
//
|
||||
// A season is a 1-week skin anchored to a real holiday. Unlike isHolidayToday()
|
||||
// (a single UTC day, worth a double-action) and unlike the Omen (an ISO-week
|
||||
// rotation), a season spans a window around its anchor date and layers three
|
||||
// things on top of the world for that week:
|
||||
//
|
||||
// 1. A themed world modifier — a reskinned Omen that OVERRIDES the weekly
|
||||
// rotation (see activeOmen). It reuses the omen effect fields, so the same
|
||||
// non-combat rule holds: a season never touches SimulateCombat or the turn
|
||||
// engine. That is enforced structurally — activeSeason() honours
|
||||
// simOmenDisabled exactly as activeOmen() does, so the balance sim never
|
||||
// sees a season through any path, and the season Omen only reaches the sim's
|
||||
// omen seams behind activeOmen's own simOmenDisabled guard.
|
||||
// 2. A limited-time curio shelf at Luigi's — a curated selection of existing
|
||||
// registry items (dailyCuriosStock). No new power enters the game; the shelf
|
||||
// just changes which items rotate in for the week.
|
||||
// 3. A themed visitor on the road — a season-gated ambient event that leaves a
|
||||
// small keepsake and a coin gift (expedition_ambient.go). No combat: the
|
||||
// ambient seam has never opened a fight and this does not change that.
|
||||
//
|
||||
// A season is a pure function of the UTC date and the anchor calendar, so it
|
||||
// needs no schema, no ticker state, and no persistence — the same discipline as
|
||||
// the Omen and the holiday calendar.
|
||||
|
||||
// seasonHalfWidth is the number of days on each side of the anchor date that the
|
||||
// season is live. 3 → a 7-day window (anchor ± 3).
|
||||
const seasonHalfWidth = 3
|
||||
|
||||
// seasonVisitor is the themed ambient event a season adds to the road. It never
|
||||
// resolves combat — it leaves a sellable keepsake and a small coin gift.
|
||||
type seasonVisitor struct {
|
||||
Weight int // relative weight in the ambient pick
|
||||
FlavorPool []string // scene narration lines for the visit
|
||||
Keepsake string // sellable trophy left behind
|
||||
KeepsakeValue int64 // coin baseline of the keepsake
|
||||
Coins int // flat coin gift
|
||||
}
|
||||
|
||||
// season is one holiday skin.
|
||||
type season struct {
|
||||
Key string // stable id (tests, logs)
|
||||
Name string // player-facing, e.g. "Hallowtide"
|
||||
Emoji string // banner emoji
|
||||
Anchor func(year int) time.Time // the anchor date for a given year (UTC)
|
||||
Blurb string // one-line "what's live this week" banner copy
|
||||
Omen omen // themed override omen (non-combat fields only)
|
||||
CurioIDs []string // curated registry IDs for Luigi's shelf
|
||||
Visitor seasonVisitor // the road visitor
|
||||
}
|
||||
|
||||
// fixedAnchor builds an Anchor for a fixed month/day holiday.
|
||||
func fixedAnchor(month time.Month, day int) func(int) time.Time {
|
||||
return func(year int) time.Time {
|
||||
return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
}
|
||||
|
||||
// seasonTable is the set of anchor holidays that get a skin. Order is match
|
||||
// order; the windows are disjoint so it never matters, but keep them disjoint.
|
||||
var seasonTable = []season{
|
||||
{
|
||||
Key: "hallowtide", Name: "Hallowtide", Emoji: "🎃",
|
||||
Anchor: fixedAnchor(time.October, 31),
|
||||
Blurb: "the veil's thin — spooky curios at Luigi's and things going bump on the road, all week",
|
||||
Omen: omen{
|
||||
Key: "hallowtide", Name: "Hallowtide",
|
||||
TwinBee: "The veil's gone thin for Hallowtide — reagents and oddments are spilling through from somewhere I'd rather not name. I'm grabbing them while they're here.",
|
||||
ConsumableChanceMult: 2.0,
|
||||
},
|
||||
CurioIDs: []string{
|
||||
"cloak_of_arachnida", "demon_armor", "goggles_of_night",
|
||||
"slippers_of_spider_climbing", "staff_of_swarming_insects",
|
||||
"sword_of_life_stealing", "dagger_of_venom", "cloak_of_the_bat",
|
||||
},
|
||||
Visitor: seasonVisitor{
|
||||
Weight: 12,
|
||||
FlavorPool: []string{
|
||||
"A gourd-headed thing shuffles out of the dark, sets a little carved lantern on your bedroll, and shuffles right back into it.",
|
||||
"Something small and many-legged skitters past, drops a trinket at your feet as if in tribute, and is gone before you can look twice.",
|
||||
"A cold draft carries a whisper and a gift — a carved gourd, still faintly warm, left where you'll find it come morning.",
|
||||
},
|
||||
Keepsake: "Carved Gourd Lantern", KeepsakeValue: 60, Coins: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "midwinter", Name: "Midwinter Feast", Emoji: "❄️",
|
||||
Anchor: fixedAnchor(time.December, 25),
|
||||
Blurb: "gifts by every door — a free pack for anyone heading out, warm curios at Luigi's, all week",
|
||||
Omen: omen{
|
||||
Key: "midwinter", Name: "Midwinter Feast",
|
||||
TwinBee: "It's Midwinter, and someone's been leaving gifts by the outfitter's door. There's a free pack in it for anyone heading out — and I set off in high spirits.",
|
||||
SupplyFreebiePacks: 1,
|
||||
StartMoodBonus: 5,
|
||||
},
|
||||
CurioIDs: []string{
|
||||
"boots_of_the_winterlands", "frost_brand", "ring_of_warmth",
|
||||
"staff_of_frost", "potion_of_healing", "staff_of_healing",
|
||||
},
|
||||
Visitor: seasonVisitor{
|
||||
Weight: 12,
|
||||
FlavorPool: []string{
|
||||
"A bundled figure passes your camp without a word, leaves a frost-glass bauble hanging where the firelight catches it, and trudges on into the snow.",
|
||||
"You wake to find your pack a little heavier — a wrapped bauble and a handful of coin, and a single set of bootprints leading away.",
|
||||
"Bells, faint and far off. By the time they fade there's a bauble on your bedroll that wasn't there before.",
|
||||
},
|
||||
Keepsake: "Frost-Glass Bauble", KeepsakeValue: 60, Coins: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "sweethearts", Name: "Sweethearts' Revel", Emoji: "💗",
|
||||
Anchor: fixedAnchor(time.February, 14),
|
||||
Blurb: "the arena crowd's throwing coin — fat purses, charming curios at Luigi's, all week",
|
||||
Omen: omen{
|
||||
Key: "sweethearts", Name: "Sweethearts' Revel",
|
||||
TwinBee: "The whole town's giddy for Sweethearts' week — the arena crowd's throwing coin like confetti. Win pretty and the purse pays twenty over the odds.",
|
||||
ArenaPayoutMult: 1.20,
|
||||
},
|
||||
CurioIDs: []string{
|
||||
"eyes_of_charming", "philter_of_love", "staff_of_charming",
|
||||
"luck_blade", "stone_of_good_luck_luckstone", "pearl_of_power",
|
||||
"glamoured_studded_leather",
|
||||
},
|
||||
Visitor: seasonVisitor{
|
||||
Weight: 12,
|
||||
FlavorPool: []string{
|
||||
"A courier in festival colours finds you even out here, presses a ribbon-wrapped token into your hand with a wink, and hurries back the way they came.",
|
||||
"A paper heart, pinned to a token and left on your pack — 'from an admirer,' it says, and nothing else.",
|
||||
"Someone's left a ribbon-wrapped keepsake by the trail marker, addressed to no one and everyone. You pocket it.",
|
||||
},
|
||||
Keepsake: "Ribbon-Wrapped Token", KeepsakeValue: 55, Coins: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "first_bloom", Name: "First Bloom", Emoji: "🌷",
|
||||
Anchor: func(year int) time.Time { return easterDate(year) },
|
||||
Blurb: "everything's growing eager — fuller harvests, green curios at Luigi's, all week",
|
||||
Omen: omen{
|
||||
Key: "first_bloom", Name: "First Bloom",
|
||||
TwinBee: "First Bloom's on us — everything's growing twice as eager and the woods feel calm with it. Every gather comes up fuller, and the dread's slow to rise.",
|
||||
HarvestYieldBonus: 1,
|
||||
ThreatDriftReduce: 1,
|
||||
},
|
||||
CurioIDs: []string{
|
||||
"bag_of_beans", "potion_of_growth", "potion_of_animal_friendship",
|
||||
"ring_of_animal_influence", "horn_of_valhalla", "sword_of_life_stealing",
|
||||
},
|
||||
Visitor: seasonVisitor{
|
||||
Weight: 12,
|
||||
FlavorPool: []string{
|
||||
"A hare the size of a hound lopes up, regards you with unsettling calm, and leaves a pressed blossom on the ground before bounding off.",
|
||||
"New vines have crept over your gear in the night — and tucked among them, a perfect pressed blossom and a little coin, as if the woods were paying rent.",
|
||||
"The undergrowth parts on its own, sets a spring blossom at your feet, and closes again. You decide not to question it.",
|
||||
},
|
||||
Keepsake: "Pressed Spring Blossom", KeepsakeValue: 50, Coins: 8,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// activeSeason returns the season whose window contains today (UTC), or false.
|
||||
// Honours simOmenDisabled so the balance sim never observes a season through any
|
||||
// path — the same neutralisation activeOmen applies to the weekly rotation.
|
||||
func activeSeason() (season, bool) {
|
||||
if simOmenDisabled {
|
||||
return season{}, false
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
return seasonForDate(today)
|
||||
}
|
||||
|
||||
// seasonForDate is the pure core of activeSeason, split out for tests. The
|
||||
// anchor is checked against the target year and its neighbours so a window that
|
||||
// straddles a year boundary still resolves.
|
||||
func seasonForDate(today time.Time) (season, bool) {
|
||||
for _, s := range seasonTable {
|
||||
for _, yr := range []int{today.Year() - 1, today.Year(), today.Year() + 1} {
|
||||
a := s.Anchor(yr)
|
||||
lo := a.AddDate(0, 0, -seasonHalfWidth)
|
||||
hi := a.AddDate(0, 0, seasonHalfWidth)
|
||||
if !today.Before(lo) && !today.After(hi) {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return season{}, false
|
||||
}
|
||||
|
||||
// seasonBannerLine is the "what's live this week" one-liner surfaced under the
|
||||
// Omen line in the morning DM. Empty when no season is active.
|
||||
func seasonBannerLine(s season) string {
|
||||
return s.Emoji + " **" + s.Name + "** is here — " + s.Blurb + "."
|
||||
}
|
||||
120
internal/plugin/adventure_season_test.go
Normal file
120
internal/plugin/adventure_season_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func date(y int, m time.Month, d int) time.Time {
|
||||
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// TestSeasonForDate_ActiveOnAnchor — every season resolves on its own anchor day
|
||||
// and both window edges (±seasonHalfWidth), and is dark one day past each edge.
|
||||
func TestSeasonForDate_ActiveOnAnchor(t *testing.T) {
|
||||
for _, s := range seasonTable {
|
||||
a := s.Anchor(2026)
|
||||
for _, off := range []int{-seasonHalfWidth, 0, seasonHalfWidth} {
|
||||
got, ok := seasonForDate(a.AddDate(0, 0, off))
|
||||
if !ok || got.Key != s.Key {
|
||||
t.Errorf("%s: day offset %d → (%v, %q), want (true, %q)",
|
||||
s.Key, off, ok, got.Key, s.Key)
|
||||
}
|
||||
}
|
||||
for _, off := range []int{-seasonHalfWidth - 1, seasonHalfWidth + 1} {
|
||||
if got, ok := seasonForDate(a.AddDate(0, 0, off)); ok {
|
||||
t.Errorf("%s: day offset %d should be dark, got %q", s.Key, off, got.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeasonForDate_WindowsDisjoint — no calendar day resolves to two seasons, so
|
||||
// the first-match order in seasonForDate never hides overlapping content.
|
||||
func TestSeasonForDate_WindowsDisjoint(t *testing.T) {
|
||||
for y := 2024; y <= 2030; y++ {
|
||||
day := date(y, time.January, 1)
|
||||
end := date(y+1, time.January, 1)
|
||||
for day.Before(end) {
|
||||
matches := 0
|
||||
for _, s := range seasonTable {
|
||||
for _, yr := range []int{day.Year() - 1, day.Year(), day.Year() + 1} {
|
||||
a := s.Anchor(yr)
|
||||
if !day.Before(a.AddDate(0, 0, -seasonHalfWidth)) &&
|
||||
!day.After(a.AddDate(0, 0, seasonHalfWidth)) {
|
||||
matches++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches > 1 {
|
||||
t.Fatalf("%s resolves to %d seasons (windows must be disjoint)",
|
||||
day.Format("2006-01-02"), matches)
|
||||
}
|
||||
day = day.AddDate(0, 0, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeasonTable_CuratedItemsExist — every curated curio ID is a real registry
|
||||
// item, so a season's shelf never lists a phantom the buyer can't purchase. Guards
|
||||
// against typos and registry edits (mirrors TestTemperMaterialsAreObtainable).
|
||||
func TestSeasonTable_CuratedItemsExist(t *testing.T) {
|
||||
for _, s := range seasonTable {
|
||||
if len(s.CurioIDs) == 0 {
|
||||
t.Errorf("%s has no curated curios", s.Key)
|
||||
}
|
||||
for _, id := range s.CurioIDs {
|
||||
if _, ok := magicItemRegistry[id]; !ok {
|
||||
t.Errorf("%s curio %q is not in magicItemRegistry", s.Key, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeasonTable_OmenIsNonCombat — a season's themed omen carries an effect and
|
||||
// only touches the non-combat omen levers, so overriding the weekly rotation with
|
||||
// it can never move the combat golden or the balance corpus. §E4/§B3.
|
||||
func TestSeasonTable_OmenIsNonCombat(t *testing.T) {
|
||||
for _, s := range seasonTable {
|
||||
o := s.Omen
|
||||
hasEffect := o.HarvestYieldBonus > 0 || o.SupplyFreebiePacks > 0 ||
|
||||
o.StartMoodBonus > 0 || o.ArenaPayoutMult > 1.0 ||
|
||||
o.ConsumableChanceMult > 1.0 || o.ThreatDriftReduce > 0
|
||||
if !hasEffect {
|
||||
t.Errorf("%s omen has no active effect", s.Key)
|
||||
}
|
||||
if o.Name == "" || o.TwinBee == "" {
|
||||
t.Errorf("%s omen missing player-facing copy", s.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeasonTable_VisitorRewardComplete — every season's road visitor has flavor,
|
||||
// a named keepsake, and a positive keepsake value, so applyAmbientEffect always
|
||||
// has a real reward to grant.
|
||||
func TestSeasonTable_VisitorRewardComplete(t *testing.T) {
|
||||
for _, s := range seasonTable {
|
||||
v := s.Visitor
|
||||
if len(v.FlavorPool) == 0 {
|
||||
t.Errorf("%s visitor has no flavor", s.Key)
|
||||
}
|
||||
if v.Keepsake == "" || v.KeepsakeValue <= 0 {
|
||||
t.Errorf("%s visitor keepsake incomplete: %q value %d", s.Key, v.Keepsake, v.KeepsakeValue)
|
||||
}
|
||||
if v.Weight <= 0 {
|
||||
t.Errorf("%s visitor has non-positive weight %d", s.Key, v.Weight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestActiveSeason_NeutralizedInSim — the balance sim must never observe a season
|
||||
// through any path; activeSeason honours simOmenDisabled exactly as activeOmen does.
|
||||
func TestActiveSeason_NeutralizedInSim(t *testing.T) {
|
||||
prev := simOmenDisabled
|
||||
simOmenDisabled = true
|
||||
defer func() { simOmenDisabled = prev }()
|
||||
if s, ok := activeSeason(); ok {
|
||||
t.Fatalf("activeSeason returned %q while simOmenDisabled", s.Key)
|
||||
}
|
||||
}
|
||||
216
internal/plugin/adventure_secret_room_test.go
Normal file
216
internal/plugin/adventure_secret_room_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// registeredSecretNodes collects every NodeKindSecret node across all
|
||||
// registered zone graphs, keyed by node ID.
|
||||
func registeredSecretNodes(t *testing.T) map[string]ZoneNode {
|
||||
t.Helper()
|
||||
out := map[string]ZoneNode{}
|
||||
for _, z := range allZones() {
|
||||
g, ok := loadZoneGraph(z.ID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for id, n := range g.Nodes {
|
||||
if n.Kind == NodeKindSecret {
|
||||
out[id] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestSecretRoomDiscovery_EverySecretHasBespokeFlavor guards that no secret
|
||||
// node ships on the generic fallback — every one gets an authored line. Catches
|
||||
// a future secret room added without a discovery entry.
|
||||
func TestSecretRoomDiscovery_EverySecretHasBespokeFlavor(t *testing.T) {
|
||||
for id, n := range registeredSecretNodes(t) {
|
||||
if _, ok := secretRoomDiscovery[id]; !ok {
|
||||
t.Errorf("secret node %q (%q) has no bespoke discovery line", id, n.Label)
|
||||
}
|
||||
if line := secretRoomDiscoveryLine(n); strings.TrimSpace(line) == "" {
|
||||
t.Errorf("secret node %q produced an empty discovery line", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretRoomDiscoveryLine_FallsBackOnUnknownNode(t *testing.T) {
|
||||
n := ZoneNode{NodeID: "made_up.node", Kind: NodeKindSecret, Label: "Nowhere Nook"}
|
||||
line := secretRoomDiscoveryLine(n)
|
||||
if !strings.Contains(line, "Nowhere Nook") {
|
||||
t.Errorf("fallback should name the node label, got: %s", line)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecretRoomKeys_UnlockRealDestinationEdges is the cross-zone-key
|
||||
// consistency check: every key's item Name must match a LockKey edge's key_id
|
||||
// (lower-cased) in its destination zone, and every source must be a real secret
|
||||
// room. A typo between the granted item name and the authored key_id would
|
||||
// silently make a vault permanently unreachable; this catches it.
|
||||
func TestSecretRoomKeys_UnlockRealDestinationEdges(t *testing.T) {
|
||||
secrets := registeredSecretNodes(t)
|
||||
for srcNode, key := range secretRoomKeys {
|
||||
if _, ok := secrets[srcNode]; !ok {
|
||||
t.Errorf("key source %q is not a registered secret room", srcNode)
|
||||
}
|
||||
g, ok := loadZoneGraph(key.unlocksIn)
|
||||
if !ok {
|
||||
t.Errorf("%s: destination zone %q has no graph", srcNode, key.unlocksIn)
|
||||
continue
|
||||
}
|
||||
wantKeyID := strings.ToLower(key.item.Name)
|
||||
found := false
|
||||
for _, outs := range g.Edges {
|
||||
for _, e := range outs {
|
||||
if e.Lock == LockKey && strings.ToLower(lockDataString(e.LockData, "key_id")) == wantKeyID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("key %q (from %s) has no LockKey edge with key_id=%q in zone %q — vault unreachable",
|
||||
key.item.Name, srcNode, wantKeyID, key.unlocksIn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossZoneKey_UnlocksWithItemNotWithout exercises the runtime lock
|
||||
// evaluation end-to-end on a real authored edge.
|
||||
func TestCrossZoneKey_UnlocksWithItemNotWithout(t *testing.T) {
|
||||
g, ok := loadZoneGraph(ZoneManorBlackspire)
|
||||
if !ok {
|
||||
t.Fatal("manor graph missing")
|
||||
}
|
||||
var keyEdge ZoneEdge
|
||||
for _, e := range g.outgoingEdges("manor_blackspire.upper_hall") {
|
||||
if e.Lock == LockKey {
|
||||
keyEdge = e
|
||||
}
|
||||
}
|
||||
if keyEdge.To == "" {
|
||||
t.Fatal("no LockKey edge off manor upper_hall")
|
||||
}
|
||||
|
||||
base := edgeUnlockCtx{RunID: "r", FromNode: "manor_blackspire.upper_hall"}
|
||||
if ok, _ := evaluateEdgeLock(keyEdge, base); ok {
|
||||
t.Error("edge should be locked without the key in inventory")
|
||||
}
|
||||
|
||||
withKey := base
|
||||
withKey.InventoryNames = map[string]bool{"sunken sigil": true}
|
||||
if ok, reason := evaluateEdgeLock(keyEdge, withKey); !ok {
|
||||
t.Errorf("edge should unlock with the sigil in inventory, got locked: %s", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantSecretRoomKey_GrantsOnceIdempotent verifies a key-bearing secret
|
||||
// hands its key over exactly once.
|
||||
func TestGrantSecretRoomKey_GrantsOnceIdempotent(t *testing.T) {
|
||||
townTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
u := id.UserID("@keyholder:test.invalid")
|
||||
|
||||
node := ZoneNode{NodeID: "sunken_temple.coral_reliquary", Kind: NodeKindSecret}
|
||||
line := p.grantSecretRoomKey(u, node)
|
||||
if !strings.Contains(line, "Sunken Sigil") {
|
||||
t.Fatalf("first grant should name the key, got: %q", line)
|
||||
}
|
||||
|
||||
items, err := loadAdvInventory(u)
|
||||
if err != nil {
|
||||
t.Fatalf("load inventory: %v", err)
|
||||
}
|
||||
sigils := 0
|
||||
for _, it := range items {
|
||||
if strings.EqualFold(it.Name, "Sunken Sigil") {
|
||||
sigils++
|
||||
}
|
||||
}
|
||||
if sigils != 1 {
|
||||
t.Fatalf("want exactly 1 Sunken Sigil, got %d", sigils)
|
||||
}
|
||||
|
||||
// Second find on a later run must not stack a duplicate.
|
||||
if again := p.grantSecretRoomKey(u, node); again != "" {
|
||||
t.Errorf("re-finding the room should not re-grant the key, got: %q", again)
|
||||
}
|
||||
items, _ = loadAdvInventory(u)
|
||||
sigils = 0
|
||||
for _, it := range items {
|
||||
if strings.EqualFold(it.Name, "Sunken Sigil") {
|
||||
sigils++
|
||||
}
|
||||
}
|
||||
if sigils != 1 {
|
||||
t.Fatalf("still want exactly 1 Sunken Sigil after re-find, got %d", sigils)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantSecretRoomKey_NoKeyForPlainSecret confirms a secret room that carries
|
||||
// no cross-zone key stays silent.
|
||||
func TestGrantSecretRoomKey_NoKeyForPlainSecret(t *testing.T) {
|
||||
townTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
u := id.UserID("@plain:test.invalid")
|
||||
node := ZoneNode{NodeID: "forest_shadows.sapling_shrine", Kind: NodeKindSecret}
|
||||
if line := p.grantSecretRoomKey(u, node); line != "" {
|
||||
t.Errorf("plain secret should grant no key, got: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveSecretRoom_GrantsPageCacheAndKey checks the full treasure-cache
|
||||
// payout: a journal page, the consumable cache, and (for a key-bearing secret)
|
||||
// the cross-zone key — with no combat touching HP.
|
||||
func TestResolveSecretRoom_GrantsPageCacheAndKey(t *testing.T) {
|
||||
townTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
u := id.UserID("@secret:test.invalid")
|
||||
|
||||
zone, ok := getZone(ZoneSunkenTemple)
|
||||
if !ok {
|
||||
t.Fatal("sunken temple zone missing")
|
||||
}
|
||||
node := ZoneNode{
|
||||
NodeID: "sunken_temple.coral_reliquary", Kind: NodeKindSecret,
|
||||
Label: "Coral Reliquary",
|
||||
Content: ZoneNodeContent{LootBias: 1.8},
|
||||
}
|
||||
run := &DungeonRun{UserID: string(u), ZoneID: ZoneSunkenTemple, CurrentNode: node.NodeID}
|
||||
|
||||
out := p.resolveSecretRoom(u, run, zone, node)
|
||||
if !strings.Contains(out, "Coral Reliquary") {
|
||||
t.Errorf("outcome should carry the discovery line:\n%s", out)
|
||||
}
|
||||
|
||||
// A journal page was granted (fresh player, nothing found yet).
|
||||
if mask, err := loadJournalPages(u); err != nil || journalPageCount(mask) != 1 {
|
||||
t.Fatalf("want exactly 1 journal page granted, got count=%d err=%v", journalPageCount(mask), err)
|
||||
}
|
||||
|
||||
// The guaranteed cache + the cross-zone key are in inventory.
|
||||
items, err := loadAdvInventory(u)
|
||||
if err != nil {
|
||||
t.Fatalf("load inventory: %v", err)
|
||||
}
|
||||
var caches, keys int
|
||||
for _, it := range items {
|
||||
switch {
|
||||
case it.Type == "key":
|
||||
keys++
|
||||
case it.Type == "consumable":
|
||||
caches++
|
||||
}
|
||||
}
|
||||
if caches != secretRoomCacheCount {
|
||||
t.Errorf("want %d cache consumables, got %d", secretRoomCacheCount, caches)
|
||||
}
|
||||
if keys != 1 {
|
||||
t.Errorf("want the Sunken Sigil key granted, got %d keys", keys)
|
||||
}
|
||||
}
|
||||
432
internal/plugin/adventure_shadow.go
Normal file
432
internal/plugin/adventure_shadow.go
Normal file
@@ -0,0 +1,432 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// The Shadow (N6/D3) — a per-player simulated rival adventurer. It "runs" the
|
||||
// same zone progression the player does, advanced once per UTC day by the
|
||||
// midnight ticker at ~1.3x the player's own long-run clear pace, so it stays
|
||||
// just ahead: a rival you can always see and always nearly catch.
|
||||
//
|
||||
// It is pure theatre. There is no Shadow combat, no punishment, no state the
|
||||
// player can lose. The only mechanics are race pressure (a morning-briefing
|
||||
// line telling you where it is relative to you) and two payoffs at each zone
|
||||
// clear: beat the Shadow to a zone and TwinBee crows a little bonus XP; let the
|
||||
// Shadow clear it first and it leaves a journal page waiting in the boss room
|
||||
// (the D1 campaign tie-in). All Shadow narration TwinBee speaks is first-person,
|
||||
// he/him, implicit-subject — the campaign voice rules (feedback_twinbee_voice,
|
||||
// feedback_twinbee_is_male). The Shadow itself is referred to as "the Shadow"
|
||||
// (or by its name); "he" in TwinBee's lines is TwinBee, never a third-person
|
||||
// "TwinBee".
|
||||
|
||||
const (
|
||||
// shadowPaceMultiplier keeps the Shadow just ahead: it clears at ~1.3x the
|
||||
// player's own long-run pace.
|
||||
shadowPaceMultiplier = 1.3
|
||||
// shadowMinDailyStep is the floor on a day's advance, so a brand-new or idle
|
||||
// player still watches the rival creep forward rather than sit frozen.
|
||||
shadowMinDailyStep = 0.2
|
||||
// shadowMaxDailyStep caps a day's advance at just under one zone, so even a
|
||||
// prolific player never sees the Shadow clear two zones overnight.
|
||||
shadowMaxDailyStep = 1.0
|
||||
// shadowMaxLead caps how far ahead the Shadow may run — it is a race, not a
|
||||
// runaway. It will never sit more than ~2.5 zones past the player's own count.
|
||||
shadowMaxLead = 2.5
|
||||
// shadowCrowXPPerTier scales the "you beat me here" bonus by zone tier, so a
|
||||
// T5 crow is worth more than a T1 one. Small on purpose (lift, not a spike).
|
||||
shadowCrowXPPerTier = 12
|
||||
)
|
||||
|
||||
// shadowState mirrors the adventure_shadow row.
|
||||
type shadowState struct {
|
||||
UserID id.UserID
|
||||
Name string
|
||||
Progress float64
|
||||
ZonesCleared int
|
||||
PendingMask int64
|
||||
CrowedMask int64
|
||||
DayCounter int
|
||||
LastAdvanced string
|
||||
}
|
||||
|
||||
// The Shadow walks zoneOrder — the design-doc order the zone registry is built
|
||||
// in — start to finish, tier 1 → 5. Bit i of a Shadow's masks is zoneOrder[i].
|
||||
|
||||
// shadowZoneIndex returns the progression index of a zone, or -1 if it is not
|
||||
// on the path (the arena, an unknown id).
|
||||
func shadowZoneIndex(z ZoneID) int {
|
||||
for i, id := range zoneOrder {
|
||||
if id == z {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func shadowSetBit(mask int64, idx int) int64 {
|
||||
if idx < 0 || idx > 62 {
|
||||
return mask
|
||||
}
|
||||
return mask | (int64(1) << idx)
|
||||
}
|
||||
|
||||
func shadowClearBit(mask int64, idx int) int64 {
|
||||
if idx < 0 || idx > 62 {
|
||||
return mask
|
||||
}
|
||||
return mask &^ (int64(1) << idx)
|
||||
}
|
||||
|
||||
func shadowBitSet(mask int64, idx int) bool {
|
||||
if idx < 0 || idx > 62 {
|
||||
return false
|
||||
}
|
||||
return mask&(int64(1)<<idx) != 0
|
||||
}
|
||||
|
||||
// loadShadow reads a player's Shadow, or (nil, nil) if it hasn't been born yet.
|
||||
func loadShadow(userID id.UserID) (*shadowState, error) {
|
||||
s := &shadowState{UserID: userID}
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT name, progress, zones_cleared, pending_mask, crowed_mask, day_counter, last_advanced
|
||||
FROM adventure_shadow WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&s.Name, &s.Progress, &s.ZonesCleared, &s.PendingMask, &s.CrowedMask, &s.DayCounter, &s.LastAdvanced)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// upsertShadow persists the whole Shadow row. The ticker is the only writer, so
|
||||
// a plain last-write-wins upsert is safe — there is no concurrent mutator to
|
||||
// race (unlike the player_meta columns a character save also touches).
|
||||
func upsertShadow(s *shadowState) error {
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO adventure_shadow
|
||||
(user_id, name, progress, zones_cleared, pending_mask, crowed_mask, day_counter, last_advanced)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
progress = excluded.progress,
|
||||
zones_cleared = excluded.zones_cleared,
|
||||
pending_mask = excluded.pending_mask,
|
||||
crowed_mask = excluded.crowed_mask,
|
||||
day_counter = excluded.day_counter,
|
||||
last_advanced = excluded.last_advanced`,
|
||||
string(s.UserID), s.Name, s.Progress, s.ZonesCleared, s.PendingMask,
|
||||
s.CrowedMask, s.DayCounter, s.LastAdvanced,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// shadowNames is the pool the rival's proper name is drawn from — cold,
|
||||
// half-familiar names for a thing that shadows you. Picked deterministically
|
||||
// from the player's own name so a given player always faces the same Shadow.
|
||||
var shadowNames = []string{
|
||||
"Vael", "Corriss", "Mordane", "Ashen", "Locke", "Grimma", "Sable",
|
||||
"Thane", "Wren", "Vesper", "Cael", "Orrin", "Dain", "Wraithe",
|
||||
"Nyx", "Solenne",
|
||||
}
|
||||
|
||||
// shadowNameFor seeds the Shadow's name from the player's display name so it is
|
||||
// stable per player and "seeded from the player's" (D3 spec). Falls back to the
|
||||
// user id when the display name is empty.
|
||||
func shadowNameFor(displayName string, userID id.UserID) string {
|
||||
seed := strings.ToLower(strings.TrimSpace(displayName))
|
||||
if seed == "" {
|
||||
seed = string(userID)
|
||||
}
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(seed))
|
||||
return shadowNames[int(h.Sum32())%len(shadowNames)]
|
||||
}
|
||||
|
||||
// newShadow mints a fresh Shadow at the start line.
|
||||
func newShadow(userID id.UserID, displayName string) *shadowState {
|
||||
return &shadowState{
|
||||
UserID: userID,
|
||||
Name: shadowNameFor(displayName, userID),
|
||||
}
|
||||
}
|
||||
|
||||
// advanceShadow moves a player's Shadow forward one UTC day. Idempotent per day
|
||||
// via last_advanced, so the once-a-day midnight guard plus this makes a double
|
||||
// tick a no-op. Mints the Shadow on first call. Errors are logged and swallowed
|
||||
// — a stalled Shadow is cosmetic, and must never break the midnight reset loop.
|
||||
func (p *AdventurePlugin) advanceShadow(char *AdventureCharacter) {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
s, err := loadShadow(char.UserID)
|
||||
if err != nil {
|
||||
slog.Warn("shadow: load", "user", char.UserID, "err", err)
|
||||
return
|
||||
}
|
||||
if s == nil {
|
||||
s = newShadow(char.UserID, char.DisplayName)
|
||||
}
|
||||
if s.LastAdvanced == today {
|
||||
return // already advanced this UTC day
|
||||
}
|
||||
|
||||
cleared := clearedZoneIDs(db.Get(), char.UserID)
|
||||
playerCount := len(cleared)
|
||||
|
||||
// The player's long-run pace, in zones per day, drives the Shadow's speed.
|
||||
// Floor it so a new/idle player still gets a creeping rival; the 1.3x keeps
|
||||
// the Shadow just ahead; cap the daily step so it never leaps two zones.
|
||||
step := (float64(playerCount) / float64(shadowPlayerAgeDays(char))) * shadowPaceMultiplier
|
||||
if step < shadowMinDailyStep {
|
||||
step = shadowMinDailyStep
|
||||
}
|
||||
if step > shadowMaxDailyStep {
|
||||
step = shadowMaxDailyStep
|
||||
}
|
||||
|
||||
newProg := s.Progress + step
|
||||
// Keep it a race: never more than shadowMaxLead zones past the player's count.
|
||||
if leadCap := float64(playerCount) + shadowMaxLead; newProg > leadCap {
|
||||
newProg = leadCap
|
||||
}
|
||||
if maxProg := float64(len(zoneOrder)); newProg > maxProg {
|
||||
newProg = maxProg
|
||||
}
|
||||
if newProg < s.Progress {
|
||||
newProg = s.Progress // never regress (a lead cap that dropped below current)
|
||||
}
|
||||
|
||||
// Any progression zone the Shadow newly finishes, and the player hasn't,
|
||||
// leaves a page waiting. Compare per-zone (not by count): the Shadow walks
|
||||
// in order while the player may clear out of order, so "did the Shadow clear
|
||||
// zone i first" is a per-zone question.
|
||||
newCleared := int(newProg)
|
||||
if newCleared > len(zoneOrder) {
|
||||
newCleared = len(zoneOrder)
|
||||
}
|
||||
for idx := s.ZonesCleared; idx < newCleared; idx++ {
|
||||
if !cleared[zoneOrder[idx]] {
|
||||
s.PendingMask = shadowSetBit(s.PendingMask, idx)
|
||||
}
|
||||
}
|
||||
|
||||
s.Progress = newProg
|
||||
s.ZonesCleared = newCleared
|
||||
s.DayCounter++
|
||||
s.LastAdvanced = today
|
||||
if err := upsertShadow(s); err != nil {
|
||||
slog.Warn("shadow: advance upsert", "user", char.UserID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// shadowPlayerAgeDays estimates how many days the player has been adventuring,
|
||||
// clamped to at least 1 so a same-day character never divides by zero. Used as
|
||||
// the denominator of the player's pace.
|
||||
func shadowPlayerAgeDays(char *AdventureCharacter) float64 {
|
||||
if char.CreatedAt.IsZero() {
|
||||
return 1
|
||||
}
|
||||
days := time.Since(char.CreatedAt).Hours() / 24
|
||||
if days < 1 {
|
||||
return 1
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
// shadowOnPlayerZoneClear is the payoff, fired when the player clears a zone's
|
||||
// boss. Three outcomes: the Shadow left a page here (pending bit) → grant the
|
||||
// page and clear the bit; the player beat the Shadow to this zone → a crow plus
|
||||
// a little bonus XP; or the Shadow already passed through with nothing owed →
|
||||
// nothing. Returns the narration line to append to the clear message ("" for
|
||||
// the silent case, or when the Shadow hasn't been born yet).
|
||||
func (p *AdventurePlugin) shadowOnPlayerZoneClear(userID id.UserID, zoneID ZoneID) string {
|
||||
s, err := loadShadow(userID)
|
||||
if err != nil || s == nil {
|
||||
return ""
|
||||
}
|
||||
idx := shadowZoneIndex(zoneID)
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if shadowBitSet(s.PendingMask, idx) {
|
||||
// The Shadow got here first and left a page. Grant it BEFORE retiring the
|
||||
// debt: grantJournalPage returns "" both when the campaign is already
|
||||
// complete and on a transient DB error, so clear the pending bit only once
|
||||
// the page has actually landed (or the ledger is genuinely full). A
|
||||
// transient failure then leaves the bit set and the next clear retries,
|
||||
// rather than swallowing a page the player earned.
|
||||
pageLine := p.grantJournalPage(userID, nil)
|
||||
mask, _ := loadJournalPages(userID)
|
||||
if pageLine == "" && !journalComplete(mask) {
|
||||
slog.Warn("shadow: waiting page grant failed; leaving debt for retry",
|
||||
"user", userID, "zone", zoneID)
|
||||
return ""
|
||||
}
|
||||
s.PendingMask = shadowClearBit(s.PendingMask, idx)
|
||||
if err := upsertShadow(s); err != nil {
|
||||
// The page is already granted; a failed bit-clear can at worst re-grant
|
||||
// a different unfound page on a later re-clear, which is benign (pages
|
||||
// are collectible and the campaign self-limits). Better than losing it.
|
||||
slog.Warn("shadow: clear pending bit", "user", userID, "zone", zoneID, "err", err)
|
||||
}
|
||||
if pageLine == "" {
|
||||
// Ledger already complete — nothing to leave, but the Shadow was first.
|
||||
return shadowBeatYouHereLine(s)
|
||||
}
|
||||
return shadowLeftPageLine(s) + "\n" + pageLine
|
||||
}
|
||||
|
||||
// No page owed. If the Shadow hasn't reached this zone yet, the player beat
|
||||
// it here — crow and hand over a small bonus, but only once per zone: the
|
||||
// crowed bit makes re-running a not-yet-reached zone unable to farm the XP.
|
||||
if int(s.Progress) <= idx && !shadowBitSet(s.CrowedMask, idx) {
|
||||
xp := shadowCrowXP(zoneID)
|
||||
if _, err := p.grantDnDXP(userID, xp); err != nil {
|
||||
// Don't promise XP we didn't grant, and don't burn the crow — leave the
|
||||
// bit unset so the next clear can crow for real.
|
||||
slog.Warn("shadow: crow xp", "user", userID, "err", err)
|
||||
return ""
|
||||
}
|
||||
s.CrowedMask = shadowSetBit(s.CrowedMask, idx)
|
||||
if err := upsertShadow(s); err != nil {
|
||||
slog.Warn("shadow: set crowed bit", "user", userID, "zone", zoneID, "err", err)
|
||||
}
|
||||
return shadowCrowLine(s, xp)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// shadowCrowXP is the bonus for beating the Shadow to a zone, scaled by tier.
|
||||
func shadowCrowXP(zoneID ZoneID) int {
|
||||
tier := 1
|
||||
if z, ok := getZone(zoneID); ok {
|
||||
tier = int(z.Tier)
|
||||
}
|
||||
if tier < 1 {
|
||||
tier = 1
|
||||
}
|
||||
return tier * shadowCrowXPPerTier
|
||||
}
|
||||
|
||||
// shadowBriefingLine is the morning-briefing race-pressure beat: where the
|
||||
// Shadow stands relative to the player. Deterministic by day so a re-rendered
|
||||
// briefing reads the same, and silent until the Shadow has been born and has
|
||||
// something to say. TwinBee's voice.
|
||||
func (p *AdventurePlugin) shadowBriefingLine(e *Expedition) string {
|
||||
userID := id.UserID(e.UserID)
|
||||
s, err := loadShadow(userID)
|
||||
if err != nil || s == nil {
|
||||
return ""
|
||||
}
|
||||
playerCount := len(clearedZoneIDs(db.Get(), userID))
|
||||
lead := s.ZonesCleared - playerCount
|
||||
return shadowRacePressureLine(s, e.CurrentDay, lead)
|
||||
}
|
||||
|
||||
// ── Flavour ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// TwinBee's voice: first-person, implicit subject, he/him, one line. "The
|
||||
// Shadow"/"{name}" is the rival; "I"/"we" is TwinBee and the player. Never the
|
||||
// third-person "TwinBee" (guarded by test, same as the journal reactions).
|
||||
|
||||
// shadowAheadLines — TwinBee, the Shadow is ahead of us. Race pressure.
|
||||
var shadowAheadLines = []string{
|
||||
"Passed the Shadow's camp at dawn — cold ashes, a day old. %s is ahead of us again.",
|
||||
"Found %s's mark cut into a doorframe up ahead. Still warm. We're not gaining.",
|
||||
"%s came through here already. I can smell the pitch of a torch not long snuffed.",
|
||||
"The Shadow's been and gone. %s leaves the doors open behind, like he wants us to know.",
|
||||
}
|
||||
|
||||
// shadowBehindLines — TwinBee, we're ahead of the Shadow. Earned swagger.
|
||||
var shadowBehindLines = []string{
|
||||
"No sign of %s ahead — I think we're out in front for once. Let's keep it that way.",
|
||||
"We've outpaced the Shadow. %s is somewhere behind us, eating our dust. Good.",
|
||||
"Quiet up ahead. %s hasn't reached this deep yet. First ones through.",
|
||||
}
|
||||
|
||||
// shadowNeckLines — TwinBee, we're level with the Shadow. Tension.
|
||||
var shadowNeckLines = []string{
|
||||
"%s is right on our heels — or we're on his. Hard to say who's chasing who now.",
|
||||
"Neck and neck with the Shadow. I keep catching %s at the edge of the torchlight.",
|
||||
"%s is close. Same rooms, hours apart. Whoever clears the next zone first wins the day.",
|
||||
}
|
||||
|
||||
// shadowRacePressureLine picks a race-pressure line by the day and the Shadow's
|
||||
// lead, deterministically. lead > 0: Shadow ahead; lead < 0: player ahead; 0:
|
||||
// level.
|
||||
func shadowRacePressureLine(s *shadowState, day, lead int) string {
|
||||
var pool []string
|
||||
switch {
|
||||
case lead > 0:
|
||||
pool = shadowAheadLines
|
||||
case lead < 0:
|
||||
pool = shadowBehindLines
|
||||
default:
|
||||
pool = shadowNeckLines
|
||||
}
|
||||
if len(pool) == 0 {
|
||||
return ""
|
||||
}
|
||||
idx := (day + s.DayCounter) % len(pool)
|
||||
if idx < 0 {
|
||||
idx = -idx
|
||||
}
|
||||
return "👤 " + fmt.Sprintf(pool[idx], s.Name)
|
||||
}
|
||||
|
||||
// shadowLeftPageLine prefaces the waiting journal page: the Shadow cleared this
|
||||
// zone first and left a page behind for us. TwinBee's voice.
|
||||
func shadowLeftPageLine(s *shadowState) string {
|
||||
return fmt.Sprintf("👤 %s beat us to this one — but left a torn page pinned in the boss's lair, like a note for whoever came next.", s.Name)
|
||||
}
|
||||
|
||||
// shadowBeatYouHereLine is the page-less acknowledgement (campaign already
|
||||
// complete): the Shadow was first, but there's nothing left to find.
|
||||
func shadowBeatYouHereLine(s *shadowState) string {
|
||||
return fmt.Sprintf("👤 %s was here before us. Boot-prints in the dust, and nothing left to take. We know how this ends now anyway.", s.Name)
|
||||
}
|
||||
|
||||
// shadowCrowLine is TwinBee crowing that we beat the Shadow to a zone, with the
|
||||
// bonus XP.
|
||||
func shadowCrowLine(s *shadowState, xp int) string {
|
||||
return fmt.Sprintf("👤 First ones through — %s is still somewhere behind us. I'd crow if it wouldn't give us away. (**+%d XP**, for beating the Shadow here.)", s.Name, xp)
|
||||
}
|
||||
|
||||
// handleShadowCmd renders the player's standing against the Shadow. A quiet
|
||||
// status view — the feature lives mostly in the briefing and at zone clears.
|
||||
func (p *AdventurePlugin) handleShadowCmd(ctx MessageContext) error {
|
||||
s, err := loadShadow(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't read the trail just now. Try again in a moment.")
|
||||
}
|
||||
if s == nil {
|
||||
return p.SendDM(ctx.Sender, "👤 No one's shadowing you yet. Clear a zone or two and someone will take an interest.")
|
||||
}
|
||||
playerCount := len(clearedZoneIDs(db.Get(), ctx.Sender))
|
||||
total := len(zoneOrder)
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("👤 **The Shadow — %s**\n\n", s.Name))
|
||||
b.WriteString(fmt.Sprintf("🏴 **%s's trail:** %d / %d zones cleared\n", s.Name, s.ZonesCleared, total))
|
||||
b.WriteString(fmt.Sprintf("🚩 **Your trail:** %d / %d zones cleared\n", playerCount, total))
|
||||
switch lead := s.ZonesCleared - playerCount; {
|
||||
case lead > 0:
|
||||
b.WriteString(fmt.Sprintf("\n_%s is **%d** ahead. Pages wait where he cleared first — take them back by clearing those zones yourself._", s.Name, lead))
|
||||
case lead < 0:
|
||||
b.WriteString(fmt.Sprintf("\n_You're **%d** ahead. Keep it that way._", -lead))
|
||||
default:
|
||||
b.WriteString("\n_Dead level. The next zone decides who's chasing whom._")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
}
|
||||
360
internal/plugin/adventure_shadow_test.go
Normal file
360
internal/plugin/adventure_shadow_test.go
Normal file
@@ -0,0 +1,360 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func newShadowTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
// freshChar builds a same-day character so shadowPlayerAgeDays reads 1 day.
|
||||
func freshChar(user id.UserID) *AdventureCharacter {
|
||||
return &AdventureCharacter{
|
||||
UserID: user,
|
||||
DisplayName: "Tester",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowName_DeterministicAndInPool(t *testing.T) {
|
||||
user := id.UserID("@name:test.invalid")
|
||||
first := shadowNameFor("Aria", user)
|
||||
if again := shadowNameFor("Aria", user); again != first {
|
||||
t.Errorf("name not deterministic: %q vs %q", first, again)
|
||||
}
|
||||
inPool := false
|
||||
for _, n := range shadowNames {
|
||||
if n == first {
|
||||
inPool = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inPool {
|
||||
t.Errorf("generated name %q not in shadowNames pool", first)
|
||||
}
|
||||
// Empty display name falls back to the user id and still yields a pool name.
|
||||
if got := shadowNameFor("", user); got == "" {
|
||||
t.Error("empty display name produced an empty shadow name")
|
||||
}
|
||||
}
|
||||
|
||||
// TestShadowFlavor_NoThirdPersonTwinBee: every Shadow line TwinBee speaks obeys
|
||||
// the campaign voice rule — he never refers to himself in the third person
|
||||
// (feedback_twinbee_voice). Same guard the journal reactions carry.
|
||||
func TestShadowFlavor_NoThirdPersonTwinBee(t *testing.T) {
|
||||
pools := [][]string{shadowAheadLines, shadowBehindLines, shadowNeckLines}
|
||||
for _, pool := range pools {
|
||||
for _, line := range pool {
|
||||
if strings.Contains(line, "TwinBee") {
|
||||
t.Errorf("third-person TwinBee in Shadow pool line: %q", line)
|
||||
}
|
||||
if !strings.Contains(line, "%s") {
|
||||
t.Errorf("race-pressure line missing the name slot: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
s := &shadowState{Name: "Vael"}
|
||||
rendered := []string{
|
||||
shadowLeftPageLine(s),
|
||||
shadowBeatYouHereLine(s),
|
||||
shadowCrowLine(s, 24),
|
||||
shadowRacePressureLine(s, 3, 1),
|
||||
shadowRacePressureLine(s, 3, -1),
|
||||
shadowRacePressureLine(s, 3, 0),
|
||||
}
|
||||
for _, r := range rendered {
|
||||
if strings.Contains(r, "TwinBee") {
|
||||
t.Errorf("third-person TwinBee in rendered Shadow line: %q", r)
|
||||
}
|
||||
if !strings.Contains(r, "Vael") {
|
||||
t.Errorf("rendered line dropped the Shadow's name: %q", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowRacePressure_Deterministic(t *testing.T) {
|
||||
s := &shadowState{Name: "Vael", DayCounter: 2}
|
||||
first := shadowRacePressureLine(s, 4, 1)
|
||||
if again := shadowRacePressureLine(s, 4, 1); again != first {
|
||||
t.Errorf("race pressure not deterministic: %q vs %q", first, again)
|
||||
}
|
||||
// Direction picks a different pool: ahead vs behind must differ in wording.
|
||||
ahead := shadowRacePressureLine(s, 4, 2)
|
||||
behind := shadowRacePressureLine(s, 4, -2)
|
||||
if ahead == behind {
|
||||
t.Errorf("ahead and behind produced the same line: %q", ahead)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowAdvance_CreepsAndIsIdempotent(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@creep:test.invalid")
|
||||
char := freshChar(user)
|
||||
|
||||
p.advanceShadow(char)
|
||||
s, err := loadShadow(user)
|
||||
if err != nil || s == nil {
|
||||
t.Fatalf("shadow not born: %v", err)
|
||||
}
|
||||
if s.Progress <= 0 {
|
||||
t.Errorf("a born shadow should have crept forward, got progress %v", s.Progress)
|
||||
}
|
||||
if s.Name == "" {
|
||||
t.Error("born shadow has no name")
|
||||
}
|
||||
firstProg := s.Progress
|
||||
|
||||
// Second advance the same UTC day is a no-op.
|
||||
p.advanceShadow(char)
|
||||
s2, _ := loadShadow(user)
|
||||
if s2.Progress != firstProg {
|
||||
t.Errorf("same-day re-advance moved the shadow: %v -> %v", firstProg, s2.Progress)
|
||||
}
|
||||
|
||||
// Roll the clock back a day and it advances again.
|
||||
s2.LastAdvanced = time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
if err := upsertShadow(s2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.advanceShadow(char)
|
||||
s3, _ := loadShadow(user)
|
||||
if s3.Progress <= firstProg {
|
||||
t.Errorf("a new day should have advanced the shadow: %v -> %v", firstProg, s3.Progress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowAdvance_PendingBitForUnclearedZone(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@pending:test.invalid")
|
||||
char := freshChar(user)
|
||||
|
||||
// Seed the shadow one step short of clearing zone 0, dated yesterday so the
|
||||
// next advance runs. The player has cleared nothing.
|
||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
seed := newShadow(user, char.DisplayName)
|
||||
seed.Progress = 0.95
|
||||
seed.LastAdvanced = yesterday
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p.advanceShadow(char)
|
||||
s, _ := loadShadow(user)
|
||||
if s.ZonesCleared < 1 {
|
||||
t.Fatalf("shadow should have cleared zone 0, zones_cleared=%d prog=%v", s.ZonesCleared, s.Progress)
|
||||
}
|
||||
if !shadowBitSet(s.PendingMask, 0) {
|
||||
t.Errorf("shadow cleared zone 0 before the player but left no pending page (mask=%b)", s.PendingMask)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowAdvance_NoPendingWhenPlayerClearedFirst(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@nopending:test.invalid")
|
||||
char := freshChar(user)
|
||||
|
||||
// Player already cleared zone 0.
|
||||
insertClearedExpedition(t, user, zoneOrder[0], ExpeditionStatusComplete, 1, "{}")
|
||||
|
||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
seed := newShadow(user, char.DisplayName)
|
||||
seed.Progress = 0.95
|
||||
seed.LastAdvanced = yesterday
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p.advanceShadow(char)
|
||||
s, _ := loadShadow(user)
|
||||
if s.ZonesCleared < 1 {
|
||||
t.Fatalf("shadow should have cleared zone 0, zones_cleared=%d", s.ZonesCleared)
|
||||
}
|
||||
if shadowBitSet(s.PendingMask, 0) {
|
||||
t.Errorf("player cleared zone 0 first — no page should wait (mask=%b)", s.PendingMask)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowAdvance_LeadCapKeepsItARace(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@leadcap:test.invalid")
|
||||
char := freshChar(user) // player has cleared nothing → count 0
|
||||
|
||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
seed := newShadow(user, char.DisplayName)
|
||||
seed.Progress = shadowMaxLead // already at the cap for a 0-clear player
|
||||
seed.ZonesCleared = 2
|
||||
seed.LastAdvanced = yesterday
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p.advanceShadow(char)
|
||||
s, _ := loadShadow(user)
|
||||
if s.Progress > shadowMaxLead+0.0001 {
|
||||
t.Errorf("shadow ran past the lead cap: progress %v > %v", s.Progress, shadowMaxLead)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowZoneClear_PendingGrantsWaitingPage(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@page:test.invalid")
|
||||
|
||||
idx := 2
|
||||
seed := newShadow(user, "Tester")
|
||||
seed.Progress = 5
|
||||
seed.ZonesCleared = 5
|
||||
seed.PendingMask = shadowSetBit(0, idx)
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
line := p.shadowOnPlayerZoneClear(user, zoneOrder[idx])
|
||||
if strings.TrimSpace(line) == "" {
|
||||
t.Fatal("a waiting page should have produced a clear-message line")
|
||||
}
|
||||
if !strings.Contains(line, "page") {
|
||||
t.Errorf("expected a journal-page grant in the line, got: %q", line)
|
||||
}
|
||||
// Bit cleared so a re-clear can't re-award.
|
||||
s, _ := loadShadow(user)
|
||||
if shadowBitSet(s.PendingMask, idx) {
|
||||
t.Error("pending bit not cleared after the page was granted")
|
||||
}
|
||||
// The page actually landed.
|
||||
if mask, _ := loadJournalPages(user); journalPageCount(mask) != 1 {
|
||||
t.Errorf("expected exactly one journal page granted, got %d", journalPageCount(mask))
|
||||
}
|
||||
|
||||
// A second clear of the same zone grants nothing more.
|
||||
again := p.shadowOnPlayerZoneClear(user, zoneOrder[idx])
|
||||
if again != "" {
|
||||
t.Errorf("re-clearing a paid-out zone should be silent, got: %q", again)
|
||||
}
|
||||
if mask, _ := loadJournalPages(user); journalPageCount(mask) != 1 {
|
||||
t.Errorf("re-clear granted a second page (count %d)", journalPageCount(mask))
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowZoneClear_PlayerFirstCrows(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@crow:test.invalid")
|
||||
|
||||
idx := 3
|
||||
seed := newShadow(user, "Tester")
|
||||
seed.Progress = 0 // shadow hasn't reached this zone
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
line := p.shadowOnPlayerZoneClear(user, zoneOrder[idx])
|
||||
if !strings.Contains(line, "XP") {
|
||||
t.Errorf("beating the shadow should crow a bonus, got: %q", line)
|
||||
}
|
||||
// No page granted on a crow.
|
||||
if mask, _ := loadJournalPages(user); journalPageCount(mask) != 0 {
|
||||
t.Errorf("a crow should not grant a page (count %d)", journalPageCount(mask))
|
||||
}
|
||||
// The crowed bit is set so a re-run of the same not-yet-reached zone can't
|
||||
// farm the XP again — second clear is silent.
|
||||
if s, _ := loadShadow(user); !shadowBitSet(s.CrowedMask, idx) {
|
||||
t.Error("crowed bit not set after the first crow")
|
||||
}
|
||||
if again := p.shadowOnPlayerZoneClear(user, zoneOrder[idx]); again != "" {
|
||||
t.Errorf("re-clearing an already-crowed zone should be silent, got: %q", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowZoneClear_ShadowPassedNoDebt(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@passed:test.invalid")
|
||||
|
||||
idx := 1
|
||||
seed := newShadow(user, "Tester")
|
||||
seed.Progress = 5 // well past this zone
|
||||
seed.ZonesCleared = 5
|
||||
seed.PendingMask = 0 // nothing owed here
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if line := p.shadowOnPlayerZoneClear(user, zoneOrder[idx]); line != "" {
|
||||
t.Errorf("a zone the shadow passed with nothing owed should be silent, got: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowZoneClear_SilentBeforeBirth(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@unborn:test.invalid")
|
||||
if line := p.shadowOnPlayerZoneClear(user, zoneOrder[0]); line != "" {
|
||||
t.Errorf("no shadow yet should be silent, got: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShadowBriefingLine_SilentUntilBorn(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
user := id.UserID("@brief:test.invalid")
|
||||
e := &Expedition{UserID: string(user), ZoneID: zoneOrder[0], CurrentDay: 2}
|
||||
|
||||
if line := p.shadowBriefingLine(e); line != "" {
|
||||
t.Errorf("no shadow should mean no briefing line, got: %q", line)
|
||||
}
|
||||
|
||||
seed := newShadow(user, "Tester")
|
||||
seed.ZonesCleared = 2
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if line := p.shadowBriefingLine(e); strings.TrimSpace(line) == "" {
|
||||
t.Error("a born shadow should produce a briefing line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleShadowCmd_DMsStatus(t *testing.T) {
|
||||
newShadowTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
sink := installSink(p)
|
||||
user := id.UserID("@cmd:test.invalid")
|
||||
|
||||
// Before birth: a gentle "no one's shadowing you yet".
|
||||
if err := p.handleShadowCmd(MessageContext{Sender: user}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dms := sink.dmsTo(user); len(dms) != 1 || !strings.Contains(dms[0], "shadowing you yet") {
|
||||
t.Errorf("expected an unborn-shadow DM, got: %v", sink.dmsTo(user))
|
||||
}
|
||||
|
||||
seed := newShadow(user, "Tester")
|
||||
seed.ZonesCleared = 4
|
||||
if err := upsertShadow(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := p.handleShadowCmd(MessageContext{Sender: user}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dms := sink.dmsTo(user)
|
||||
if len(dms) != 2 || !strings.Contains(dms[1], seed.Name) {
|
||||
t.Errorf("expected a status DM naming the shadow, got: %v", dms)
|
||||
}
|
||||
}
|
||||
@@ -1146,13 +1146,38 @@ func dailyCuriosStock() []MagicItem {
|
||||
rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
|
||||
rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] })
|
||||
|
||||
n := curiosStockSize
|
||||
if n > len(ids) {
|
||||
n = len(ids)
|
||||
target := curiosStockSize
|
||||
if target > len(ids) {
|
||||
target = len(ids)
|
||||
}
|
||||
stock := make([]MagicItem, 0, n)
|
||||
for _, id := range ids[:n] {
|
||||
stock := make([]MagicItem, 0, target)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
// N7/E4 — a live season features its curated items at the front of the shelf,
|
||||
// displacing the day's rotation. They are existing registry items, so no
|
||||
// net-new power enters the economy for the week; only which items rotate in.
|
||||
if s, ok := activeSeason(); ok {
|
||||
for _, id := range s.CurioIDs {
|
||||
if mi, ok := magicItemRegistry[id]; ok && !seen[id] {
|
||||
stock = append(stock, mi)
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
if len(stock) > target {
|
||||
target = len(stock)
|
||||
}
|
||||
}
|
||||
|
||||
// Fill the remaining slots from the day's deterministic rotation.
|
||||
for _, id := range ids {
|
||||
if len(stock) >= target {
|
||||
break
|
||||
}
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
stock = append(stock, magicItemRegistry[id])
|
||||
seen[id] = true
|
||||
}
|
||||
// Stable display order: rarity ascending, then name.
|
||||
sort.Slice(stock, func(i, j int) bool {
|
||||
@@ -1167,7 +1192,11 @@ func dailyCuriosStock() []MagicItem {
|
||||
|
||||
func (p *AdventurePlugin) luigiCuriosView(userID id.UserID, balance float64) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🔮 **Curios** — fresh stock daily at dawn\n")
|
||||
if s, ok := activeSeason(); ok {
|
||||
sb.WriteString(fmt.Sprintf("%s **Curios** — %s stock, this week only\n", s.Emoji, s.Name))
|
||||
} else {
|
||||
sb.WriteString("🔮 **Curios** — fresh stock daily at dawn\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
|
||||
|
||||
factor := p.shopSessionPriceFactor(userID)
|
||||
|
||||
487
internal/plugin/adventure_town.go
Normal file
487
internal/plugin/adventure_town.go
Normal file
@@ -0,0 +1,487 @@
|
||||
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)
|
||||
}
|
||||
234
internal/plugin/adventure_town_test.go
Normal file
234
internal/plugin/adventure_town_test.go
Normal file
@@ -0,0 +1,234 @@
|
||||
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,6 +50,26 @@ 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,3 +79,13 @@ 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 := dndLevelForUser(c.UserID) + c.MiningSkill + c.ForagingSkill + c.FishingSkill
|
||||
combined := combinedAdvLevel(&c)
|
||||
if combined > bestLevel {
|
||||
bestLevel = combined
|
||||
}
|
||||
@@ -283,7 +283,7 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe
|
||||
weight := 4 // minimum (level 1 in all 4 skills)
|
||||
for _, c := range chars {
|
||||
if c.UserID == uid {
|
||||
weight = dndLevelForUser(c.UserID) + c.MiningSkill + c.ForagingSkill + c.FishingSkill
|
||||
weight = combinedAdvLevel(&c)
|
||||
if weight < 4 {
|
||||
weight = 4
|
||||
}
|
||||
|
||||
133
internal/plugin/adventure_vault.go
Normal file
133
internal/plugin/adventure_vault.go
Normal file
@@ -0,0 +1,133 @@
|
||||
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)
|
||||
}
|
||||
138
internal/plugin/adventure_vault_test.go
Normal file
138
internal/plugin/adventure_vault_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
110
internal/plugin/adventure_well_rested.go
Normal file
110
internal/plugin/adventure_well_rested.go
Normal file
@@ -0,0 +1,110 @@
|
||||
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, ", ")
|
||||
}
|
||||
192
internal/plugin/adventure_well_rested_test.go
Normal file
192
internal/plugin/adventure_well_rested_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
784
internal/plugin/adventure_worldboss.go
Normal file
784
internal/plugin/adventure_worldboss.go
Normal file
@@ -0,0 +1,784 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// World boss (N6/C3) — the monthly communal "Siege". A named boss camps outside
|
||||
// town for a fixed window with a single shared HP pool. Every player gets one
|
||||
// bout per day against it (an arena-style fight through runZoneCombat); the
|
||||
// damage they deal is subtracted from the shared pool whether the individual
|
||||
// bout is won or lost. The pool falling to zero defeats the boss; the window
|
||||
// lapsing with the pool still up means the boss survives and loots the town.
|
||||
//
|
||||
// Economy (decisions locked 2026-07-10): a defeat pays every contributor from a
|
||||
// freshly minted bounty scaled by fights fought (not damage — accessibility),
|
||||
// plus a consumable cache and one low-rate treasure roll each; a survival debits
|
||||
// the community pot as a tribute (a sink the economy wants). The bout itself
|
||||
// costs real HP the way the arena does — no restore — but never permadeaths.
|
||||
//
|
||||
// The event lives in its own tables (world_boss / world_boss_contrib), outside
|
||||
// the saveAdvCharacter fan-out, so a character save can never clobber the shared
|
||||
// pool — the same structural isolation adventure_shadow earns.
|
||||
|
||||
const (
|
||||
// worldBossWindow is how long the boss camps outside town once it spawns.
|
||||
worldBossWindow = 72 * time.Hour
|
||||
// worldBossPresenceDays is the any-chat presence lookback used to size the
|
||||
// boss to the community that will actually fight it
|
||||
// (feedback_presence_is_any_chat).
|
||||
worldBossPresenceDays = 14
|
||||
// worldBossMinTier floors the boss tier — the Siege is always a serious
|
||||
// fight, never a T1/T2 pushover, even for a low-level town.
|
||||
worldBossMinTier = 3
|
||||
// worldBossBoutsPerPlayer sizes the shared pool: expected full-damage bouts
|
||||
// to fell the boss ≈ activePlayers × this. Higher = a longer siege that needs
|
||||
// more of the town to turn up. Tunable; watch prod turnout.
|
||||
worldBossBoutsPerPlayer = 2.0
|
||||
// worldBossMinBouts / worldBossMaxBouts clamp the pool so a near-empty town
|
||||
// still gets a beatable boss and a huge town doesn't get an unkillable one.
|
||||
worldBossMinBouts = 4
|
||||
worldBossMaxBouts = 60
|
||||
// worldBossDefeatBaseEuro is the minted bounty a single fight earns a
|
||||
// contributor on a defeat; total payout = base × fights fought.
|
||||
worldBossDefeatBaseEuro = 1500
|
||||
// worldBossTributePct is the fraction of the community pot the boss loots on
|
||||
// a survival — a visible pot sink.
|
||||
worldBossTributePct = 0.20
|
||||
// worldBossTreasureWeight is the (low) weight of the single treasure roll each
|
||||
// contributor gets on a defeat.
|
||||
worldBossTreasureWeight = advTreasureWeightElite
|
||||
// worldBossCacheSize is how many tier consumables each contributor is handed
|
||||
// on a defeat.
|
||||
worldBossCacheSize = 2
|
||||
)
|
||||
|
||||
// worldBossState mirrors an active world_boss row.
|
||||
type worldBossState struct {
|
||||
ID int64
|
||||
Name string
|
||||
Tier int
|
||||
HPMax int
|
||||
HPCurrent int
|
||||
Status string // active | defeated | survived
|
||||
StartsAt time.Time
|
||||
EndsAt time.Time
|
||||
}
|
||||
|
||||
// worldBossContrib mirrors a world_boss_contrib row — one player's tally against
|
||||
// one boss.
|
||||
type worldBossContrib struct {
|
||||
BossID int64
|
||||
UserID id.UserID
|
||||
Fights int
|
||||
Damage int
|
||||
LastFightDate string
|
||||
}
|
||||
|
||||
// worldBossNames is the pool a Siege boss is named from — big, ancient,
|
||||
// town-threatening things. Picked deterministically per event so a given month's
|
||||
// boss reads the same to everyone.
|
||||
var worldBossNames = []string{
|
||||
"Gorloth the Sunderer", "The Ashen Wyrm", "Kravok, Maw of the Deep",
|
||||
"Nyxaris the Devouring", "The Iron Colossus", "Ssath'ra, Coil of Ruin",
|
||||
"Ymirok the Frostbound", "The Hollow Leviathan", "Dravmaug, Ender of Walls",
|
||||
"The Bone Cathedral", "Vornath the Insatiable", "The Screaming Tide",
|
||||
}
|
||||
|
||||
// worldBossNameFor picks a stable boss name from an event key (the spawn month),
|
||||
// so the same Siege always reads by the same name.
|
||||
func worldBossNameFor(eventKey string) string {
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(eventKey))
|
||||
return worldBossNames[int(h.Sum32())%len(worldBossNames)]
|
||||
}
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────────
|
||||
|
||||
func scanWorldBoss(row interface{ Scan(...any) error }) (*worldBossState, error) {
|
||||
b := &worldBossState{}
|
||||
err := row.Scan(&b.ID, &b.Name, &b.Tier, &b.HPMax, &b.HPCurrent, &b.Status, &b.StartsAt, &b.EndsAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// loadActiveWorldBoss returns the live Siege, or (nil, nil) if none is camped.
|
||||
func loadActiveWorldBoss() (*worldBossState, error) {
|
||||
b, err := scanWorldBoss(db.Get().QueryRow(
|
||||
`SELECT id, name, tier, hp_max, hp_current, status, starts_at, ends_at
|
||||
FROM world_boss WHERE status = 'active' ORDER BY id DESC LIMIT 1`))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// loadWorldBoss reads one boss by id (history included). (nil, nil) if absent.
|
||||
func loadWorldBoss(bossID int64) (*worldBossState, error) {
|
||||
b, err := scanWorldBoss(db.Get().QueryRow(
|
||||
`SELECT id, name, tier, hp_max, hp_current, status, starts_at, ends_at
|
||||
FROM world_boss WHERE id = ?`, bossID))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// insertWorldBoss writes a fresh active boss and returns its id.
|
||||
func insertWorldBoss(name string, tier, hpMax int, startsAt, endsAt time.Time) (int64, error) {
|
||||
res, err := db.Get().Exec(
|
||||
`INSERT INTO world_boss (name, tier, hp_max, hp_current, status, starts_at, ends_at)
|
||||
VALUES (?, ?, ?, ?, 'active', ?, ?)`,
|
||||
name, tier, hpMax, hpMax, startsAt, endsAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// applyWorldBossDamage subtracts damage from the shared pool atomically, clamped
|
||||
// at zero, only while the boss is still active. Returns the pool's new value and
|
||||
// whether the pool is now down (killed). The UPDATE and the re-read are separate
|
||||
// statements, so two concurrent killers can BOTH observe killed=true — that is
|
||||
// fine: resolveWorldBossDefeated dedupes on the status='active' guard in
|
||||
// setWorldBossStatus, so only one payout ever fires. killed is just "should the
|
||||
// caller attempt resolution".
|
||||
func applyWorldBossDamage(bossID int64, dmg int) (remaining int, killed bool, err error) {
|
||||
if dmg < 0 {
|
||||
dmg = 0
|
||||
}
|
||||
_, err = db.Get().Exec(
|
||||
`UPDATE world_boss SET hp_current = MAX(0, hp_current - ?)
|
||||
WHERE id = ? AND status = 'active'`, dmg, bossID)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
b, err := loadWorldBoss(bossID)
|
||||
if err != nil || b == nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return b.HPCurrent, b.HPCurrent <= 0 && b.Status == "active", nil
|
||||
}
|
||||
|
||||
// setWorldBossStatus closes out a boss (defeated | survived) and stamps
|
||||
// resolved_at. Guarded on status='active' so a double resolution is a no-op.
|
||||
func setWorldBossStatus(bossID int64, status string) (bool, error) {
|
||||
res, err := db.Get().Exec(
|
||||
`UPDATE world_boss SET status = ?, resolved_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = 'active'`, status, bossID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
// upsertWorldBossContrib bumps a player's tally against a boss.
|
||||
func upsertWorldBossContrib(bossID int64, userID id.UserID, dmg int, dateKey string) error {
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO world_boss_contrib (boss_id, user_id, fights, damage, last_fight_date)
|
||||
VALUES (?, ?, 1, ?, ?)
|
||||
ON CONFLICT(boss_id, user_id) DO UPDATE SET
|
||||
fights = fights + 1,
|
||||
damage = damage + excluded.damage,
|
||||
last_fight_date = excluded.last_fight_date`,
|
||||
bossID, string(userID), dmg, dateKey)
|
||||
return err
|
||||
}
|
||||
|
||||
// loadWorldBossContrib reads one player's tally, or (nil, nil) if they haven't
|
||||
// fought this boss.
|
||||
func loadWorldBossContrib(bossID int64, userID id.UserID) (*worldBossContrib, error) {
|
||||
c := &worldBossContrib{BossID: bossID, UserID: userID}
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT fights, damage, last_fight_date FROM world_boss_contrib
|
||||
WHERE boss_id = ? AND user_id = ?`, bossID, string(userID)).
|
||||
Scan(&c.Fights, &c.Damage, &c.LastFightDate)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// loadWorldBossContribs reads every contributor to a boss, ordered by fights
|
||||
// desc then damage desc (the board order).
|
||||
func loadWorldBossContribs(bossID int64) ([]worldBossContrib, error) {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT user_id, fights, damage, last_fight_date FROM world_boss_contrib
|
||||
WHERE boss_id = ? ORDER BY fights DESC, damage DESC`, bossID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []worldBossContrib
|
||||
for rows.Next() {
|
||||
c := worldBossContrib{BossID: bossID}
|
||||
var uid string
|
||||
if err := rows.Scan(&uid, &c.Fights, &c.Damage, &c.LastFightDate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.UserID = id.UserID(uid)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ── Scaling ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// activePlayerIDs returns the user ids that have chatted (any message, not just
|
||||
// commands) in the last `days` days — the any-chat presence signal
|
||||
// (feedback_presence_is_any_chat), read off the streaks plugin's daily_activity
|
||||
// table.
|
||||
func activePlayerIDs(days int) ([]id.UserID, error) {
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -days).Format("2006-01-02")
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT DISTINCT user_id FROM daily_activity WHERE date >= ?`, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []id.UserID
|
||||
for rows.Next() {
|
||||
var uid string
|
||||
if err := rows.Scan(&uid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id.UserID(uid))
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// combinedAdvLevel is the same "how strong is this adventurer" reducer TwinBee
|
||||
// uses — combat level plus the three gathering skills.
|
||||
func combinedAdvLevel(c *AdventureCharacter) int {
|
||||
return dndLevelForUser(c.UserID) + c.MiningSkill + c.ForagingSkill + c.FishingSkill
|
||||
}
|
||||
|
||||
// medianInt returns the median of a sorted-or-not int slice (0 for empty).
|
||||
func medianInt(xs []int) int {
|
||||
if len(xs) == 0 {
|
||||
return 0
|
||||
}
|
||||
s := append([]int(nil), xs...)
|
||||
sort.Ints(s)
|
||||
mid := len(s) / 2
|
||||
if len(s)%2 == 1 {
|
||||
return s[mid]
|
||||
}
|
||||
return (s[mid-1] + s[mid]) / 2
|
||||
}
|
||||
|
||||
// tierForCombinedLevel buckets a combined adventure level to a zone tier, floored
|
||||
// at worldBossMinTier. Same thresholds as twinBeeMaxTier (combined 12 → T4,
|
||||
// 21 → T5), applied here to the median rather than the max.
|
||||
func tierForCombinedLevel(level int) int {
|
||||
switch {
|
||||
case level >= 21:
|
||||
return 5
|
||||
case level >= 12:
|
||||
return 4
|
||||
default:
|
||||
return worldBossMinTier
|
||||
}
|
||||
}
|
||||
|
||||
// groupInt renders an int with thousands separators (no currency symbol),
|
||||
// reusing fmtEuro's grouping.
|
||||
func groupInt(n int) string {
|
||||
return strings.TrimPrefix(fmtEuro(n), "€")
|
||||
}
|
||||
|
||||
// worldBossSpawnPlan computes the boss's tier and pool HP from the players active
|
||||
// in the presence window. Returns the tier, HP, and the active-player count that
|
||||
// drove the sizing (0 players ⇒ a floor boss so a manual spawn still works).
|
||||
func worldBossSpawnPlan() (tier, hpMax, activeN int) {
|
||||
ids, err := activePlayerIDs(worldBossPresenceDays)
|
||||
if err != nil {
|
||||
slog.Warn("worldboss: active player lookup failed", "err", err)
|
||||
}
|
||||
active := make(map[id.UserID]bool, len(ids))
|
||||
for _, uid := range ids {
|
||||
active[uid] = true
|
||||
}
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Warn("worldboss: char load failed", "err", err)
|
||||
}
|
||||
var levels []int
|
||||
for i := range chars {
|
||||
c := &chars[i]
|
||||
if !active[c.UserID] || !c.Alive {
|
||||
continue
|
||||
}
|
||||
levels = append(levels, combinedAdvLevel(c))
|
||||
}
|
||||
activeN = len(levels)
|
||||
tier = tierForCombinedLevel(medianInt(levels))
|
||||
|
||||
bouts := int(float64(max(activeN, 1)) * worldBossBoutsPerPlayer)
|
||||
if bouts < worldBossMinBouts {
|
||||
bouts = worldBossMinBouts
|
||||
}
|
||||
if bouts > worldBossMaxBouts {
|
||||
bouts = worldBossMaxBouts
|
||||
}
|
||||
perBout, _, _, _, _ := arenaTierBaseStats(tier)
|
||||
hpMax = perBout * bouts
|
||||
return tier, hpMax, activeN
|
||||
}
|
||||
|
||||
// worldBossTemplate builds the disposable per-bout enemy stat block. The shared
|
||||
// pool lives in world_boss.hp_current; this template is just the thing a single
|
||||
// player swings at, so its own HP is the arena tier's boss HP — beatable in a
|
||||
// bout, which lets a strong player land a full boss's worth of damage on the
|
||||
// pool.
|
||||
func worldBossTemplate(name string, tier int) DnDMonsterTemplate {
|
||||
hp, ac, atk, ab, cr := arenaTierBaseStats(tier)
|
||||
return DnDMonsterTemplate{
|
||||
ID: fmt.Sprintf("worldboss_t%d", tier),
|
||||
Name: name,
|
||||
CR: cr,
|
||||
HP: hp,
|
||||
AC: ac,
|
||||
Attack: atk,
|
||||
AttackBonus: ab,
|
||||
Speed: 12,
|
||||
BlockRate: 0.05,
|
||||
XPValue: arenaTiers[tier-1].BattleXP * 5,
|
||||
Notes: "the Siege",
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
// spawnWorldBoss mints a fresh Siege and announces it. It refuses if one is
|
||||
// already camped (only one at a time). eventKey names the boss deterministically
|
||||
// (the spawn month for the auto path, a stamp for a manual one).
|
||||
func (p *AdventurePlugin) spawnWorldBoss(eventKey string) (*worldBossState, error) {
|
||||
if existing, err := loadActiveWorldBoss(); err != nil {
|
||||
return nil, err
|
||||
} else if existing != nil {
|
||||
return existing, fmt.Errorf("a world boss is already active")
|
||||
}
|
||||
tier, hpMax, activeN := worldBossSpawnPlan()
|
||||
name := worldBossNameFor(eventKey)
|
||||
now := time.Now().UTC()
|
||||
ends := now.Add(worldBossWindow)
|
||||
bossID, err := insertWorldBoss(name, tier, hpMax, now, ends)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
boss, err := loadWorldBoss(bossID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.announceWorldBossSpawn(boss, activeN)
|
||||
slog.Info("worldboss: spawned", "id", bossID, "name", name, "tier", tier, "hp", hpMax, "activeN", activeN)
|
||||
return boss, nil
|
||||
}
|
||||
|
||||
// worldBossTick rides the 1-minute event ticker. It auto-spawns a boss on the
|
||||
// first of each UTC month and resolves a live boss whose window has lapsed. The
|
||||
// defeat path is not here — a bout crossing the pool to zero resolves inline
|
||||
// (W2), because the ticker never sees the pool between two 60s reads.
|
||||
func (p *AdventurePlugin) worldBossTick() {
|
||||
boss, err := loadActiveWorldBoss()
|
||||
if err != nil {
|
||||
slog.Warn("worldboss: tick load failed", "err", err)
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if boss != nil {
|
||||
// Safety net: a killing blow commits the pool to 0 inline, but if the
|
||||
// process died (or was redeployed) before the bout resolved the status,
|
||||
// the boss is left active/0-HP. Resolve it as the defeat it was — never
|
||||
// let the survive branch below debit the pot for a boss the town killed.
|
||||
if boss.HPCurrent <= 0 {
|
||||
p.resolveWorldBossDefeated(boss)
|
||||
return
|
||||
}
|
||||
if now.After(boss.EndsAt) {
|
||||
p.resolveWorldBossSurvived(boss)
|
||||
}
|
||||
return
|
||||
}
|
||||
// No boss camped — auto-spawn on the 1st of the month, once.
|
||||
if now.Day() != 1 {
|
||||
return
|
||||
}
|
||||
monthKey := now.Format("2006-01")
|
||||
if db.JobCompleted("worldboss_spawn", monthKey) {
|
||||
return
|
||||
}
|
||||
if _, err := p.spawnWorldBoss(monthKey); err != nil {
|
||||
slog.Warn("worldboss: auto-spawn failed", "err", err)
|
||||
return
|
||||
}
|
||||
db.MarkJobCompleted("worldboss_spawn", monthKey)
|
||||
}
|
||||
|
||||
// ── Resolution ───────────────────────────────────────────────────────────────
|
||||
|
||||
// worldBossPayout is one contributor's minted defeat reward.
|
||||
type worldBossPayout struct {
|
||||
UserID id.UserID
|
||||
Fights int
|
||||
Euro int
|
||||
}
|
||||
|
||||
// computeWorldBossPayouts scales the minted bounty by fights fought (not damage —
|
||||
// accessibility). Pure so the split is testable without a euro/Matrix stub.
|
||||
func computeWorldBossPayouts(contribs []worldBossContrib, baseEuro int) []worldBossPayout {
|
||||
out := make([]worldBossPayout, 0, len(contribs))
|
||||
for _, c := range contribs {
|
||||
if c.Fights <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, worldBossPayout{UserID: c.UserID, Fights: c.Fights, Euro: baseEuro * c.Fights})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveWorldBossDefeated pays every contributor a minted bounty scaled by
|
||||
// fights, plus a consumable cache and one low-rate treasure roll each, then
|
||||
// announces the kill. Guarded so it fires once.
|
||||
func (p *AdventurePlugin) resolveWorldBossDefeated(boss *worldBossState) {
|
||||
won, err := setWorldBossStatus(boss.ID, "defeated")
|
||||
if err != nil {
|
||||
slog.Warn("worldboss: defeat close-out failed", "err", err)
|
||||
return
|
||||
}
|
||||
if !won {
|
||||
return // someone else already resolved it
|
||||
}
|
||||
contribs, err := loadWorldBossContribs(boss.ID)
|
||||
if err != nil {
|
||||
slog.Warn("worldboss: contrib load failed", "err", err)
|
||||
}
|
||||
payouts := computeWorldBossPayouts(contribs, worldBossDefeatBaseEuro)
|
||||
treasureZone := worldBossTreasureZone(boss.Tier)
|
||||
for _, pay := range payouts {
|
||||
p.euro.Credit(pay.UserID, float64(pay.Euro), "worldboss_bounty")
|
||||
for _, item := range consumableCache(boss.Tier, worldBossCacheSize) {
|
||||
it := item
|
||||
p.grantZoneItem(pay.UserID, &it, "🧪")
|
||||
}
|
||||
if treasureZone != "" {
|
||||
p.rollZoneTreasure(pay.UserID, treasureZone, worldBossTreasureWeight)
|
||||
}
|
||||
}
|
||||
p.announceWorldBossDefeated(boss, payouts)
|
||||
slog.Info("worldboss: defeated", "id", boss.ID, "contributors", len(payouts))
|
||||
}
|
||||
|
||||
// resolveWorldBossSurvived debits the community pot as a tribute (a sink) and
|
||||
// announces the town's loss. Guarded so it fires once.
|
||||
func (p *AdventurePlugin) resolveWorldBossSurvived(boss *worldBossState) {
|
||||
won, err := setWorldBossStatus(boss.ID, "survived")
|
||||
if err != nil {
|
||||
slog.Warn("worldboss: survive close-out failed", "err", err)
|
||||
return
|
||||
}
|
||||
if !won {
|
||||
return
|
||||
}
|
||||
tribute := int(float64(communityPotBalance()) * worldBossTributePct)
|
||||
paid := 0
|
||||
if tribute > 0 && communityPotDebit(tribute) {
|
||||
paid = tribute
|
||||
}
|
||||
p.announceWorldBossSurvived(boss, paid)
|
||||
slog.Info("worldboss: survived", "id", boss.ID, "tribute", paid)
|
||||
}
|
||||
|
||||
// worldBossTreasureZone maps the boss tier to a representative zone whose treasure
|
||||
// table the defeat roll draws from. "" if no zone of that tier exists.
|
||||
func worldBossTreasureZone(tier int) ZoneID {
|
||||
zs := zonesByTier(ZoneTier(tier))
|
||||
if len(zs) == 0 {
|
||||
return ""
|
||||
}
|
||||
return zs[0].ID
|
||||
}
|
||||
|
||||
// ── Announcements (games room) ───────────────────────────────────────────────
|
||||
|
||||
// announceWorldBoss posts to the shared games room. No-op if GAMES_ROOM is unset.
|
||||
func (p *AdventurePlugin) announceWorldBoss(text string) {
|
||||
gr := gamesRoom()
|
||||
if gr == "" {
|
||||
return
|
||||
}
|
||||
if err := p.SendMessage(gr, text); err != nil {
|
||||
slog.Warn("worldboss: games-room announce failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) announceWorldBossSpawn(boss *worldBossState, activeN int) {
|
||||
p.announceWorldBoss(fmt.Sprintf(
|
||||
"⚔️ **The Siege has begun!** **%s** (Tier %d) camps outside town with **%s HP**.\n"+
|
||||
"Everyone gets **one bout per day** for the next 72 hours — `!adventure worldboss fight`. "+
|
||||
"Every hit chips the shared pool. Fell it together for a bounty; let it stand and it loots the town.",
|
||||
boss.Name, boss.Tier, groupInt(boss.HPMax)))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) announceWorldBossDefeated(boss *worldBossState, payouts []worldBossPayout) {
|
||||
var top string
|
||||
if len(payouts) > 0 {
|
||||
top = fmt.Sprintf(" Top of the muster: %s (%d fights).",
|
||||
p.DisplayName(payouts[0].UserID), payouts[0].Fights)
|
||||
}
|
||||
p.announceWorldBoss(fmt.Sprintf(
|
||||
"🏆 **%s has fallen!** %d adventurer(s) brought it down.%s\n"+
|
||||
"Bounties, supply caches, and salvage have been paid out to everyone who fought.",
|
||||
boss.Name, len(payouts), top))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) announceWorldBossSurvived(boss *worldBossState, tribute int) {
|
||||
msg := fmt.Sprintf("💀 **%s still stands.** The Siege is over — the town could not bring it down.", boss.Name)
|
||||
if tribute > 0 {
|
||||
msg += fmt.Sprintf(" It loots **€%s** from the community pot on its way out.", groupInt(tribute))
|
||||
}
|
||||
p.announceWorldBoss(msg)
|
||||
}
|
||||
|
||||
// ── Player command (W2) ──────────────────────────────────────────────────────
|
||||
|
||||
// handleWorldBossCmd routes `!adventure worldboss [status|fight|spawn]` (alias
|
||||
// `!adventure siege …`). Bare / "status" shows the board; "fight" takes today's
|
||||
// bout; "spawn" is an operator override.
|
||||
func (p *AdventurePlugin) handleWorldBossCmd(ctx MessageContext, args string) error {
|
||||
switch strings.ToLower(strings.TrimSpace(args)) {
|
||||
case "", "status":
|
||||
return p.worldBossStatusDM(ctx)
|
||||
case "fight":
|
||||
return p.fightWorldBoss(ctx)
|
||||
case "spawn":
|
||||
return p.worldBossOperatorSpawn(ctx)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Usage: `!adventure worldboss [status|fight]`")
|
||||
}
|
||||
|
||||
// worldBossOperatorSpawn lets an admin start a Siege on demand (the override half
|
||||
// of the "both" spawn decision). Silent to non-admins, like the other admin
|
||||
// commands.
|
||||
func (p *AdventurePlugin) worldBossOperatorSpawn(ctx MessageContext) error {
|
||||
if !p.IsAdmin(ctx.Sender) {
|
||||
return nil
|
||||
}
|
||||
key := "manual-" + time.Now().UTC().Format("2006-01-02T15:04")
|
||||
boss, err := p.spawnWorldBoss(key)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Could not spawn the Siege: %v", err))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"Spawned **%s** (Tier %d, %s HP). It camps for 72 hours.",
|
||||
boss.Name, boss.Tier, groupInt(boss.HPMax)))
|
||||
}
|
||||
|
||||
// fightWorldBoss runs one player's daily bout against the Siege: an arena-style
|
||||
// solo fight whose damage is subtracted from the shared pool win or lose. Real
|
||||
// HP cost, no death — a loss leaves the fighter battered (floored at 1 HP) but
|
||||
// standing. The per-user lock serialises a player's own repeat submits, so the
|
||||
// once-per-day gate can't be raced by a double-tap.
|
||||
func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
boss, err := loadActiveWorldBoss()
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Something went wrong reaching the Siege. Try again in a moment.")
|
||||
}
|
||||
if boss == nil {
|
||||
return p.SendDM(ctx.Sender, "No Siege is camped outside town right now.")
|
||||
}
|
||||
|
||||
char, err := loadAdvCharacter(ctx.Sender)
|
||||
if err != nil || char == nil {
|
||||
return p.SendDM(ctx.Sender, "You need an adventurer first — type `!adventure` to begin.")
|
||||
}
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "You're dead. The Siege will have to wait until you're back on your feet.")
|
||||
}
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
if worldBossBoutUsedToday(boss.ID, ctx.Sender, today) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You've already taken your bout against **%s** today. Come back tomorrow — one fight per day.", boss.Name))
|
||||
}
|
||||
|
||||
bout, err := p.resolveWorldBossBout(ctx.Sender, boss, today)
|
||||
if err != nil {
|
||||
slog.Error("worldboss: bout failed", "user", ctx.Sender, "err", err)
|
||||
return p.SendDM(ctx.Sender, "The Siege combat hit an error. Try again in a moment.")
|
||||
}
|
||||
|
||||
// Resolve a defeat BEFORE streaming the (multi-second) narration. The pool is
|
||||
// already committed to 0; flipping the status now closes the window where a
|
||||
// crash or the ticker's survive path could resolve a killed boss as a
|
||||
// survival and debit the pot instead of paying bounties.
|
||||
if bout.Killed {
|
||||
p.resolveWorldBossDefeated(boss)
|
||||
}
|
||||
|
||||
playerName, _ := loadDisplayName(ctx.Sender)
|
||||
if playerName == "" {
|
||||
playerName = "You"
|
||||
}
|
||||
phaseMessages := append([]string{
|
||||
fmt.Sprintf("⚔️ **The Siege — %s** (Tier %d)", boss.Name, boss.Tier),
|
||||
}, RenderCombatLog(bout.Combat, playerName, boss.Name)...)
|
||||
|
||||
var footer string
|
||||
if bout.Killed {
|
||||
footer = fmt.Sprintf("💥 You deal **%d** damage — the killing blow! **%s** falls!", bout.Damage, boss.Name)
|
||||
} else {
|
||||
footer = fmt.Sprintf("💥 You deal **%d** damage. **%s** has **%s / %s HP** left.",
|
||||
bout.Damage, boss.Name, groupInt(bout.Remaining), groupInt(boss.HPMax))
|
||||
}
|
||||
if bout.Battered {
|
||||
footer += "\nYou stagger out of the fight at 1 HP — rest up before your next outing."
|
||||
}
|
||||
|
||||
<-p.sendZoneCombatMessages(ctx.Sender, phaseMessages, footer)
|
||||
return nil
|
||||
}
|
||||
|
||||
// worldBossBoutUsedToday reports whether a player has already spent their daily
|
||||
// bout against this boss.
|
||||
func worldBossBoutUsedToday(bossID int64, userID id.UserID, dateKey string) bool {
|
||||
c, err := loadWorldBossContrib(bossID, userID)
|
||||
return err == nil && c != nil && c.LastFightDate == dateKey
|
||||
}
|
||||
|
||||
// worldBossBoutResult is the outcome of one resolved bout, before narration.
|
||||
type worldBossBoutResult struct {
|
||||
Damage int
|
||||
Remaining int
|
||||
Killed bool
|
||||
Battered bool
|
||||
Combat CombatResult
|
||||
}
|
||||
|
||||
// resolveWorldBossBout runs the arena-style fight, applies the real HP cost with
|
||||
// the no-death floor, subtracts the damage dealt from the shared pool, and
|
||||
// records the contribution. It performs no DM or games-room I/O, so it is the
|
||||
// testable core of fightWorldBoss.
|
||||
func (p *AdventurePlugin) resolveWorldBossBout(userID id.UserID, boss *worldBossState, dateKey string) (worldBossBoutResult, error) {
|
||||
monster := worldBossTemplate(boss.Name, boss.Tier)
|
||||
result, err := p.runZoneCombat(userID, monster, boss.Tier, bossCombatPhases, 50)
|
||||
if err != nil {
|
||||
return worldBossBoutResult{}, err
|
||||
}
|
||||
|
||||
// No death / no hospital: floor the fighter at 1 HP so a loss never reads as
|
||||
// a corpse. runZoneCombat already persisted the real HP cost.
|
||||
battered := worldBossFloorHP(userID)
|
||||
|
||||
dmg := result.EnemyEntryHP - result.EnemyEndHP
|
||||
if dmg < 0 {
|
||||
dmg = 0
|
||||
}
|
||||
// Record the contribution (which also sets the once-per-day gate) BEFORE
|
||||
// draining the shared pool. If this write fails we must not have already
|
||||
// drained the pool — otherwise the player could refight a pool they emptied
|
||||
// and lose the credit for it. A hard error here aborts the bout; the HP cost
|
||||
// was already paid, but the pool is untouched and the player can retry.
|
||||
if err := upsertWorldBossContrib(boss.ID, userID, dmg, dateKey); err != nil {
|
||||
return worldBossBoutResult{}, err
|
||||
}
|
||||
remaining, killed, err := applyWorldBossDamage(boss.ID, dmg)
|
||||
if err != nil {
|
||||
return worldBossBoutResult{}, err
|
||||
}
|
||||
return worldBossBoutResult{
|
||||
Damage: dmg, Remaining: remaining, Killed: killed, Battered: battered, Combat: result,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// worldBossFloorHP raises a player to 1 HP if the bout left them at zero, and
|
||||
// reports whether it had to. Keeps "no death" honest without undoing the real
|
||||
// HP cost of a bout the player mostly survived.
|
||||
func worldBossFloorHP(userID id.UserID) bool {
|
||||
cur, _ := dndHPSnapshot(userID)
|
||||
if cur > 0 {
|
||||
return false
|
||||
}
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE dnd_character SET hp_current = 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?`,
|
||||
string(userID)); err != nil {
|
||||
slog.Warn("worldboss: hp floor failed", "user", userID, "err", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// worldBossStatusDM renders the current Siege board to the caller.
|
||||
func (p *AdventurePlugin) worldBossStatusDM(ctx MessageContext) error {
|
||||
boss, err := loadActiveWorldBoss()
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Something went wrong reaching the Siege. Try again in a moment.")
|
||||
}
|
||||
if boss == nil {
|
||||
return p.SendDM(ctx.Sender,
|
||||
"No Siege is camped outside town right now. The next one arrives at the start of the month.")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
pct := 0
|
||||
if boss.HPMax > 0 {
|
||||
pct = boss.HPCurrent * 100 / boss.HPMax
|
||||
}
|
||||
fmt.Fprintf(&sb, "⚔️ **The Siege — %s** (Tier %d)\n", boss.Name, boss.Tier)
|
||||
fmt.Fprintf(&sb, "Pool: **%s / %s HP** (%d%%)\n", groupInt(boss.HPCurrent), groupInt(boss.HPMax), pct)
|
||||
if left := time.Until(boss.EndsAt); left > 0 {
|
||||
h := int(left.Hours())
|
||||
m := int(left.Minutes()) % 60
|
||||
fmt.Fprintf(&sb, "Time left: **%dh %dm**\n", h, m)
|
||||
}
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
if c, _ := loadWorldBossContrib(boss.ID, ctx.Sender); c != nil {
|
||||
line := fmt.Sprintf("\nYou: %d fight(s), %s damage dealt.", c.Fights, groupInt(c.Damage))
|
||||
if c.LastFightDate == today {
|
||||
line += " Your bout is spent for today."
|
||||
} else {
|
||||
line += " Your bout is ready — `!adventure worldboss fight`."
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
} else {
|
||||
sb.WriteString("\nYou haven't fought yet — `!adventure worldboss fight`.\n")
|
||||
}
|
||||
|
||||
if contribs, _ := loadWorldBossContribs(boss.ID); len(contribs) > 0 {
|
||||
sb.WriteString("\n**Top of the muster:**\n")
|
||||
for i, c := range contribs {
|
||||
if i >= 5 {
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(&sb, "%d. %s — %d fight(s)\n", i+1, p.DisplayName(c.UserID), c.Fights)
|
||||
}
|
||||
}
|
||||
return p.SendDM(ctx.Sender, sb.String())
|
||||
}
|
||||
417
internal/plugin/adventure_worldboss_test.go
Normal file
417
internal/plugin/adventure_worldboss_test.go
Normal file
@@ -0,0 +1,417 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func newWorldBossTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
func TestWorldBossName_DeterministicAndInPool(t *testing.T) {
|
||||
first := worldBossNameFor("2026-07")
|
||||
if again := worldBossNameFor("2026-07"); again != first {
|
||||
t.Errorf("name not deterministic: %q vs %q", first, again)
|
||||
}
|
||||
inPool := false
|
||||
for _, n := range worldBossNames {
|
||||
if n == first {
|
||||
inPool = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inPool {
|
||||
t.Errorf("generated name %q not in worldBossNames pool", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMedianInt(t *testing.T) {
|
||||
cases := []struct {
|
||||
in []int
|
||||
want int
|
||||
}{
|
||||
{nil, 0},
|
||||
{[]int{5}, 5},
|
||||
{[]int{3, 1, 2}, 2}, // odd, unsorted
|
||||
{[]int{4, 2, 8, 6}, 5}, // even → mean of the two middles (4,6)
|
||||
{[]int{10, 10, 10, 10}, 10}, // even, all equal
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := medianInt(c.in); got != c.want {
|
||||
t.Errorf("medianInt(%v) = %d, want %d", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTierForCombinedLevel(t *testing.T) {
|
||||
cases := []struct{ level, want int }{
|
||||
{0, worldBossMinTier},
|
||||
{11, worldBossMinTier},
|
||||
{12, 4},
|
||||
{20, 4},
|
||||
{21, 5},
|
||||
{99, 5},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := tierForCombinedLevel(c.level); got != c.want {
|
||||
t.Errorf("tierForCombinedLevel(%d) = %d, want %d", c.level, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorldBoss_InsertLoadRoundTrip(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
id0, err := insertWorldBoss("Gorloth", 5, 2400, now, now.Add(worldBossWindow))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active, err := loadActiveWorldBoss()
|
||||
if err != nil || active == nil {
|
||||
t.Fatalf("loadActiveWorldBoss: %v (nil=%v)", err, active == nil)
|
||||
}
|
||||
if active.ID != id0 || active.Name != "Gorloth" || active.Tier != 5 ||
|
||||
active.HPMax != 2400 || active.HPCurrent != 2400 || active.Status != "active" {
|
||||
t.Errorf("round-trip mismatch: %+v", active)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWorldBossDamage_ClampsAndFells(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
bossID, err := insertWorldBoss("Kravok", 3, 100, now, now.Add(worldBossWindow))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rem, killed, err := applyWorldBossDamage(bossID, 40)
|
||||
if err != nil || rem != 60 || killed {
|
||||
t.Fatalf("first hit: rem=%d killed=%v err=%v (want 60,false)", rem, killed, err)
|
||||
}
|
||||
|
||||
// Overkill clamps at 0 and reports the fell.
|
||||
rem, killed, err = applyWorldBossDamage(bossID, 999)
|
||||
if err != nil || rem != 0 || !killed {
|
||||
t.Fatalf("overkill: rem=%d killed=%v err=%v (want 0,true)", rem, killed, err)
|
||||
}
|
||||
|
||||
// Negative damage is treated as zero.
|
||||
if rem, _, err := applyWorldBossDamage(bossID, -5); err != nil || rem != 0 {
|
||||
t.Fatalf("negative: rem=%d err=%v", rem, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetWorldBossStatus_ResolvesOnce(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
bossID, err := insertWorldBoss("Ymirok", 4, 500, now, now.Add(worldBossWindow))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, err := setWorldBossStatus(bossID, "defeated"); err != nil || !ok {
|
||||
t.Fatalf("first close-out: ok=%v err=%v (want true)", ok, err)
|
||||
}
|
||||
// A second close-out is a no-op — the boss is no longer active.
|
||||
if ok, err := setWorldBossStatus(bossID, "survived"); err != nil || ok {
|
||||
t.Fatalf("double close-out: ok=%v err=%v (want false)", ok, err)
|
||||
}
|
||||
// And damage no longer lands on a resolved boss.
|
||||
if rem, killed, _ := applyWorldBossDamage(bossID, 500); rem != 500 || killed {
|
||||
t.Errorf("damage on resolved boss changed pool: rem=%d killed=%v", rem, killed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorldBossContrib_UpsertAccumulates(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
user := id.UserID("@a:test.invalid")
|
||||
if err := upsertWorldBossContrib(1, user, 30, "2026-07-01"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := upsertWorldBossContrib(1, user, 45, "2026-07-02"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := loadWorldBossContrib(1, user)
|
||||
if err != nil || c == nil {
|
||||
t.Fatalf("load: %v (nil=%v)", err, c == nil)
|
||||
}
|
||||
if c.Fights != 2 || c.Damage != 75 || c.LastFightDate != "2026-07-02" {
|
||||
t.Errorf("accumulate mismatch: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWorldBossContribs_OrderedByFights(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
a := id.UserID("@a:test.invalid")
|
||||
b := id.UserID("@b:test.invalid")
|
||||
// a: 1 fight; b: 2 fights → b sorts first.
|
||||
upsertWorldBossContrib(7, a, 100, "2026-07-01")
|
||||
upsertWorldBossContrib(7, b, 10, "2026-07-01")
|
||||
upsertWorldBossContrib(7, b, 10, "2026-07-02")
|
||||
got, err := loadWorldBossContribs(7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || got[0].UserID != b || got[1].UserID != a {
|
||||
t.Errorf("order = %+v, want b then a", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeWorldBossPayouts_ScalesByFights(t *testing.T) {
|
||||
contribs := []worldBossContrib{
|
||||
{UserID: "@a:t", Fights: 3, Damage: 999},
|
||||
{UserID: "@b:t", Fights: 1, Damage: 10},
|
||||
{UserID: "@c:t", Fights: 0, Damage: 0}, // no fights → no payout
|
||||
}
|
||||
pays := computeWorldBossPayouts(contribs, 1000)
|
||||
if len(pays) != 2 {
|
||||
t.Fatalf("got %d payouts, want 2 (zero-fight excluded)", len(pays))
|
||||
}
|
||||
if pays[0].Euro != 3000 || pays[1].Euro != 1000 {
|
||||
t.Errorf("payouts = %d,%d want 3000,1000", pays[0].Euro, pays[1].Euro)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorldBossSpawnPlan_SizesToActiveTown seeds three active max-level players
|
||||
// and checks the boss is T5 with a pool scaled to the turnout.
|
||||
func TestWorldBossSpawnPlan_SizesToActiveTown(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
for _, u := range []string{"@a:test.invalid", "@b:test.invalid", "@c:test.invalid"} {
|
||||
c := &AdventureCharacter{UserID: id.UserID(u), DisplayName: "P", Alive: true,
|
||||
ForagingSkill: 30, CreatedAt: time.Now().UTC()}
|
||||
if err := saveAdvCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Get().Exec(
|
||||
`INSERT INTO daily_activity (user_id, date, message_count) VALUES (?, ?, 1)`,
|
||||
u, today); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tier, hpMax, activeN := worldBossSpawnPlan()
|
||||
if activeN != 3 {
|
||||
t.Errorf("activeN = %d, want 3", activeN)
|
||||
}
|
||||
if tier != 5 {
|
||||
t.Errorf("tier = %d, want 5 (combined >= 21)", tier)
|
||||
}
|
||||
// bouts = 3 × 2.0 = 6; perBout at T5 = 400 → pool 2400.
|
||||
perBout, _, _, _, _ := arenaTierBaseStats(5)
|
||||
if want := perBout * 6; hpMax != want {
|
||||
t.Errorf("hpMax = %d, want %d", hpMax, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorldBossSpawnPlan_EmptyTownGetsFloorBoss: no active players still yields a
|
||||
// beatable floor boss so a manual spawn works.
|
||||
func TestWorldBossSpawnPlan_EmptyTownGetsFloorBoss(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
tier, hpMax, activeN := worldBossSpawnPlan()
|
||||
if activeN != 0 {
|
||||
t.Errorf("activeN = %d, want 0", activeN)
|
||||
}
|
||||
if tier != worldBossMinTier {
|
||||
t.Errorf("tier = %d, want floor %d", tier, worldBossMinTier)
|
||||
}
|
||||
perBout, _, _, _, _ := arenaTierBaseStats(worldBossMinTier)
|
||||
if want := perBout * worldBossMinBouts; hpMax != want {
|
||||
t.Errorf("hpMax = %d, want %d (min bouts floor)", hpMax, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpawnWorldBoss_RefusesWhenOneIsActive(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
first, err := p.spawnWorldBoss("2026-07")
|
||||
if err != nil || first == nil {
|
||||
t.Fatalf("first spawn: %v (nil=%v)", err, first == nil)
|
||||
}
|
||||
second, err := p.spawnWorldBoss("2026-07")
|
||||
if err == nil {
|
||||
t.Error("second spawn should refuse while one is active")
|
||||
}
|
||||
if second == nil || second.ID != first.ID {
|
||||
t.Error("refusal should return the existing active boss")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveWorldBossSurvived_DebitsPot exercises the survive path end to end:
|
||||
// the boss closes out and the pot loses its tribute.
|
||||
func TestResolveWorldBossSurvived_DebitsPot(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
communityPotAdd(1000)
|
||||
now := time.Now().UTC()
|
||||
bossID, err := insertWorldBoss("Vornath", 5, 2400, now.Add(-worldBossWindow), now.Add(-time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
boss, _ := loadWorldBoss(bossID)
|
||||
p := &AdventurePlugin{}
|
||||
p.resolveWorldBossSurvived(boss)
|
||||
|
||||
after, _ := loadWorldBoss(bossID)
|
||||
if after.Status != "survived" {
|
||||
t.Errorf("status = %q, want survived", after.Status)
|
||||
}
|
||||
// tribute = 20% of 1000 = 200 → pot left with 800.
|
||||
if bal := communityPotBalance(); bal != 800 {
|
||||
t.Errorf("pot = %d, want 800 after 20%% tribute", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// ── W2: the bout ─────────────────────────────────────────────────────────────
|
||||
|
||||
func fightableChar(t *testing.T, uid id.UserID) {
|
||||
t.Helper()
|
||||
if err := createAdvCharacter(uid, "boxer"); err != nil {
|
||||
t.Fatalf("createAdvCharacter: %v", err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 12,
|
||||
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
|
||||
HPMax: 120, HPCurrent: 120, ArmorClass: 18,
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatalf("SaveDnDCharacter: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorldBossFloorHP(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
uid := id.UserID("@floor:test.invalid")
|
||||
fightableChar(t, uid)
|
||||
if worldBossFloorHP(uid) {
|
||||
t.Error("floor should be a no-op above 0 HP")
|
||||
}
|
||||
if _, err := db.Get().Exec(`UPDATE dnd_character SET hp_current = 0 WHERE user_id = ?`, string(uid)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !worldBossFloorHP(uid) {
|
||||
t.Error("floor should raise a 0-HP fighter")
|
||||
}
|
||||
if cur, _ := dndHPSnapshot(uid); cur != 1 {
|
||||
t.Errorf("hp after floor = %d, want 1", cur)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWorldBossBout_SubtractsAndRecords(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
uid := id.UserID("@bout:test.invalid")
|
||||
fightableChar(t, uid)
|
||||
now := time.Now().UTC()
|
||||
// Big pool so one bout can only chip it — assert the partial subtract + contrib.
|
||||
bossID, err := insertWorldBoss("Kravok", 3, 100000, now, now.Add(worldBossWindow))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
boss, _ := loadWorldBoss(bossID)
|
||||
p := &AdventurePlugin{}
|
||||
bout, err := p.resolveWorldBossBout(uid, boss, "2026-07-05")
|
||||
if err != nil {
|
||||
t.Fatalf("bout: %v", err)
|
||||
}
|
||||
if bout.Damage < 0 {
|
||||
t.Errorf("damage negative: %d", bout.Damage)
|
||||
}
|
||||
if bout.Killed {
|
||||
t.Error("a 100k pool should survive one bout")
|
||||
}
|
||||
if bout.Remaining != 100000-bout.Damage {
|
||||
t.Errorf("remaining %d != max-dmg %d", bout.Remaining, 100000-bout.Damage)
|
||||
}
|
||||
c, _ := loadWorldBossContrib(bossID, uid)
|
||||
if c == nil || c.Fights != 1 || c.Damage != bout.Damage || c.LastFightDate != "2026-07-05" {
|
||||
t.Errorf("contrib mismatch: %+v (bout dmg %d)", c, bout.Damage)
|
||||
}
|
||||
if !worldBossBoutUsedToday(bossID, uid, "2026-07-05") {
|
||||
t.Error("bout should read as used for that day")
|
||||
}
|
||||
if worldBossBoutUsedToday(bossID, uid, "2026-07-06") {
|
||||
t.Error("a different day should still be available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWorldBossBout_FellsTinyPool(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
uid := id.UserID("@kill:test.invalid")
|
||||
fightableChar(t, uid)
|
||||
now := time.Now().UTC()
|
||||
bossID, err := insertWorldBoss("Wisp", 3, 1, now, now.Add(worldBossWindow))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
boss, _ := loadWorldBoss(bossID)
|
||||
p := &AdventurePlugin{}
|
||||
bout, err := p.resolveWorldBossBout(uid, boss, "2026-07-05")
|
||||
if err != nil {
|
||||
t.Fatalf("bout: %v", err)
|
||||
}
|
||||
// A L12 fighter lands at least one blow on a T3 dummy over a full boss fight,
|
||||
// felling a 1-HP pool.
|
||||
if !bout.Killed || bout.Remaining != 0 {
|
||||
t.Errorf("tiny pool not felled: killed=%v remaining=%d dmg=%d", bout.Killed, bout.Remaining, bout.Damage)
|
||||
}
|
||||
// Resolution is the caller's job — the bout leaves the boss active.
|
||||
after, _ := loadWorldBoss(bossID)
|
||||
if after.Status != "active" {
|
||||
t.Errorf("bout should not self-resolve; status=%q", after.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorldBossTick_ResolvesZeroHPBossAsDefeated: a killing blow that committed
|
||||
// the pool to 0 but (crash/redeploy) never resolved the status must be resolved
|
||||
// as DEFEATED by the ticker net — never fall through to the survive/pot-debit
|
||||
// path. No contributors here, so resolution never touches euro.
|
||||
func TestWorldBossTick_ResolvesZeroHPBossAsDefeated(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
communityPotAdd(1000)
|
||||
bossID, err := insertWorldBoss("Ghost", 3, 100, now, now.Add(worldBossWindow))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Get().Exec(`UPDATE world_boss SET hp_current = 0 WHERE id = ?`, bossID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &AdventurePlugin{}
|
||||
p.worldBossTick()
|
||||
after, _ := loadWorldBoss(bossID)
|
||||
if after.Status != "defeated" {
|
||||
t.Errorf("status = %q, want defeated (ticker safety net)", after.Status)
|
||||
}
|
||||
if bal := communityPotBalance(); bal != 1000 {
|
||||
t.Errorf("pot = %d, want 1000 — a defeat mints, it must not debit the pot", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorldBossTick_ResolvesLapsedBossAsSurvived: a boss whose window lapsed with
|
||||
// the pool still up survives and loots the pot.
|
||||
func TestWorldBossTick_ResolvesLapsedBossAsSurvived(t *testing.T) {
|
||||
newWorldBossTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
communityPotAdd(1000)
|
||||
bossID, err := insertWorldBoss("Titan", 4, 500, now.Add(-worldBossWindow), now.Add(-time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := &AdventurePlugin{}
|
||||
p.worldBossTick()
|
||||
after, _ := loadWorldBoss(bossID)
|
||||
if after.Status != "survived" {
|
||||
t.Errorf("status = %q, want survived", after.Status)
|
||||
}
|
||||
if bal := communityPotBalance(); bal != 800 {
|
||||
t.Errorf("pot = %d, want 800 after 20%% tribute", bal)
|
||||
}
|
||||
}
|
||||
227
internal/plugin/bootstrap_pete_news.go
Normal file
227
internal/plugin/bootstrap_pete_news.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// bootstrapPeteNewsBackfill replays the back-catalogue through the news emit
|
||||
// path the first time the seam comes up enabled, so a launch doesn't open onto
|
||||
// an empty section. It covers the three tractable, high-signal sources:
|
||||
//
|
||||
// 1. Realm-first zone clears — the earliest boss-defeated run of each zone. This
|
||||
// also SEEDS news_realm_firsts, so a later live clear of an already-cleared
|
||||
// zone tiers as a BULLETIN repeat, not a mis-announced "first ever".
|
||||
// 2. Deaths — every player_meta row that carries a death (graveyard content).
|
||||
// 3. Single-holder achievements — rarity-gated to one holder, matching the live
|
||||
// milestone rule so routine unlocks don't flood the backlog.
|
||||
//
|
||||
// Backfilled facts are backdated to when they happened and marked NoPush (no
|
||||
// web-push spam on launch). GUIDs are deterministic (keyed on the historical
|
||||
// timestamp), so a re-run — or a later live emit of the same event — dedups on
|
||||
// Pete's side. Guarded one-shot; kept (never deleted at close-out) so a fresh
|
||||
// deploy doesn't ghost-town again. See pete_adventure_news_plan.md gap #7.
|
||||
func (p *AdventurePlugin) bootstrapPeteNewsBackfill() {
|
||||
const jobName = "pete_news_backfill_v1"
|
||||
if db.JobCompleted(jobName, "once") {
|
||||
return
|
||||
}
|
||||
// The backfill IS the launch content, so it should fire the first boot the
|
||||
// seam is actually live — not get silently consumed on a boot where emission
|
||||
// is off. Leave the job unmarked until both switches are on.
|
||||
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||
return
|
||||
}
|
||||
|
||||
firsts := p.backfillZoneFirsts()
|
||||
deaths := backfillDeaths()
|
||||
achv := p.backfillSoloAchievements()
|
||||
|
||||
db.MarkJobCompleted(jobName, "once")
|
||||
slog.Warn("bootstrap: pete news backfill complete",
|
||||
"zone_firsts", firsts, "deaths", deaths, "achievements", achv)
|
||||
}
|
||||
|
||||
// backfillZoneFirsts seeds news_realm_firsts from history and emits one PRIORITY
|
||||
// realm-first dispatch per zone, attributed to its earliest boss-defeating
|
||||
// clearer. SQLite returns the user_id from the same row as MIN(completed_at)
|
||||
// (bare-column min/max rule), so the (zone, first clearer, time) triple is
|
||||
// consistent. Returns the count emitted.
|
||||
func (p *AdventurePlugin) backfillZoneFirsts() int {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT zone_id, user_id, MIN(completed_at)
|
||||
FROM dnd_zone_run
|
||||
WHERE boss_defeated = 1 AND completed_at IS NOT NULL AND abandoned = 0
|
||||
GROUP BY zone_id`)
|
||||
if err != nil {
|
||||
slog.Error("backfill: zone-firsts query", "err", err)
|
||||
return 0
|
||||
}
|
||||
type first struct {
|
||||
zoneID, userID, completedAt string
|
||||
}
|
||||
var firsts []first
|
||||
for rows.Next() {
|
||||
var f first
|
||||
if err := rows.Scan(&f.zoneID, &f.userID, &f.completedAt); err != nil {
|
||||
slog.Error("backfill: zone-firsts scan", "err", err)
|
||||
continue
|
||||
}
|
||||
firsts = append(firsts, f)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
n := 0
|
||||
for _, f := range firsts {
|
||||
uid := id.UserID(f.userID)
|
||||
// Seed the ledger regardless of whether we can render a dispatch, so the
|
||||
// realm-first tiering is correct even for an unnamed straggler.
|
||||
claimRealmFirst("zone", f.zoneID)
|
||||
|
||||
name := charName(uid)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
ts, ok := parseSQLiteTime(f.completedAt)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
zone := zoneOrFallback(ZoneID(f.zoneID))
|
||||
lvl := charLevel(uid)
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("zone_first:%s:%s:%d", eventToken(uid, fmt.Sprintf("%s:%d", f.zoneID, ts.Unix())), f.zoneID, ts.Unix()),
|
||||
EventType: "zone_first",
|
||||
Tier: "priority",
|
||||
Subject: name,
|
||||
Zone: zone.Display,
|
||||
Boss: zone.Boss.Name,
|
||||
Level: lvl,
|
||||
Outcome: "cleared",
|
||||
OccurredAt: ts.Unix(),
|
||||
NoPush: true,
|
||||
}, uid, "")
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// backfillDeaths emits one death dispatch per player_meta death record. Deaths
|
||||
// are day-granular (last_death_date), so the dispatch is backdated to that day.
|
||||
func backfillDeaths() int {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT user_id, last_death_date, death_location
|
||||
FROM player_meta
|
||||
WHERE last_death_date != ''`)
|
||||
if err != nil {
|
||||
slog.Error("backfill: deaths query", "err", err)
|
||||
return 0
|
||||
}
|
||||
type death struct {
|
||||
userID, date, location string
|
||||
}
|
||||
var deaths []death
|
||||
for rows.Next() {
|
||||
var d death
|
||||
if err := rows.Scan(&d.userID, &d.date, &d.location); err != nil {
|
||||
slog.Error("backfill: deaths scan", "err", err)
|
||||
continue
|
||||
}
|
||||
deaths = append(deaths, d)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
n := 0
|
||||
for _, d := range deaths {
|
||||
uid := id.UserID(d.userID)
|
||||
name := charName(uid)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
day, err := time.Parse("2006-01-02", d.date)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lvl := charLevel(uid)
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("death:%s:%s", eventToken(uid, d.date), d.date),
|
||||
EventType: "death",
|
||||
Tier: "priority",
|
||||
Subject: name,
|
||||
Zone: d.location,
|
||||
Level: lvl,
|
||||
Outcome: "lost",
|
||||
OccurredAt: day.UTC().Unix(),
|
||||
NoPush: true,
|
||||
}, uid, "")
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// backfillSoloAchievements emits a milestone dispatch for every achievement held
|
||||
// by exactly one player — the same rarity gate the live path uses — so the
|
||||
// backlog carries the genuinely distinctive unlocks, not routine ones.
|
||||
func (p *AdventurePlugin) backfillSoloAchievements() int {
|
||||
rows, err := db.Get().Query(
|
||||
`SELECT achievement_id, user_id, unlocked_at
|
||||
FROM achievements
|
||||
GROUP BY achievement_id
|
||||
HAVING COUNT(*) = 1`)
|
||||
if err != nil {
|
||||
slog.Error("backfill: achievements query", "err", err)
|
||||
return 0
|
||||
}
|
||||
type solo struct {
|
||||
achID, userID string
|
||||
unlockedAt int64
|
||||
}
|
||||
var solos []solo
|
||||
for rows.Next() {
|
||||
var s solo
|
||||
if err := rows.Scan(&s.achID, &s.userID, &s.unlockedAt); err != nil {
|
||||
slog.Error("backfill: achievements scan", "err", err)
|
||||
continue
|
||||
}
|
||||
solos = append(solos, s)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
n := 0
|
||||
for _, s := range solos {
|
||||
uid := id.UserID(s.userID)
|
||||
name := charName(uid)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
label := s.achID
|
||||
if p.achievements != nil {
|
||||
for _, a := range p.achievements.achievements {
|
||||
if a.ID == s.achID {
|
||||
label = a.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
ts := s.unlockedAt
|
||||
if ts == 0 {
|
||||
ts = nowUnix()
|
||||
}
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("milestone:%s:%s", eventToken(uid, s.achID), s.achID),
|
||||
EventType: "milestone",
|
||||
Tier: "bulletin",
|
||||
Subject: name,
|
||||
Milestone: label,
|
||||
OccurredAt: ts,
|
||||
NoPush: true,
|
||||
}, uid, "")
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -171,7 +171,12 @@ func (p *AdventurePlugin) loadCombatBonuses(userID id.UserID, char *AdventureCha
|
||||
treasures, _ := loadAdvTreasureBonuses(userID)
|
||||
buffs, _ := loadAdvActiveBuffs(userID)
|
||||
hasGrudge := char.GrudgeLocation != ""
|
||||
return computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
|
||||
b := computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
|
||||
// N7/B2 — renown pays out on loot/XP here too. Safe despite this feeding
|
||||
// combat_stats: applyRenownBonuses touches only LootQuality/XPMultiplier,
|
||||
// which combat stat derivation never reads (see adventure_renown.go).
|
||||
applyRenownBonuses(b, char.RenownLevel())
|
||||
return b
|
||||
}
|
||||
|
||||
// loadConsumableInventory scans inventory for items matching consumable definitions.
|
||||
@@ -185,7 +190,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)
|
||||
craftResults, updatedItems := autoCraftConsumables(userID, items, char.ForagingSkill, homeWorkshopCraftBonus(char.HouseTier))
|
||||
if len(craftResults) > 0 {
|
||||
items = updatedItems
|
||||
for _, cr := range craftResults {
|
||||
|
||||
@@ -381,6 +381,9 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
|
||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, !elite, elite); drop != "" {
|
||||
b.WriteString(drop + "\n")
|
||||
}
|
||||
if !elite {
|
||||
writeBossEpilogue(&b, zone.ID)
|
||||
}
|
||||
if bossOnExpedition {
|
||||
// The boss is the expedition's climax. Frame the close-out as
|
||||
// the win rather than a "keep walking" nudge. One more
|
||||
|
||||
@@ -118,6 +118,9 @@ func (p *AdventurePlugin) finishPartyWin(
|
||||
b.WriteString(drop + "\n")
|
||||
}
|
||||
}
|
||||
if !elite {
|
||||
writeBossEpilogue(&b, zone.ID)
|
||||
}
|
||||
switch {
|
||||
case bossOnExpedition && seat == 0:
|
||||
b.WriteString("🎉 **Zone cleared — the expedition is won.** `!expedition run` to march out and claim your spoils.")
|
||||
|
||||
@@ -157,12 +157,33 @@ func DerivePlayerStats(
|
||||
}
|
||||
}
|
||||
|
||||
// Pet modifiers
|
||||
// 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)
|
||||
if char.HasPet() {
|
||||
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
|
||||
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
|
||||
}
|
||||
if char.PetMorningDefense {
|
||||
mods.DamageReduct *= 0.95 // 5% less damage
|
||||
|
||||
@@ -332,8 +332,22 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// sqlExecer is the subset of *sql.DB / *sql.Tx that saveDnDCharacterExec needs,
|
||||
// so the upsert can run either standalone or inside a caller's transaction.
|
||||
type sqlExecer interface {
|
||||
Exec(query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
// SaveDnDCharacter upserts the row.
|
||||
func SaveDnDCharacter(c *DnDCharacter) error {
|
||||
return saveDnDCharacterExec(db.Get(), c)
|
||||
}
|
||||
|
||||
// saveDnDCharacterExec runs the dnd_character upsert against any executor. Used
|
||||
// directly by SaveDnDCharacter and inside grantDnDXP's overflow→renown
|
||||
// transaction (adventure_renown.go), so the character save and the renown
|
||||
// credit commit atomically.
|
||||
func saveDnDCharacterExec(ex sqlExecer, c *DnDCharacter) error {
|
||||
pending := 0
|
||||
if c.PendingSetup {
|
||||
pending = 1
|
||||
@@ -346,7 +360,7 @@ func SaveDnDCharacter(c *DnDCharacter) error {
|
||||
if c.OnboardingSent {
|
||||
onboard = 1
|
||||
}
|
||||
_, err := db.Get().Exec(`
|
||||
_, err := ex.Exec(`
|
||||
INSERT INTO dnd_character (user_id, race, class, dnd_level, dnd_xp,
|
||||
str_score, dex_score, con_score, int_score, wis_score, cha_score,
|
||||
hp_current, hp_max, temp_hp, armor_class,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
@@ -305,6 +307,13 @@ 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
|
||||
}
|
||||
@@ -481,10 +490,23 @@ 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 || c.HPCurrent >= c.HPMax {
|
||||
if c == nil || c.HPMax <= 0 {
|
||||
return
|
||||
}
|
||||
startHP := c.HPCurrent + stats.HPBonus
|
||||
// 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
|
||||
if startHP < 1 {
|
||||
startHP = 1
|
||||
}
|
||||
@@ -554,4 +576,32 @@ func markAdventureDead(userID id.UserID, source, location string) {
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("dnd: kill on combat loss", "user", userID, "err", err)
|
||||
}
|
||||
emitDeathNews(userID, location)
|
||||
}
|
||||
|
||||
// emitDeathNews files a PRIORITY death dispatch to Pete's adventure news. No-op
|
||||
// unless the seam is enabled. Uses the character name (never the Matrix handle);
|
||||
// skips silently if the name is unknown.
|
||||
func emitDeathNews(userID id.UserID, location string) {
|
||||
// Gate before the char lookups: markAdventureDead fires per-member on a
|
||||
// party wipe and in the sim harness, so skip the DB reads when disabled.
|
||||
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||
return
|
||||
}
|
||||
name := charName(userID)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
lvl := charLevel(userID)
|
||||
ts := nowUnix()
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("death:%s:%d", eventToken(userID, fmt.Sprintf("%d", ts)), ts),
|
||||
EventType: "death",
|
||||
Tier: "priority",
|
||||
Subject: name,
|
||||
Zone: location,
|
||||
Level: lvl,
|
||||
Outcome: "lost",
|
||||
OccurredAt: ts,
|
||||
}, userID, "")
|
||||
}
|
||||
|
||||
@@ -160,6 +160,9 @@ func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies Exp
|
||||
if isHol, _ := isHolidayToday(); isHol {
|
||||
startMood = 55
|
||||
}
|
||||
if b := activeOmen().StartMoodBonus; b > 0 { // N7/B3 the Omen
|
||||
startMood += b
|
||||
}
|
||||
exp := &Expedition{
|
||||
ID: newExpeditionID(),
|
||||
UserID: string(userID),
|
||||
|
||||
@@ -283,6 +283,12 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
formatRespecDuration(remaining)))
|
||||
}
|
||||
zoneTok, packTok := splitFirstWord(rest)
|
||||
// N5/D1c: the campaign finale is reached here but is not a normal zone — it
|
||||
// runs a single auto-resolved boss fight with its own unlock gate, no
|
||||
// supplies, no walk. Intercept before the zone/loadout machinery.
|
||||
if strings.EqualFold(zoneTok, "epilogue") {
|
||||
return p.handleEpilogueEncounter(ctx)
|
||||
}
|
||||
available := zonesForLevel(c.Level)
|
||||
zoneID, ok := resolveZoneInput(zoneTok, available)
|
||||
if !ok {
|
||||
@@ -378,6 +384,9 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
if isHol, _ := isHolidayToday(); isHol {
|
||||
suppliesPurchase.StandardPacks++
|
||||
}
|
||||
if pk := activeOmen().SupplyFreebiePacks; pk > 0 { // N7/B3 the Omen
|
||||
suppliesPurchase.StandardPacks += pk
|
||||
}
|
||||
supplies := makeSupplies(zone.Tier, suppliesPurchase)
|
||||
|
||||
// Debit coins; bail on debit failure (race / cap).
|
||||
|
||||
@@ -400,6 +400,9 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
||||
|
||||
line := pickMorningBriefing(e.CurrentDay)
|
||||
body := renderMorningBriefing(e, line, burn)
|
||||
if sl := p.shadowBriefingLine(e); sl != "" {
|
||||
body += "\n" + sl + "\n"
|
||||
}
|
||||
if restSummary != "" {
|
||||
body += "\n💤 _" + restSummary + "_\n"
|
||||
}
|
||||
@@ -513,6 +516,9 @@ func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBrief
|
||||
|
||||
line := pickMorningBriefing(e.CurrentDay)
|
||||
body := renderMorningBriefing(e, line, burn)
|
||||
if sl := p.shadowBriefingLine(e); sl != "" {
|
||||
body += "\n" + sl + "\n"
|
||||
}
|
||||
if forced {
|
||||
body += "\n_The autopilot stalled overnight; the day rolled over without rest._\n"
|
||||
}
|
||||
|
||||
@@ -170,7 +170,17 @@ func (p *AdventurePlugin) finalizeExpeditionOnZoneClear(userID id.UserID, runID
|
||||
exp.Status = ExpeditionStatusComplete
|
||||
_ = retireAllRegionRuns(exp)
|
||||
p.rollZoneTreasure(userID, exp.ZoneID, advTreasureWeightZoneClear)
|
||||
return p.AwardCompletionMilestones(exp, false)
|
||||
lines := p.AwardCompletionMilestones(exp, false)
|
||||
// N6/D3: the Shadow's payoff for this zone — a crow (player was first) or a
|
||||
// waiting journal page (the Shadow cleared it first). Appended after the
|
||||
// milestone lines so the campaign beat reads last.
|
||||
if sl := p.shadowOnPlayerZoneClear(userID, exp.ZoneID); sl != "" {
|
||||
lines = append(lines, sl)
|
||||
}
|
||||
// News: boss down means the zone is cleared. Realm-first → PRIORITY,
|
||||
// repeat → BULLETIN. No-op unless the Pete seam is enabled.
|
||||
emitZoneClearNews(userID, exp)
|
||||
return lines
|
||||
}
|
||||
|
||||
// midZoneRegionClear reports whether the just-completed run (runID) is the
|
||||
|
||||
@@ -463,6 +463,9 @@ func grantHarvestYield(userID id.UserID, res ZoneResource, qty int) error {
|
||||
if isHol, _ := isHolidayToday(); isHol {
|
||||
qty++
|
||||
}
|
||||
if b := activeOmen().HarvestYieldBonus; b > 0 { // N7/B3 the Omen
|
||||
qty += b
|
||||
}
|
||||
tier := zoneTierFromID(res.ZoneID)
|
||||
for i := 0; i < qty; i++ {
|
||||
item := AdvItem{
|
||||
|
||||
@@ -117,6 +117,14 @@ func applyDailyThreatDrift(e *Expedition) (int, string, error) {
|
||||
return 0, "", nil
|
||||
}
|
||||
delta, reason := dailyThreatDrift(e.DMMood)
|
||||
// N7/B3 the Omen — a "still waters" week subtracts from the daily threat
|
||||
// *rise* only (guarded on delta > 0), floored so the worst case is threat
|
||||
// holding steady for the day; it never turns a rise into active decay.
|
||||
if r := activeOmen().ThreatDriftReduce; r > 0 && delta > 0 {
|
||||
if delta -= r; delta < 0 {
|
||||
delta = 0
|
||||
}
|
||||
}
|
||||
if delta == 0 {
|
||||
return 0, reason, nil
|
||||
}
|
||||
|
||||
@@ -161,6 +161,16 @@ func (p *AdventurePlugin) handleDnDLevelCmd(ctx MessageContext) error {
|
||||
b.WriteString(fmt.Sprintf("⚔️ Level **%d** %s %s\n", c.Level, ri.Display, ci.Display))
|
||||
if c.Level >= dndMaxLevel {
|
||||
b.WriteString("XP: capped at L20.")
|
||||
if rl := advChar.RenownLevel(); rl > 0 {
|
||||
into, cost := renownXPIntoLevel(advChar.RenownXP)
|
||||
b.WriteString(fmt.Sprintf("\n✦ **Renown %d**", rl))
|
||||
if rank := renownRankFor(rl); rank != "" {
|
||||
b.WriteString(fmt.Sprintf(" — _%s_", rank))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\nRenown XP: %d / %d to Renown %d", into, cost, rl+1))
|
||||
} else {
|
||||
b.WriteString("\n_Overflow XP now earns **Renown** — prestige past the cap._")
|
||||
}
|
||||
} else {
|
||||
next := dndXPToNextLevel(c.Level)
|
||||
pct := int(100.0 * float64(c.XP) / float64(next))
|
||||
|
||||
@@ -222,8 +222,15 @@ 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 = 0
|
||||
c.TempHP = wellRestedTempHP(c.HPMax, restTier)
|
||||
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
|
||||
@@ -241,8 +248,9 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error {
|
||||
}
|
||||
markActedToday(ctx.Sender)
|
||||
_ = refreshAllResources(ctx.Sender)
|
||||
// Phase 9: spell slots refresh on long rest.
|
||||
_ = refreshSpellSlots(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: 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
|
||||
@@ -263,5 +271,13 @@ 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)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
@@ -290,6 +291,22 @@ func (p *AdventurePlugin) dndSetupConfirm(ctx MessageContext) error {
|
||||
// Idempotent — !setup confirm after a respec wipe will repopulate.
|
||||
_ = ensureSpellsForCharacter(c)
|
||||
|
||||
// A new character walked through the gates — file a welcome dispatch to
|
||||
// Pete. GUID keyed on the user alone so a later respec/re-confirm can't
|
||||
// re-announce the same arrival. No-op unless the seam is enabled.
|
||||
if name := charName(ctx.Sender); name != "" {
|
||||
ts := nowUnix()
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: "arrival:" + eventToken(ctx.Sender, "arrival"),
|
||||
EventType: "arrival",
|
||||
Tier: "bulletin",
|
||||
Subject: name,
|
||||
ClassRace: classRaceLabel(c),
|
||||
Level: c.Level,
|
||||
OccurredAt: ts,
|
||||
}, ctx.Sender, "")
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, renderSetupComplete(c))
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,20 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta,
|
||||
if adv != nil && adv.DisplayName != "" {
|
||||
name = adv.DisplayName
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", name, c.Level, ri.Display, ci.Display))
|
||||
renownLevel := 0
|
||||
if adv != nil {
|
||||
renownLevel = adv.RenownLevel()
|
||||
}
|
||||
nameLine := name
|
||||
if m := renownMarker(renownLevel); m != "" {
|
||||
nameLine = fmt.Sprintf("%s %s", name, m)
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", nameLine, c.Level, ri.Display, ci.Display))
|
||||
if renownLevel > 0 {
|
||||
if rank := renownRankFor(renownLevel); rank != "" {
|
||||
b.WriteString(fmt.Sprintf(" _Renown %d — %s_\n", renownLevel, rank))
|
||||
}
|
||||
}
|
||||
if c.Subclass != "" {
|
||||
if si, ok := subclassInfo(c.Subclass); ok {
|
||||
b.WriteString(fmt.Sprintf(" _%s_\n", si.Display))
|
||||
@@ -212,6 +225,9 @@ 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))
|
||||
}
|
||||
|
||||
@@ -564,6 +564,48 @@ 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
|
||||
|
||||
@@ -127,15 +127,26 @@ func (p *AdventurePlugin) grantDnDXP(userID id.UserID, amount int) ([]LevelUpEve
|
||||
events = append(events, LevelUpEvent{NewLevel: c.Level, HPGain: gain})
|
||||
}
|
||||
|
||||
// Cap at L20 — overflow XP is silently dropped.
|
||||
if c.Level >= dndMaxLevel {
|
||||
// N7/B2 Renown — persist the character; at the cap, overflow XP that used to
|
||||
// be dropped converts to Renown in the SAME transaction as the save, so a
|
||||
// crash can neither lose the overflow nor double-credit it on the next grant.
|
||||
// Renown is title + cosmetic + capped loot/XP perks, never combat stats, so
|
||||
// the balance corpus stays valid.
|
||||
var renownFrom, renownTo int
|
||||
if c.Level >= dndMaxLevel && c.XP > 0 {
|
||||
overflow := c.XP
|
||||
c.XP = 0
|
||||
}
|
||||
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
from, to, err := saveDnDCharacterWithOverflow(c, overflow)
|
||||
if err != nil {
|
||||
return events, err
|
||||
}
|
||||
renownFrom, renownTo = renownLevelFor(from), renownLevelFor(to)
|
||||
} else if err := SaveDnDCharacter(c); err != nil {
|
||||
return events, err
|
||||
}
|
||||
|
||||
p.announceRenown(userID, renownFrom, renownTo)
|
||||
|
||||
if len(events) > 0 {
|
||||
// Phase 10 SUB3a-ii — Battle Master Relentless (L15) raises the
|
||||
// superiority cap from 4 → 5; reconcile any level-gated subclass pool
|
||||
|
||||
@@ -988,6 +988,25 @@ func (p *AdventurePlugin) streamFlowThen(userID id.UserID, phaseMessages []strin
|
||||
// inter-phase delays — see resolveCombatRoom for the contract. For
|
||||
// non-combat rooms (entry, trap), phases is nil and outcome carries the
|
||||
// resolution narration.
|
||||
// currentSecretNode returns the run's current graph node when it is a secret
|
||||
// room, so resolveRoom can divert it to the treasure-cache resolver. Returns
|
||||
// false for a non-secret node, an unknown zone, or a legacy row that never got
|
||||
// a CurrentNode written (those pre-date branching graphs and have no secrets).
|
||||
func currentSecretNode(run *DungeonRun) (ZoneNode, bool) {
|
||||
if run.CurrentNode == "" {
|
||||
return ZoneNode{}, false
|
||||
}
|
||||
g, ok := loadZoneGraph(run.ZoneID)
|
||||
if !ok {
|
||||
return ZoneNode{}, false
|
||||
}
|
||||
n, ok := g.Nodes[run.CurrentNode]
|
||||
if !ok || n.Kind != NodeKindSecret {
|
||||
return ZoneNode{}, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, compact bool) (intro string, phases []string, outcome string, ended bool, err error) {
|
||||
// Revisit R2 — a cleared room stays cleared. Advancing out of a room the
|
||||
// player backtracked into must not re-roll its combat or re-arm its trap.
|
||||
@@ -1004,6 +1023,14 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo
|
||||
if run.RoomIsCleared(run.CurrentRoom) {
|
||||
return
|
||||
}
|
||||
// N5/D4 — a secret room is a no-combat treasure cache, not the exploration
|
||||
// fight its RoomType collapses to. Keyed off the graph node (which still
|
||||
// carries NodeKindSecret) rather than CurrentRoomType (which does not), so
|
||||
// it must be checked before the RoomType switch below.
|
||||
if node, ok := currentSecretNode(run); ok {
|
||||
outcome = p.resolveSecretRoom(userID, run, zone, node)
|
||||
return
|
||||
}
|
||||
switch run.CurrentRoomType() {
|
||||
case RoomEntry:
|
||||
return
|
||||
@@ -1103,6 +1130,13 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
||||
ob.WriteString("\n")
|
||||
ob.WriteString(line)
|
||||
}
|
||||
// D1b campaign capstone. The compact autopilot resolves boss rooms
|
||||
// itself (the primary long-expedition path), so the epilogue has to
|
||||
// fire here too — otherwise almost every player clears the boss
|
||||
// without ever seeing it.
|
||||
if isBoss {
|
||||
writeBossEpilogue(&ob, zone.ID)
|
||||
}
|
||||
outcome = ob.String()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -202,6 +202,52 @@ func scanMoodEventsFromEvents(runID string, events []CombatEvent) (nat20s, nat1s
|
||||
//
|
||||
// Higher-tier zones currently fall through to the pre-D2a flat-percent
|
||||
// nick — D3a/D4a will add Tier 2+ trap catalogs.
|
||||
// resolveSecretRoom resolves a NodeKindSecret room as a no-combat treasure
|
||||
// cache (N5/D4). Finding the secret — via the perception/stat check on the fork
|
||||
// that hangs it, or a cross-zone key — is the reward: a guaranteed journal page
|
||||
// (the Hollow King's fragments gather in hidden rooms), a LootBias-weighted
|
||||
// treasure roll, a guaranteed zone-tier consumable cache, and, for the two
|
||||
// key-bearing secrets, a cross-zone key. No enemy, no threat.
|
||||
//
|
||||
// LootBias has been authored on every secret node since the branching-zones
|
||||
// phase and was dead code until now; it finally scales the treasure weight here.
|
||||
func (p *AdventurePlugin) resolveSecretRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, node ZoneNode) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(secretRoomDiscoveryLine(node))
|
||||
|
||||
var rewards []string
|
||||
if line := p.grantJournalPage(userID, nil); line != "" {
|
||||
rewards = append(rewards, line)
|
||||
}
|
||||
if line := p.grantSecretRoomKey(userID, node); line != "" {
|
||||
rewards = append(rewards, line)
|
||||
}
|
||||
|
||||
// LootBias-weighted treasure roll. Floors at the elite weight so even the
|
||||
// leanest secret out-rolls a standard kill; the richest (abyss 3.0) still
|
||||
// rolls below a boss. checkTreasureDrop DMs the discovery itself on a hit,
|
||||
// exactly as a combat-room drop does, so it isn't folded into the outcome.
|
||||
weight := node.Content.LootBias
|
||||
if weight < advTreasureWeightElite {
|
||||
weight = advTreasureWeightElite
|
||||
}
|
||||
p.rollZoneTreasure(userID, zone.ID, weight)
|
||||
|
||||
// Guaranteed cache so the room always pays out something visible.
|
||||
for _, item := range consumableCache(int(zone.Tier), secretRoomCacheCount) {
|
||||
it := item
|
||||
if line := p.grantZoneItem(userID, &it, "🧪"); line != "" {
|
||||
rewards = append(rewards, line)
|
||||
}
|
||||
}
|
||||
|
||||
if len(rewards) > 0 {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(strings.Join(rewards, "\n"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) resolveTrapRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (damage int, narration string) {
|
||||
dndChar, _ := LoadDnDCharacter(userID)
|
||||
if dndChar == nil {
|
||||
|
||||
@@ -454,7 +454,11 @@ var advIngredientActivities = []AdvActivityType{
|
||||
// rollZoneIngredient draws one crafting ingredient from a random legacy
|
||||
// gathering table at the zone's tier. Returns nil on the common no-drop path.
|
||||
func rollZoneIngredient(zoneTier int) *AdvItem {
|
||||
if rand.Float64() >= advIngredientDropChance {
|
||||
chance := advIngredientDropChance
|
||||
if m := activeOmen().ConsumableChanceMult; m > 1.0 { // N7/B3 the Omen
|
||||
chance *= m
|
||||
}
|
||||
if rand.Float64() >= chance {
|
||||
return nil
|
||||
}
|
||||
act := advIngredientActivities[rand.IntN(len(advIngredientActivities))]
|
||||
@@ -517,6 +521,13 @@ func (p *AdventurePlugin) dropZoneLoot(userID id.UserID, zoneID ZoneID, monster
|
||||
extra = append(extra, line)
|
||||
}
|
||||
}
|
||||
// The Hollow King campaign (N5/D1). Elites carry the scattered pages; the
|
||||
// gating boss gets an epilogue instead (D1b), not a page.
|
||||
if isElite {
|
||||
if line := p.maybeDropJournalPage(userID, nil); line != "" {
|
||||
extra = append(extra, line)
|
||||
}
|
||||
}
|
||||
trailer := strings.Join(extra, "\n")
|
||||
|
||||
entry, tier, ok := rollZoneLoot(zoneID, monster.CR, isBoss, nil)
|
||||
|
||||
290
internal/plugin/exercise_prod_test.go
Normal file
290
internal/plugin/exercise_prod_test.go
Normal file
@@ -0,0 +1,290 @@
|
||||
//go:build prodexercise
|
||||
|
||||
package plugin
|
||||
|
||||
// Headless smoke-exercise of the N-series features (parties aside — those have
|
||||
// their own sim path) against a COPY of the prod DB. Nothing here touches the
|
||||
// live prod file: point GOGOBEE_PROD_DB_DIR at a directory holding a *copy* of
|
||||
// gogobee.db (+ optional -wal/-shm) and the test re-copies that into t.TempDir()
|
||||
// before opening it, so even the copy is left pristine.
|
||||
//
|
||||
// GOGOBEE_PROD_DB_DIR=/path/to/dbcopy \
|
||||
// go test -tags prodexercise -run TestExerciseNewFeaturesProd -v ./internal/plugin/
|
||||
//
|
||||
// Every outbound DM / room message a handler would have sent to Matrix is
|
||||
// captured via the existing MessageSink seam and dumped with t.Log, so the run
|
||||
// shows exactly what a player would see. Each feature is wrapped so a panic or
|
||||
// error in one is reported and the battery continues.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func TestExerciseNewFeaturesProd(t *testing.T) {
|
||||
srcDir := os.Getenv("GOGOBEE_PROD_DB_DIR")
|
||||
if srcDir == "" {
|
||||
t.Skip("set GOGOBEE_PROD_DB_DIR to a dir holding a COPY of gogobee.db")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(srcDir, "gogobee.db")); err != nil {
|
||||
t.Skipf("no gogobee.db in %s", srcDir)
|
||||
}
|
||||
|
||||
tmp := t.TempDir()
|
||||
for _, f := range []string{"gogobee.db", "gogobee.db-wal", "gogobee.db-shm"} {
|
||||
copyIfPresent(t, filepath.Join(srcDir, f), filepath.Join(tmp, f))
|
||||
}
|
||||
|
||||
db.Close()
|
||||
if err := db.Init(tmp); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
// Reproducibility: the weekly Omen keys off wall-clock time.Now(); pin it off
|
||||
// so the same DB copy exercises identically regardless of which week we run.
|
||||
simOmenDisabled = true
|
||||
simAutoArmEnabled = true
|
||||
|
||||
euro := NewEuroPlugin(nil)
|
||||
xp := NewXPPlugin(nil)
|
||||
p := NewAdventurePlugin(nil, euro, xp)
|
||||
sink := &captureSink{}
|
||||
p.Sink = sink
|
||||
mark := 0
|
||||
drain := func() {
|
||||
for _, m := range sink.msgs[mark:] {
|
||||
who := string(m.ToUser)
|
||||
if who == "" {
|
||||
who = "room:" + string(m.ToRoom)
|
||||
}
|
||||
t.Logf(" → [%s]\n%s", who, indent(m.Text))
|
||||
}
|
||||
mark = len(sink.msgs)
|
||||
}
|
||||
exercise := func(name string, fn func()) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Logf(" !! %s PANICKED: %v", name, r)
|
||||
mark = len(sink.msgs)
|
||||
}
|
||||
}()
|
||||
t.Logf("──────── %s ────────", name)
|
||||
fn()
|
||||
drain()
|
||||
}
|
||||
|
||||
chars := runnableChars(t)
|
||||
if len(chars) == 0 {
|
||||
t.Fatal("no runnable characters (dnd_character with class + adventure_characters row) in the DB copy")
|
||||
}
|
||||
t.Logf("runnable characters: %d", len(chars))
|
||||
for _, c := range chars {
|
||||
healToFull(t, c.uid)
|
||||
euro.Credit(c.uid, 5000, "exercise bankroll")
|
||||
t.Logf(" • %-28s %s L%d", c.uid, c.class, c.level)
|
||||
}
|
||||
ctxFor := func(uid id.UserID, body string) MessageContext {
|
||||
return MessageContext{Sender: uid, RoomID: id.RoomID("!exercise:sim"), EventID: "$ex", Body: body}
|
||||
}
|
||||
|
||||
// ── N7/B2 Renown & prestige ─────────────────────────────────────────────
|
||||
exercise("N7/B2 Renown — standing + prestige announce", func() {
|
||||
for _, c := range chars {
|
||||
xp, _ := loadRenownXP(c.uid)
|
||||
lvl := renownLevelForUser(c.uid)
|
||||
t.Logf(" %s: renownXP=%d renownLevel=%d rank=%q", c.uid, xp, lvl, renownRankFor(lvl))
|
||||
}
|
||||
// Push the first character up a renown level to fire the announce path.
|
||||
c0 := chars[0]
|
||||
before, after, err := addRenownXP(c0.uid, 500)
|
||||
if err != nil {
|
||||
t.Logf(" addRenownXP: %v", err)
|
||||
return
|
||||
}
|
||||
t.Logf(" %s renownXP %d→%d", c0.uid, before, after)
|
||||
p.announceRenown(c0.uid, renownLevelFor(before), renownLevelFor(after))
|
||||
})
|
||||
|
||||
// ── N6/C3 World Boss / Siege raid ───────────────────────────────────────
|
||||
exercise("N6/C3 World Boss — spawn, status, each fighter's bout", func() {
|
||||
boss, err := p.spawnWorldBoss("exercise-siege")
|
||||
if err != nil {
|
||||
t.Logf(" spawnWorldBoss: %v", err)
|
||||
}
|
||||
if boss != nil {
|
||||
t.Logf(" spawned %q tier=%d hp=%d", boss.Name, boss.Tier, boss.HPMax)
|
||||
}
|
||||
p.handleWorldBossCmd(ctxFor(chars[0].uid, ""), "status")
|
||||
for _, c := range chars {
|
||||
t.Logf(" -- %s takes a bout --", c.uid)
|
||||
p.handleWorldBossCmd(ctxFor(c.uid, ""), "fight")
|
||||
}
|
||||
t.Logf(" -- pool after the raid --")
|
||||
p.handleWorldBossCmd(ctxFor(chars[0].uid, ""), "status")
|
||||
})
|
||||
|
||||
// ── N6/C2 Duel (needs two eligible players) ─────────────────────────────
|
||||
exercise("N6/C2 Duel — challenge, accept, resolve", func() {
|
||||
if len(chars) < 2 {
|
||||
t.Log(" need 2 characters for a duel; skipping")
|
||||
return
|
||||
}
|
||||
a, b := chars[0], chars[1]
|
||||
p.handleDuelCmd(ctxFor(a.uid, ""), fmt.Sprintf("%s 100", b.uid))
|
||||
p.handleDuelCmd(ctxFor(b.uid, ""), "accept")
|
||||
})
|
||||
|
||||
// ── N6/D3 The Shadow ────────────────────────────────────────────────────
|
||||
exercise("N6/D3 Shadow — standing vs the rival", func() {
|
||||
for _, c := range chars {
|
||||
p.handleShadowCmd(ctxFor(c.uid, ""))
|
||||
}
|
||||
})
|
||||
|
||||
// ── N5/D1a Campaign journal ─────────────────────────────────────────────
|
||||
exercise("N5/D1a Journal — collected campaign pages", func() {
|
||||
for _, c := range chars {
|
||||
p.handleJournalCmd(ctxFor(c.uid, ""))
|
||||
}
|
||||
})
|
||||
|
||||
// ── N4/E3 Town registries ───────────────────────────────────────────────
|
||||
exercise("N4/E3 Town — !town / !graveyard / !rivals", func() {
|
||||
p.handleTownCmd(ctxFor(chars[0].uid, ""))
|
||||
p.handleGraveyardCmd(ctxFor(chars[0].uid, ""))
|
||||
p.handleRivalsCmd(ctxFor(chars[0].uid, ""))
|
||||
p.handleRivalsTopCmd(ctxFor(chars[0].uid, ""), "")
|
||||
})
|
||||
|
||||
// ── N4/E1 Estate vault ──────────────────────────────────────────────────
|
||||
exercise("N4/E1 Vault — list (gated on T4 estate)", func() {
|
||||
for _, c := range chars {
|
||||
p.handleVaultCmd(ctxFor(c.uid, ""), "")
|
||||
}
|
||||
})
|
||||
|
||||
// ── N4/E2 Item gifting ──────────────────────────────────────────────────
|
||||
exercise("N4/E2 Gifting — !give <item> @user", func() {
|
||||
if len(chars) < 2 {
|
||||
t.Log(" need 2 characters to gift; skipping")
|
||||
return
|
||||
}
|
||||
giver, receiver := chars[0], chars[1]
|
||||
item := firstInventoryItem(giver.uid)
|
||||
if item == "" {
|
||||
t.Logf(" %s has no inventory items to gift", giver.uid)
|
||||
return
|
||||
}
|
||||
body := fmt.Sprintf("!give %s %s", item, receiver.uid)
|
||||
t.Logf(" %s gifts %q to %s", giver.uid, item, receiver.uid)
|
||||
p.handleGiveCmd(ctxFor(giver.uid, body))
|
||||
})
|
||||
|
||||
// ── N7/B4 Achievements wing (best-effort; needs a registry) ─────────────
|
||||
exercise("N7/B4 Achievements — !achievements", func() {
|
||||
ach := NewAchievementsPlugin(nil, nil)
|
||||
ach.Sink = sink
|
||||
p.SetAchievements(ach)
|
||||
for _, c := range chars {
|
||||
ach.handleAchievements(ctxFor(c.uid, ""))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
type exChar struct {
|
||||
uid id.UserID
|
||||
class string
|
||||
level int
|
||||
}
|
||||
|
||||
// runnableChars lists characters that have both a completed dnd_character (class
|
||||
// set) and the legacy adventure_characters row the expedition/feature paths load.
|
||||
func runnableChars(t *testing.T) []exChar {
|
||||
t.Helper()
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`
|
||||
SELECT d.user_id, d.class, d.dnd_level
|
||||
FROM dnd_character d
|
||||
JOIN adventure_characters a ON a.user_id = d.user_id
|
||||
WHERE d.class != ''`)
|
||||
if err != nil {
|
||||
t.Fatalf("query chars: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []exChar
|
||||
for rows.Next() {
|
||||
var c exChar
|
||||
var uid string
|
||||
if err := rows.Scan(&uid, &c.class, &c.level); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.uid = id.UserID(uid)
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].level > out[j].level })
|
||||
return out
|
||||
}
|
||||
|
||||
func healToFull(t *testing.T, uid id.UserID) {
|
||||
t.Helper()
|
||||
c, err := LoadDnDCharacter(uid)
|
||||
if err != nil || c == nil {
|
||||
t.Logf(" healToFull: load %s: %v", uid, err)
|
||||
return
|
||||
}
|
||||
c.HPCurrent = c.HPMax
|
||||
c.TempHP = 0
|
||||
c.Exhaustion = 0
|
||||
c.ShortRestCharges = c.Level
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Logf(" healToFull: save %s: %v", uid, err)
|
||||
}
|
||||
}
|
||||
|
||||
func firstInventoryItem(uid id.UserID) string {
|
||||
d := db.Get()
|
||||
var name string
|
||||
// adventure_inventory holds the player's consumables/misc, one row per item.
|
||||
_ = d.QueryRow(
|
||||
`SELECT name FROM adventure_inventory WHERE user_id=? LIMIT 1`,
|
||||
string(uid),
|
||||
).Scan(&name)
|
||||
return name
|
||||
}
|
||||
|
||||
func copyIfPresent(t *testing.T, src, dst string) {
|
||||
t.Helper()
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return // wal/shm may be absent — fine
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func indent(s string) string {
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
for i, l := range lines {
|
||||
lines[i] = " " + l
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -230,7 +230,7 @@ func ambientEventMonologue() ambientEvent {
|
||||
// it's cheap to test in isolation and free of package-level state.
|
||||
func ambientEvents() []ambientEvent {
|
||||
always := func(*Expedition) bool { return true }
|
||||
return []ambientEvent{
|
||||
events := []ambientEvent{
|
||||
ambientEventMonologue(),
|
||||
{
|
||||
Kind: "small_find",
|
||||
@@ -280,6 +280,19 @@ func ambientEvents() []ambientEvent {
|
||||
},
|
||||
},
|
||||
}
|
||||
// N7/E4 — a live season adds a themed road visitor. Non-combat: it leaves a
|
||||
// keepsake and a coin gift, resolved in applyAmbientEffect. The pool and
|
||||
// reward come from activeSeason(); the ambient seam is production-only, so
|
||||
// this never reaches the balance sim.
|
||||
if s, ok := activeSeason(); ok && len(s.Visitor.FlavorPool) > 0 {
|
||||
events = append(events, ambientEvent{
|
||||
Kind: "season_visitor",
|
||||
Pool: s.Visitor.FlavorPool,
|
||||
Weight: s.Visitor.Weight,
|
||||
Eligible: func(*Expedition) bool { return true },
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// pickAmbientEvent runs a weighted pick over eligible events. avoidKind
|
||||
@@ -395,6 +408,39 @@ func (p *AdventurePlugin) applyAmbientEffect(e *Expedition, ev ambientEvent) str
|
||||
return ""
|
||||
}
|
||||
return "+1 HP"
|
||||
case "season_visitor":
|
||||
// N7/E4 — the visitor leaves a sellable keepsake and a coin gift. Re-read
|
||||
// the season so the reward matches the current window even if it turned
|
||||
// over between pick and apply; bail quietly if it just ended.
|
||||
s, ok := activeSeason()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
v := s.Visitor
|
||||
uid := id.UserID(e.UserID)
|
||||
if err := addAdvInventoryItem(uid, AdvItem{
|
||||
Name: v.Keepsake,
|
||||
Type: "trophy",
|
||||
Tier: 1,
|
||||
Value: v.KeepsakeValue,
|
||||
}); err != nil {
|
||||
slog.Warn("expedition: ambient season keepsake", "user", uid, "err", err)
|
||||
return ""
|
||||
}
|
||||
if v.Coins > 0 {
|
||||
if p.euro != nil {
|
||||
p.euro.Credit(uid, float64(v.Coins), "expedition ambient: "+s.Key+" visitor")
|
||||
}
|
||||
if _, err := db.Get().Exec(`
|
||||
UPDATE dnd_expedition
|
||||
SET coins_earned = coins_earned + ?,
|
||||
last_activity = CURRENT_TIMESTAMP
|
||||
WHERE expedition_id = ?`, v.Coins, e.ID); err != nil {
|
||||
slog.Warn("expedition: ambient season coin tally", "expedition", e.ID, "err", err)
|
||||
}
|
||||
return fmt.Sprintf("You keep the %s. +%d coins", v.Keepsake, v.Coins)
|
||||
}
|
||||
return fmt.Sprintf("You keep the %s.", v.Keepsake)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -44,11 +44,14 @@ func renderEndOfDayDigest(expID string, prevDay int) string {
|
||||
threatLines []string
|
||||
milestoneLine []string
|
||||
narrativeBits []string
|
||||
journalPages int
|
||||
)
|
||||
for _, e := range entries {
|
||||
switch e.Type {
|
||||
case "walk":
|
||||
walks++
|
||||
case "journal":
|
||||
journalPages++
|
||||
case "harvest":
|
||||
// Only count successful gathers — failed rolls / errors are noise.
|
||||
if strings.Contains(e.Summary, "success") {
|
||||
@@ -115,6 +118,12 @@ func renderEndOfDayDigest(expID string, prevDay int) string {
|
||||
b.WriteString("\n")
|
||||
bulleted = true
|
||||
}
|
||||
if react := twinBeeJournalReaction(prevDay, journalPages); react != "" {
|
||||
b.WriteString("• ")
|
||||
b.WriteString(react)
|
||||
b.WriteString("\n")
|
||||
bulleted = true
|
||||
}
|
||||
if !bulleted {
|
||||
// All entries were filtered out — fall back to the bare camp block.
|
||||
return ""
|
||||
|
||||
@@ -49,6 +49,10 @@ func NewSimRunner(dataDir string) (*SimRunner, error) {
|
||||
// etc. fire the way a competent prod player would set them up.
|
||||
// Without this the sim under-counts class survival.
|
||||
simAutoArmEnabled = true
|
||||
// N7/B3 — neutralize the weekly Omen so corpus results don't depend on which
|
||||
// wall-clock week the sweep runs in (the sim's synthetic clock never reaches
|
||||
// activeOmen's time.Now()).
|
||||
simOmenDisabled = true
|
||||
euro := &EuroPlugin{}
|
||||
p := &AdventurePlugin{euro: euro}
|
||||
return &SimRunner{P: p, Euro: euro}, nil
|
||||
@@ -127,6 +131,38 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// PrepareRealCharacter readies an already-persisted character (loaded from a
|
||||
// copy of the prod DB) for a headless run. Unlike BuildCharacter it fabricates
|
||||
// nothing: the character keeps its real race/class/subclass/level, ability
|
||||
// scores, equipment, spellbook and inventory. It only (a) heals to full — a
|
||||
// player rests before heading out, so we don't handicap a wounded prod snapshot
|
||||
// — and (b) tops the bankroll up to `bank` so the "heavy" supply preset always
|
||||
// affords itself regardless of the player's live coin balance. Returns the
|
||||
// loaded character. The DB copy is disposable; the live prod file is untouched.
|
||||
func (s *SimRunner) PrepareRealCharacter(uid id.UserID, bank float64) (*DnDCharacter, error) {
|
||||
c, err := LoadDnDCharacter(uid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LoadDnDCharacter: %w", err)
|
||||
}
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("no character for %s in db copy", uid)
|
||||
}
|
||||
if c.Class == "" {
|
||||
return nil, fmt.Errorf("%s has no class (setup incomplete) — nothing to simulate", uid)
|
||||
}
|
||||
c.HPCurrent = c.HPMax
|
||||
c.TempHP = 0
|
||||
c.Exhaustion = 0
|
||||
c.ShortRestCharges = c.Level
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
return nil, fmt.Errorf("SaveDnDCharacter: %w", err)
|
||||
}
|
||||
if bal := s.Euro.GetBalance(uid); bal < bank {
|
||||
s.Euro.Credit(uid, bank-bal, "expedition-sim real-char bankroll top-up")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// stockSimConsumables drops a small tier-appropriate bundle of potions
|
||||
// + a couple offensive items into the synthetic player's inventory so
|
||||
// SelectConsumables / setupAutoHealFromInventory have something to fire
|
||||
|
||||
303
internal/plugin/pete.go
Normal file
303
internal/plugin/pete.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// This file is gogobee's game-side entry to Pete's adventure news. Plugins call
|
||||
// emitFact at event chokepoints; it enforces the kill-switch + per-player
|
||||
// opt-out, then hands a durable fact to peteclient. gogobee supplies facts only
|
||||
// — Pete owns the voice. See pete_adventure_news_plan.md / _voice.md.
|
||||
|
||||
const (
|
||||
newsEnabledKey = "adventure_news_enabled"
|
||||
newsGUIDSaltKey = "adventure_news_guid_salt"
|
||||
anonName = "an adventurer"
|
||||
)
|
||||
|
||||
// newsEmissionOn is the runtime kill-switch (default on). An operator flips it
|
||||
// with `!news off` without a redeploy; FEATURE_PETE_NEWS is the source-level
|
||||
// master switch checked separately by peteclient.Enabled. Persisted in the
|
||||
// durable news_config table (NOT api_cache, which RunMaintenance prunes).
|
||||
func newsEmissionOn() bool {
|
||||
var v string
|
||||
err := db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsEnabledKey).Scan(&v)
|
||||
if err != nil {
|
||||
return true // no row → default on
|
||||
}
|
||||
return v != "0"
|
||||
}
|
||||
|
||||
// setNewsEmission persists the runtime kill-switch.
|
||||
func setNewsEmission(on bool) {
|
||||
v := "1"
|
||||
if !on {
|
||||
v = "0"
|
||||
}
|
||||
db.Exec("news emission set",
|
||||
`INSERT INTO news_config (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
newsEnabledKey, v)
|
||||
}
|
||||
|
||||
// isNewsOptedOut reports whether a player asked not to be named in the news.
|
||||
func isNewsOptedOut(userID id.UserID) bool {
|
||||
var one int
|
||||
err := db.Get().QueryRow(`SELECT 1 FROM news_optout WHERE user_id = ?`, string(userID)).Scan(&one)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// setNewsOptout records or clears a player's opt-out. Opting out anonymizes
|
||||
// their name in future dispatches ("an adventurer…"); it does not stop events
|
||||
// being reported. Idempotent either direction.
|
||||
func setNewsOptout(userID id.UserID, out bool) {
|
||||
if out {
|
||||
db.Exec("news optout set",
|
||||
`INSERT OR IGNORE INTO news_optout (user_id, opted_out_at) VALUES (?, unixepoch())`,
|
||||
string(userID))
|
||||
return
|
||||
}
|
||||
db.Exec("news optout clear", `DELETE FROM news_optout WHERE user_id = ?`, string(userID))
|
||||
}
|
||||
|
||||
var (
|
||||
guidSaltOnce sync.Once
|
||||
guidSalt []byte
|
||||
)
|
||||
|
||||
// newsGUIDSalt returns the secret, server-side salt that keys every public event
|
||||
// token. It is 32 random bytes, generated once and persisted in the durable
|
||||
// news_config table, cached in-process. Secret from anyone outside the server
|
||||
// (external users never see it), stable across restarts (so a re-emit or the
|
||||
// cold-start backfill reproduces the same GUID). No operator action needed.
|
||||
func newsGUIDSalt() []byte {
|
||||
guidSaltOnce.Do(func() {
|
||||
var stored string
|
||||
if err := db.Get().QueryRow(
|
||||
`SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&stored); err == nil {
|
||||
if b, e := hex.DecodeString(stored); e == nil && len(b) > 0 {
|
||||
guidSalt = b
|
||||
return
|
||||
}
|
||||
}
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// crypto/rand should never fail; if it does, refuse to fall back to a
|
||||
// predictable salt (that would reopen the enumeration attack). Leave the
|
||||
// salt empty and let eventToken sort it out.
|
||||
return
|
||||
}
|
||||
salt := hex.EncodeToString(b)
|
||||
// Persist. OR IGNORE so a concurrent first-writer wins cleanly; we then
|
||||
// re-read the row so every caller agrees on the same salt.
|
||||
db.Exec("news guid salt seed",
|
||||
`INSERT OR IGNORE INTO news_config (key, value) VALUES (?, ?)`, newsGUIDSaltKey, salt)
|
||||
_ = db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&salt)
|
||||
guidSalt, _ = hex.DecodeString(salt)
|
||||
})
|
||||
return guidSalt
|
||||
}
|
||||
|
||||
// eventToken is the per-event, one-way identifier embedded in a public GUID
|
||||
// (which becomes a PUBLIC permalink path on Pete). It is HMAC-SHA256 over the
|
||||
// Matrix user id AND an event-specific discriminator, keyed by the secret
|
||||
// newsGUIDSalt.
|
||||
//
|
||||
// Two properties matter and both come from HMAC being a PRF under a secret key:
|
||||
// - Not computable from a Matrix handle. An attacker who knows @alice:server
|
||||
// cannot derive any of her tokens without the salt — so her events (including
|
||||
// ones anonymized after `!news optout`) can't be enumerated from her handle.
|
||||
// - Per-event, so tokens are mutually unlinkable. Even for the same user, two
|
||||
// events yield unrelated tokens; a story that names her (opted-in, or a duel
|
||||
// opponent) exposes only that one event's token and can't be walked back to
|
||||
// her other, anonymized events.
|
||||
//
|
||||
// The discriminator must uniquely identify the logical event so a re-emit or the
|
||||
// backfill reproduces the same token (idempotency rides on the GUID).
|
||||
func eventToken(userID id.UserID, discriminator string) string {
|
||||
mac := hmac.New(sha256.New, newsGUIDSalt())
|
||||
mac.Write([]byte(userID))
|
||||
mac.Write([]byte{0}) // domain separator: keep id and discriminator from bleeding
|
||||
mac.Write([]byte(discriminator))
|
||||
return hex.EncodeToString(mac.Sum(nil)[:9]) // 18 hex chars — ample, non-reversible
|
||||
}
|
||||
|
||||
// charName returns a player's in-game character name, never their Matrix handle.
|
||||
// Empty when unknown — callers skip the emit rather than fall back to a handle.
|
||||
func charName(userID id.UserID) string {
|
||||
name, _ := loadDisplayName(userID)
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
// charLevel returns a player's current character level for a news Fact, or 0 if
|
||||
// no character loads (Level is omitempty, so 0 is dropped from the payload).
|
||||
func charLevel(userID id.UserID) int {
|
||||
if dc, _ := LoadDnDCharacter(userID); dc != nil {
|
||||
return dc.Level
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// classRaceLabel renders a character's race + class for the arrival fact, e.g.
|
||||
// "Elf Ranger".
|
||||
func classRaceLabel(c *DnDCharacter) string {
|
||||
ri, _ := raceInfo(c.Race)
|
||||
ci, _ := classInfo(c.Class)
|
||||
return strings.TrimSpace(ri.Display + " " + ci.Display)
|
||||
}
|
||||
|
||||
// emitFact is the single game-side entry to Pete's news. It enforces the runtime
|
||||
// kill-switch and per-player opt-out (anonymize the name, never drop the event),
|
||||
// derives the actors allow-list from the FINAL names (so Pete's fact-guard and
|
||||
// the rendered content agree), then durably queues the fact.
|
||||
//
|
||||
// subjectUser/opponentUser are the Matrix users behind Subject/Opponent (either
|
||||
// may be empty for realm-level events). They are used ONLY for the opt-out
|
||||
// lookup and are never sent.
|
||||
func emitFact(f peteclient.Fact, subjectUser, opponentUser id.UserID) {
|
||||
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||
return
|
||||
}
|
||||
if subjectUser != "" && isNewsOptedOut(subjectUser) {
|
||||
f.Subject = anonName
|
||||
}
|
||||
if opponentUser != "" && isNewsOptedOut(opponentUser) {
|
||||
f.Opponent = anonName
|
||||
}
|
||||
var actors []string
|
||||
for _, n := range []string{f.Subject, f.Opponent} {
|
||||
if n != "" {
|
||||
actors = append(actors, n)
|
||||
}
|
||||
}
|
||||
f.Actors = actors
|
||||
peteclient.Emit(f)
|
||||
}
|
||||
|
||||
// handleNewsCmd backs `!news`. Players toggle whether they're named in Pete's
|
||||
// dispatches; admins flip the realm-wide emission kill-switch. Replies in-room
|
||||
// so the effect is visible where it was invoked.
|
||||
//
|
||||
// !news → show your status + what the command does
|
||||
// !news optout → anonymize your name in future dispatches
|
||||
// !news optin → be named again
|
||||
// !news on|off → (admin) master kill-switch for all emission
|
||||
func (p *AdventurePlugin) handleNewsCmd(ctx MessageContext) error {
|
||||
arg := strings.ToLower(strings.TrimSpace(p.GetArgs(ctx.Body, "news")))
|
||||
switch arg {
|
||||
case "optout", "opt-out", "anonymize", "anon":
|
||||
setNewsOptout(ctx.Sender, true)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"Done — Pete will refer to you as \"an adventurer\" in the news from now on. `!news optin` to be named again.")
|
||||
case "optin", "opt-in":
|
||||
setNewsOptout(ctx.Sender, false)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"You're back on the record — Pete will use your character name in future dispatches.")
|
||||
case "on", "enable":
|
||||
if !p.IsAdmin(ctx.Sender) {
|
||||
return nil
|
||||
}
|
||||
setNewsEmission(true)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "📣 Adventure news emission is now ON.")
|
||||
case "off", "disable":
|
||||
if !p.IsAdmin(ctx.Sender) {
|
||||
return nil
|
||||
}
|
||||
setNewsEmission(false)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "🔇 Adventure news emission is now OFF. No new dispatches will be sent.")
|
||||
case "", "status", "help":
|
||||
named := "using your character name"
|
||||
if isNewsOptedOut(ctx.Sender) {
|
||||
named = "anonymized (\"an adventurer\")"
|
||||
}
|
||||
msg := "📰 **Pete's Adventure News** reports realm happenings — deaths, first-clears, arrivals, duels.\n" +
|
||||
"You are currently " + named + ".\n" +
|
||||
"`!news optout` to be anonymized · `!news optin` to be named again."
|
||||
if p.IsAdmin(ctx.Sender) {
|
||||
state := "ON"
|
||||
if !newsEmissionOn() {
|
||||
state = "OFF"
|
||||
}
|
||||
msg += "\n_(admin)_ emission is **" + state + "** · `!news on` / `!news off` to toggle."
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
default:
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Unknown option. Try `!news`, `!news optout`, or `!news optin`.")
|
||||
}
|
||||
}
|
||||
|
||||
// nowUnix is the event timestamp helper (kept in one place so the emit sites
|
||||
// read uniformly).
|
||||
func nowUnix() int64 { return time.Now().Unix() }
|
||||
|
||||
// claimRealmFirst records the first occurrence of a (kind,target) across the
|
||||
// realm and reports whether THIS call was the first — true exactly once. Used to
|
||||
// tier realm-firsts (PRIORITY) apart from repeat clears (BULLETIN). The backfill
|
||||
// pre-seeds this ledger so historical firsts aren't re-announced after a deploy.
|
||||
func claimRealmFirst(kind, target string) bool {
|
||||
res := db.ExecResult("news realm-first claim",
|
||||
`INSERT OR IGNORE INTO news_realm_firsts (kind, target, first_at) VALUES (?, ?, unixepoch())`,
|
||||
kind, target)
|
||||
if res == nil {
|
||||
return false
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return err == nil && n > 0
|
||||
}
|
||||
|
||||
// emitZoneClearNews files a zone-clear dispatch (boss down = zone cleared). The
|
||||
// realm's first clear of a zone is a PRIORITY "zone_first"; later clears are a
|
||||
// BULLETIN "zone_clear". The event_type and the GUID prefix track the tier so
|
||||
// Pete templates and labels a repeat as a repeat, not a first-ever. Character
|
||||
// name only; no-op unless the seam is enabled.
|
||||
func emitZoneClearNews(userID id.UserID, exp *Expedition) {
|
||||
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||
return
|
||||
}
|
||||
// Claim the realm-first BEFORE the name guard, so an unnamed straggler's
|
||||
// genuine first clear still seeds news_realm_firsts. Otherwise the next
|
||||
// named clearer would claim it and be mis-announced as the first-ever.
|
||||
// Mirrors backfillZoneFirsts, which claims before its own name check.
|
||||
eventType, tier := "zone_clear", "bulletin"
|
||||
if claimRealmFirst("zone", string(exp.ZoneID)) {
|
||||
eventType, tier = "zone_first", "priority"
|
||||
}
|
||||
name := charName(userID)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
zone := zoneOrFallback(exp.ZoneID)
|
||||
region := ""
|
||||
if IsMultiRegionZone(exp.ZoneID) {
|
||||
if r, ok := CurrentRegion(exp); ok {
|
||||
region = r.Name
|
||||
}
|
||||
}
|
||||
lvl := charLevel(userID)
|
||||
ts := nowUnix()
|
||||
disc := fmt.Sprintf("%s:%d", exp.ZoneID, ts)
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("%s:%s:%s:%d", eventType, eventToken(userID, disc), exp.ZoneID, ts),
|
||||
EventType: eventType,
|
||||
Tier: tier,
|
||||
Subject: name,
|
||||
Zone: zone.Display,
|
||||
Region: region,
|
||||
Boss: zone.Boss.Name,
|
||||
Level: lvl,
|
||||
Outcome: "cleared",
|
||||
OccurredAt: ts,
|
||||
}, userID, "")
|
||||
}
|
||||
216
internal/plugin/pete_test.go
Normal file
216
internal/plugin/pete_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// enablePeteSeam turns on the emit seam for a test and restores it to disabled
|
||||
// afterwards, so a leaked-enabled singleton can't affect other tests.
|
||||
func enablePeteSeam(t *testing.T) {
|
||||
t.Helper()
|
||||
os.Setenv("FEATURE_PETE_NEWS", "true")
|
||||
os.Setenv("PETE_INGEST_URL", "http://127.0.0.1:0")
|
||||
os.Setenv("PETE_INGEST_TOKEN", "tok")
|
||||
peteclient.Init()
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv("FEATURE_PETE_NEWS")
|
||||
os.Unsetenv("PETE_INGEST_URL")
|
||||
os.Unsetenv("PETE_INGEST_TOKEN")
|
||||
peteclient.Init()
|
||||
})
|
||||
}
|
||||
|
||||
func queuedCount(t *testing.T, likeGUID string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := db.Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM pete_emit_queue WHERE guid LIKE ?`, likeGUID).Scan(&n); err != nil {
|
||||
t.Fatalf("queue count: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestNewsOptoutRoundTrip: opting out records the player, opting in clears them,
|
||||
// both idempotently.
|
||||
func TestNewsOptoutRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
u := id.UserID("@zapp:example.org")
|
||||
if isNewsOptedOut(u) {
|
||||
t.Fatal("fresh player should not be opted out")
|
||||
}
|
||||
|
||||
setNewsOptout(u, true)
|
||||
if !isNewsOptedOut(u) {
|
||||
t.Error("opt-out not recorded")
|
||||
}
|
||||
setNewsOptout(u, true) // idempotent
|
||||
if !isNewsOptedOut(u) {
|
||||
t.Error("second opt-out flipped state")
|
||||
}
|
||||
|
||||
setNewsOptout(u, false)
|
||||
if isNewsOptedOut(u) {
|
||||
t.Error("opt-in did not clear")
|
||||
}
|
||||
setNewsOptout(u, false) // idempotent
|
||||
if isNewsOptedOut(u) {
|
||||
t.Error("second opt-in flipped state")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeteNewsBackfill: the one-shot replays deaths + solo achievements + zone
|
||||
// realm-firsts through the emit queue, seeds the realm-first ledger, and is
|
||||
// idempotent on a second boot.
|
||||
func TestPeteNewsBackfill(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
enablePeteSeam(t)
|
||||
|
||||
// A named, dead adventurer.
|
||||
db.Exec("seed death", `INSERT INTO player_meta (user_id, display_name, last_death_date, death_location)
|
||||
VALUES (?, ?, ?, ?)`, "@zapp:x", "Zapp", "2026-06-01", "the Underforge")
|
||||
// A single-holder achievement.
|
||||
db.Exec("seed achv", `INSERT INTO achievements (user_id, achievement_id, unlocked_at)
|
||||
VALUES (?, ?, ?)`, "@zapp:x", "adv_first_blood", 1717200000)
|
||||
// A completed, boss-defeated zone run → realm-first.
|
||||
db.Exec("seed run", `INSERT INTO dnd_zone_run (run_id, user_id, zone_id, total_rooms, boss_defeated, completed_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?)`, "r1", "@zapp:x", "goblin_warren", 5, "2026-05-20 12:00:00")
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
p.bootstrapPeteNewsBackfill()
|
||||
|
||||
if got := queuedCount(t, "death:%"); got != 1 {
|
||||
t.Errorf("death dispatches queued = %d, want 1", got)
|
||||
}
|
||||
if got := queuedCount(t, "milestone:%"); got != 1 {
|
||||
t.Errorf("achievement dispatches queued = %d, want 1", got)
|
||||
}
|
||||
if got := queuedCount(t, "zone_first:%"); got != 1 {
|
||||
t.Errorf("zone-first dispatches queued = %d, want 1", got)
|
||||
}
|
||||
// Realm-first ledger seeded so a later live clear tiers as a repeat.
|
||||
if claimRealmFirst("zone", "goblin_warren") {
|
||||
t.Error("realm-first ledger not seeded — a live clear would mis-announce as first-ever")
|
||||
}
|
||||
|
||||
// Second boot: job gate holds, nothing re-queued.
|
||||
p.bootstrapPeteNewsBackfill()
|
||||
if got := queuedCount(t, "%"); got != 3 {
|
||||
t.Errorf("total queued after re-run = %d, want 3 (idempotent)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventToken: the public GUID token is salted (not a bare sha256 an attacker
|
||||
// can recompute from the handle), stable per logical event (idempotency), and
|
||||
// per-event so a user's tokens don't link to each other.
|
||||
func TestEventToken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
// The salt is cached process-wide via sync.Once; reset it so it derives (and
|
||||
// persists) against this test's fresh DB rather than an earlier test's.
|
||||
guidSaltOnce = sync.Once{}
|
||||
guidSalt = nil
|
||||
|
||||
u := id.UserID("@alice:example.org")
|
||||
|
||||
// Not the old unsalted digest: an attacker with the handle can't recompute it.
|
||||
bare := sha256.Sum256([]byte(u))
|
||||
if got := eventToken(u, "arrival"); got == hex.EncodeToString(bare[:9]) {
|
||||
t.Error("token matches unsalted sha256(handle) — enumeration attack reopened")
|
||||
}
|
||||
|
||||
// Stable for the same (user, discriminator): a re-emit dedups.
|
||||
if eventToken(u, "arrival") != eventToken(u, "arrival") {
|
||||
t.Error("token not stable for the same event — idempotency broken")
|
||||
}
|
||||
|
||||
// Per-event: two different events for the same user yield unrelated tokens, so
|
||||
// a named event can't be walked back to the user's anonymized events.
|
||||
if eventToken(u, "arrival") == eventToken(u, "death:123") {
|
||||
t.Error("tokens collide across events — linkage attack reopened")
|
||||
}
|
||||
|
||||
// Different users, same discriminator, still differ.
|
||||
if eventToken(u, "arrival") == eventToken(id.UserID("@bob:example.org"), "arrival") {
|
||||
t.Error("token collides across users")
|
||||
}
|
||||
|
||||
// The salt persisted, so it survives a restart (a re-emit after reboot dedups).
|
||||
var salt string
|
||||
if err := db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&salt); err != nil || salt == "" {
|
||||
t.Fatalf("guid salt not persisted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitZoneClearTaxonomy: the realm's first clear of a zone emits a PRIORITY
|
||||
// zone_first; a later clear emits a BULLETIN zone_clear — distinct event_type and
|
||||
// GUID prefix, so Pete never files a repeat as a first-ever.
|
||||
func TestEmitZoneClearTaxonomy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
enablePeteSeam(t)
|
||||
|
||||
db.Exec("seed player", `INSERT INTO player_meta (user_id, display_name) VALUES (?, ?)`,
|
||||
"@zapp:x", "Zapp")
|
||||
exp := &Expedition{ZoneID: "goblin_warren"}
|
||||
|
||||
emitZoneClearNews(id.UserID("@zapp:x"), exp) // realm-first
|
||||
emitZoneClearNews(id.UserID("@zapp:x"), exp) // repeat
|
||||
|
||||
if got := queuedCount(t, "zone_first:%"); got != 1 {
|
||||
t.Errorf("zone_first queued = %d, want 1 (the realm-first)", got)
|
||||
}
|
||||
if got := queuedCount(t, "zone_clear:%"); got != 1 {
|
||||
t.Errorf("zone_clear queued = %d, want 1 (the repeat)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewsEmissionKillSwitch: the runtime flag defaults on and persists a flip.
|
||||
func TestNewsEmissionKillSwitch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
if !newsEmissionOn() {
|
||||
t.Error("emission should default ON")
|
||||
}
|
||||
setNewsEmission(false)
|
||||
if newsEmissionOn() {
|
||||
t.Error("emission still ON after disable")
|
||||
}
|
||||
setNewsEmission(true)
|
||||
if !newsEmissionOn() {
|
||||
t.Error("emission still OFF after enable")
|
||||
}
|
||||
}
|
||||
@@ -310,6 +310,144 @@ func petStateFromAdvChar(c *AdventureCharacter) PetState {
|
||||
}
|
||||
}
|
||||
|
||||
// pet2StateFromAdvChar projects the second pet's fields off an
|
||||
// AdventureCharacter. SupplyShopUnlocked / MorningDefense are always zero for
|
||||
// slot 2 — those mechanics stay pet-1-only (see AdventureCharacter.Pet2*).
|
||||
func pet2StateFromAdvChar(c *AdventureCharacter) PetState {
|
||||
return PetState{
|
||||
Type: c.Pet2Type,
|
||||
Name: c.Pet2Name,
|
||||
XP: c.Pet2XP,
|
||||
Level: c.Pet2Level,
|
||||
ArmorTier: c.Pet2ArmorTier,
|
||||
Level10Date: c.Pet2Level10Date,
|
||||
Arrived: c.Pet2Arrived,
|
||||
ChasedAway: c.Pet2ChasedAway,
|
||||
Reactivated: c.Pet2Reactivated,
|
||||
}
|
||||
}
|
||||
|
||||
// loadPet2State returns the player's second pet from the pet2_* columns. Empty
|
||||
// PetState{} if the user has no row or no second pet.
|
||||
func loadPet2State(userID id.UserID) (PetState, error) {
|
||||
var (
|
||||
s PetState
|
||||
flagsRaw string
|
||||
)
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT pet2_type, pet2_name, pet2_xp, pet2_level, pet2_armor_tier,
|
||||
pet2_flags_json, pet2_level_10_date
|
||||
FROM player_meta WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&s.Type, &s.Name, &s.XP, &s.Level, &s.ArmorTier,
|
||||
&flagsRaw, &s.Level10Date)
|
||||
if err == sql.ErrNoRows {
|
||||
return PetState{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return PetState{}, err
|
||||
}
|
||||
var f petFlagsJSON
|
||||
if flagsRaw != "" {
|
||||
_ = json.Unmarshal([]byte(flagsRaw), &f)
|
||||
}
|
||||
s.Arrived = f.Arrived
|
||||
s.ChasedAway = f.ChasedAway
|
||||
s.Reactivated = f.Reactivated
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// upsertPlayerMetaPet2State writes the second pet's column set.
|
||||
func upsertPlayerMetaPet2State(userID id.UserID, s PetState) error {
|
||||
flags, err := json.Marshal(petFlagsJSON{
|
||||
Arrived: s.Arrived,
|
||||
ChasedAway: s.ChasedAway,
|
||||
Reactivated: s.Reactivated,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.Get().Exec(
|
||||
`INSERT INTO player_meta (
|
||||
user_id, pet2_type, pet2_name, pet2_xp, pet2_level, pet2_armor_tier,
|
||||
pet2_flags_json, pet2_level_10_date
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
pet2_type = excluded.pet2_type,
|
||||
pet2_name = excluded.pet2_name,
|
||||
pet2_xp = excluded.pet2_xp,
|
||||
pet2_level = excluded.pet2_level,
|
||||
pet2_armor_tier = excluded.pet2_armor_tier,
|
||||
pet2_flags_json = excluded.pet2_flags_json,
|
||||
pet2_level_10_date = excluded.pet2_level_10_date`,
|
||||
string(userID), s.Type, s.Name, s.XP, s.Level, s.ArmorTier,
|
||||
string(flags), s.Level10Date,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// loadJournalPages reads the Hollow King campaign page bitmask (N5/D1). A
|
||||
// missing row means no pages found yet.
|
||||
func loadJournalPages(userID id.UserID) (int64, error) {
|
||||
var mask int64
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT journal_pages FROM player_meta WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&mask)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return mask, nil
|
||||
}
|
||||
|
||||
// grantJournalPageDB sets the bit for one page atomically. The OR against the
|
||||
// stored value (not a read-modify-write of the in-memory character) means a
|
||||
// page granted mid-expedition survives a concurrent character save, and the
|
||||
// call is idempotent — re-granting a found page is a no-op.
|
||||
func grantJournalPageDB(userID id.UserID, page int) error {
|
||||
if page < 1 {
|
||||
return nil
|
||||
}
|
||||
bit := int64(1) << (page - 1)
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO player_meta (user_id, journal_pages) VALUES (?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
journal_pages = journal_pages | excluded.journal_pages`,
|
||||
string(userID), bit,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// loadEpilogueCleared reads the finale reward-once flag (N5/D1c).
|
||||
func loadEpilogueCleared(userID id.UserID) (bool, error) {
|
||||
var cleared int
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT epilogue_cleared FROM player_meta WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&cleared)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return cleared != 0, nil
|
||||
}
|
||||
|
||||
// markEpilogueClearedDB latches the finale reward-once flag atomically, so the
|
||||
// monotonic false→true set survives any concurrent character save.
|
||||
func markEpilogueClearedDB(userID id.UserID) error {
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO player_meta (user_id, epilogue_cleared) VALUES (?, 1)
|
||||
ON CONFLICT(user_id) DO UPDATE SET epilogue_cleared = 1`,
|
||||
string(userID),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// HouseState is the in-memory mirror of player_meta's house_* columns. Phase
|
||||
// L4e ports housing/mortgage off AdvCharacter (gogobee_legacy_migration.md
|
||||
// §6.5). All six fields mutate together at known sites (purchase, payoff,
|
||||
@@ -1194,6 +1332,26 @@ func applyPlayerMetaOverlay(c *AdventureCharacter) {
|
||||
c.PetReactivated = s.Reactivated
|
||||
c.PetMorningDefense = s.MorningDefense
|
||||
}
|
||||
if s, err := loadPet2State(uid); err == nil {
|
||||
c.Pet2Type = s.Type
|
||||
c.Pet2Name = s.Name
|
||||
c.Pet2XP = s.XP
|
||||
c.Pet2Level = s.Level
|
||||
c.Pet2ArmorTier = s.ArmorTier
|
||||
c.Pet2Level10Date = s.Level10Date
|
||||
c.Pet2Arrived = s.Arrived
|
||||
c.Pet2ChasedAway = s.ChasedAway
|
||||
c.Pet2Reactivated = s.Reactivated
|
||||
}
|
||||
if mask, err := loadJournalPages(uid); err == nil {
|
||||
c.JournalPages = mask
|
||||
}
|
||||
if cleared, err := loadEpilogueCleared(uid); err == nil {
|
||||
c.EpilogueCleared = cleared
|
||||
}
|
||||
if xp, err := loadRenownXP(uid); err == nil {
|
||||
c.RenownXP = xp
|
||||
}
|
||||
if s, err := loadHouseState(uid); err == nil {
|
||||
c.HouseTier = s.Tier
|
||||
c.HouseLoanBalance = s.LoanBalance
|
||||
@@ -1248,6 +1406,9 @@ func upsertAllPlayerMetaFromAdvChar(c *AdventureCharacter) error {
|
||||
if err := upsertPlayerMetaPetState(uid, petStateFromAdvChar(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertPlayerMetaPet2State(uid, pet2StateFromAdvChar(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertPlayerMetaHouseState(uid, houseStateFromAdvChar(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
81
internal/plugin/player_meta_seed_test.go
Normal file
81
internal/plugin/player_meta_seed_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// TestAutoMigrationSeedsPlayerMeta guards the camcast straggler regression: a
|
||||
// brand-new player whose first-ever adventure action auto-migrates a character
|
||||
// (e.g. !rest before !setup) must come out with a canonical player_meta seed,
|
||||
// or every legacy-layer command (expeditions, arena, world boss, town…) fails
|
||||
// with "sql: no rows". Before the ensurePlayerMetaSeed guard this produced a
|
||||
// confirmed dnd_character with zero player_meta rows.
|
||||
func TestAutoMigrationSeedsPlayerMeta(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@fresh-automigrant:example.org")
|
||||
|
||||
p := &AdventurePlugin{euro: &EuroPlugin{}}
|
||||
p.Sink = &captureSink{}
|
||||
|
||||
// First-ever adventure action for a player with no character at all.
|
||||
if err := p.handleDnDShortRest(MessageContext{Sender: uid}); err != nil {
|
||||
t.Fatalf("handleDnDShortRest: %v", err)
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
var dndRows, pmRows, equipRows, pending int
|
||||
d.QueryRow(`SELECT COUNT(*) FROM dnd_character WHERE user_id=?`, string(uid)).Scan(&dndRows)
|
||||
d.QueryRow(`SELECT COUNT(*) FROM player_meta WHERE user_id=?`, string(uid)).Scan(&pmRows)
|
||||
d.QueryRow(`SELECT COUNT(*) FROM adventure_equipment WHERE user_id=?`, string(uid)).Scan(&equipRows)
|
||||
d.QueryRow(`SELECT COALESCE(MAX(pending_setup),-1) FROM dnd_character WHERE user_id=?`, string(uid)).Scan(&pending)
|
||||
|
||||
if dndRows != 1 {
|
||||
t.Fatalf("expected 1 auto-migrated dnd_character, got %d", dndRows)
|
||||
}
|
||||
if pending != 0 {
|
||||
t.Errorf("auto-migrated character should be confirmed (pending_setup=0), got %d", pending)
|
||||
}
|
||||
if pmRows != 1 {
|
||||
t.Errorf("player_meta not seeded for auto-migrant: got %d rows, want 1 (camcast straggler regression)", pmRows)
|
||||
}
|
||||
// createAdvCharacter seeds tier-0 gear in all five slots.
|
||||
if equipRows != len(allSlots) {
|
||||
t.Errorf("tier-0 equipment not seeded: got %d rows, want %d", equipRows, len(allSlots))
|
||||
}
|
||||
|
||||
// The character must now load through the legacy layer that camcast choked on.
|
||||
if _, err := loadAdvCharacter(uid); err != nil {
|
||||
t.Errorf("loadAdvCharacter after auto-migration: %v (this is the exact failure camcast hit)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsurePlayerMetaSeedIdempotent proves the guard never duplicates a legacy
|
||||
// player's equipment — createAdvCharacter's tier-0 insert has no conflict guard,
|
||||
// so the seed must be conditional on player_meta being absent.
|
||||
func TestEnsurePlayerMetaSeedIdempotent(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@legacy:example.org")
|
||||
|
||||
// Seed once (fresh), then again (should be a no-op).
|
||||
if err := ensurePlayerMetaSeed(uid); err != nil {
|
||||
t.Fatalf("first seed: %v", err)
|
||||
}
|
||||
if err := ensurePlayerMetaSeed(uid); err != nil {
|
||||
t.Fatalf("second seed: %v", err)
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
var pmRows, equipRows int
|
||||
d.QueryRow(`SELECT COUNT(*) FROM player_meta WHERE user_id=?`, string(uid)).Scan(&pmRows)
|
||||
d.QueryRow(`SELECT COUNT(*) FROM adventure_equipment WHERE user_id=?`, string(uid)).Scan(&equipRows)
|
||||
if pmRows != 1 {
|
||||
t.Errorf("player_meta rows = %d, want 1 (no duplication)", pmRows)
|
||||
}
|
||||
if equipRows != len(allSlots) {
|
||||
t.Errorf("equipment rows = %d, want %d (guard must not re-insert gear)", equipRows, len(allSlots))
|
||||
}
|
||||
}
|
||||
@@ -638,6 +638,14 @@ func (b *Base) SendNotice(roomID id.RoomID, text string) error {
|
||||
|
||||
// SendReply sends a reply to a specific event.
|
||||
func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error {
|
||||
// The sink is the headless capture seam: a reply is an outbound room
|
||||
// message, so — like SendMessage/SendDM — it diverts here when installed and
|
||||
// the live client is never touched. Without this branch, reply-only handlers
|
||||
// (duels, !town, !rivals, !achievements) hit a nil client under the sink.
|
||||
if b != nil && b.Sink != nil {
|
||||
_, err := b.Sink.Capture(outboundMessage{ToRoom: roomID, Text: text})
|
||||
return err
|
||||
}
|
||||
content := textContent(text)
|
||||
if eventID != "" {
|
||||
content.RelatesTo = &event.RelatesTo{
|
||||
|
||||
@@ -120,6 +120,23 @@ var ThomRateDecreasePet = []string{
|
||||
"You can draw your own conclusions.",
|
||||
}
|
||||
|
||||
// ── FINAL PAYOFF — THE LAST HOUSE (D2 NPC ARC) ────────────────────────────────
|
||||
//
|
||||
// Sent once, when the Tier-4 "Established" mortgage hits zero — the last house
|
||||
// Thom has to sell. There is no next tier, so this is the last time Thom has
|
||||
// business with the player. A letter, and (if they keep a pet) a treat left in
|
||||
// the bag. Substitute {pet} with strings.ReplaceAll at the call site.
|
||||
|
||||
var thomFinalLetterNoPet = "Paid off. All of it. There is no next tier -- you've bought the last house I had to sell you.\n\n" +
|
||||
"I've closed a great many mortgages. Thirty-one years, Krooke Realty. Most people I stop thinking about the hour the balance hits zero, and I have never once considered that a loss. I am writing to tell you that you appear to be an exception. I have not decided how I feel about that. I am telling you anyway, which should indicate something.\n\n" +
|
||||
"The door is yours, the roof is yours, the ground it stands on is yours. Nobody sends you a bill for it ever again. Walk through it whenever you like.\n\n" +
|
||||
"-- Thom Krooke, Krooke Realty"
|
||||
|
||||
var thomFinalLetterPet = "Paid off. All of it. There is no next tier -- you own it outright now, walls and roof and the ground it sits on.\n\n" +
|
||||
"I've enclosed something for {pet}. A treat. The good kind -- not the sort they sell by the sack, I had it sent from a place I trust. Give it to {pet} tonight and don't you dare ration it, and don't 'save it for a special occasion' either. Tonight IS the occasion. {pet} has spent this entire mortgage living in a house that wasn't finished being paid for, and now it is, and {pet} should know the difference even if {pet} can't be told in words.\n\n" +
|
||||
"You did a fine thing, getting {pet} a home that is truly yours. I won't say it a second time.\n\n" +
|
||||
"-- Thom Krooke, Krooke Realty"
|
||||
|
||||
// ── NO CHANGE (NEVER SENT) ────────────────────────────────────────────────────
|
||||
// Thom does not send neutral news.
|
||||
// If rate is unchanged week over week: no DM. No notification. Nothing.
|
||||
|
||||
@@ -34,6 +34,12 @@ package plugin
|
||||
// hangs off the open spoke of upper_hall (cheapest gate, hardest fight);
|
||||
// the SECRET sits behind the highest-DC Perception with its loot bias
|
||||
// preserved.
|
||||
//
|
||||
// N5/D4 adds a fourth upper_hall spoke: the Sealed Reliquary, a cross-zone
|
||||
// key vault (LockKey "sunken sigil", earned in the Sunken Temple's Coral
|
||||
// Reliquary). A one-node loot shortcut back to spire_corridor — reachable
|
||||
// only by a player who found the sigil, so it never shows on the longest
|
||||
// path and the 23-node band is unchanged.
|
||||
|
||||
func zoneManorBlackspireGraph() ZoneGraph {
|
||||
nodes := []ZoneNode{
|
||||
@@ -108,6 +114,12 @@ func zoneManorBlackspireGraph() ZoneGraph {
|
||||
{NodeID: "manor_blackspire.reliquary_dust", Kind: NodeKindExploration,
|
||||
Label: "Reliquary Dust", PosX: 18, PosY: 2},
|
||||
|
||||
// upper_hall — cross-zone key spoke (N5/D4). A Sunken Temple sigil
|
||||
// opens this vault; a loot-rich shortcut back to the spire corridor.
|
||||
{NodeID: "manor_blackspire.sealed_reliquary", Kind: NodeKindSecret,
|
||||
Label: "Sealed Reliquary", PosX: 16, PosY: -2,
|
||||
Content: ZoneNodeContent{LootBias: 2.2}},
|
||||
|
||||
// upper_hall — level-min spoke (tower).
|
||||
{NodeID: "manor_blackspire.tower_observatory", Kind: NodeKindExploration,
|
||||
Label: "Tower Observatory", PosX: 16, PosY: 4},
|
||||
@@ -174,6 +186,9 @@ func zoneManorBlackspireGraph() ZoneGraph {
|
||||
{From: "manor_blackspire.upper_hall", To: "manor_blackspire.tower_observatory",
|
||||
Lock: LockLevelMin, LockData: map[string]any{"min_level": 7},
|
||||
Hint: "a spiral stair that creaks ominously — climb only if you trust your footing", Weight: 3},
|
||||
{From: "manor_blackspire.upper_hall", To: "manor_blackspire.sealed_reliquary",
|
||||
Lock: LockKey, LockData: map[string]any{"key_id": "sunken sigil"},
|
||||
Hint: "a panel scored with a drowned god's mark — you'd need its twin to open it", Weight: 4},
|
||||
|
||||
// Master Bedroom spoke (elite).
|
||||
{From: "manor_blackspire.master_bedroom", To: "manor_blackspire.grim_balcony", Lock: LockNone},
|
||||
@@ -185,6 +200,9 @@ func zoneManorBlackspireGraph() ZoneGraph {
|
||||
{From: "manor_blackspire.choir_balcony", To: "manor_blackspire.reliquary_dust", Lock: LockNone},
|
||||
{From: "manor_blackspire.reliquary_dust", To: "manor_blackspire.spire_corridor", Lock: LockNone},
|
||||
|
||||
// Sealed Reliquary spoke (cross-zone key — Sunken Sigil).
|
||||
{From: "manor_blackspire.sealed_reliquary", To: "manor_blackspire.spire_corridor", Lock: LockNone},
|
||||
|
||||
// Tower spoke.
|
||||
{From: "manor_blackspire.tower_observatory", To: "manor_blackspire.spiral_ascent", Lock: LockNone},
|
||||
{From: "manor_blackspire.spiral_ascent", To: "manor_blackspire.telescope_attic", Lock: LockNone},
|
||||
|
||||
@@ -12,22 +12,24 @@ func TestManorBlackspireGraph_Registered(t *testing.T) {
|
||||
}
|
||||
// Long-expedition D1-c widened this zone from 11 → 35 nodes so the
|
||||
// longest entry→boss walk lands in the T3 [22,26] traversal band.
|
||||
if len(g.Nodes) != 35 {
|
||||
t.Errorf("nodes = %d, want 35", len(g.Nodes))
|
||||
// N5/D4 added the Sealed Reliquary cross-zone vault → 36.
|
||||
if len(g.Nodes) != 36 {
|
||||
t.Errorf("nodes = %d, want 36", len(g.Nodes))
|
||||
}
|
||||
}
|
||||
|
||||
// TestManorBlackspireGraph_TwoStackedThreeWayForks captures the design
|
||||
// shape: both great_hall and upper_hall expose three options.
|
||||
func TestManorBlackspireGraph_TwoStackedThreeWayForks(t *testing.T) {
|
||||
// TestManorBlackspireGraph_StackedForks captures the design shape: great_hall
|
||||
// exposes three options; upper_hall exposes four since N5/D4 added the Sealed
|
||||
// Reliquary key spoke (the other three remain the elite/secret/tower gates).
|
||||
func TestManorBlackspireGraph_StackedForks(t *testing.T) {
|
||||
g := zoneManorBlackspireGraph()
|
||||
for _, hub := range []string{
|
||||
"manor_blackspire.great_hall",
|
||||
"manor_blackspire.upper_hall",
|
||||
} {
|
||||
outs := g.outgoingEdges(hub)
|
||||
if len(outs) != 3 {
|
||||
t.Errorf("%s outgoing = %d, want 3", hub, len(outs))
|
||||
want := map[string]int{
|
||||
"manor_blackspire.great_hall": 3,
|
||||
"manor_blackspire.upper_hall": 4,
|
||||
}
|
||||
for hub, n := range want {
|
||||
if outs := g.outgoingEdges(hub); len(outs) != n {
|
||||
t.Errorf("%s outgoing = %d, want %d", hub, len(outs), n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +45,7 @@ func TestManorBlackspireGraph_AllSpokesReachBoss(t *testing.T) {
|
||||
"manor_blackspire.master_bedroom",
|
||||
"manor_blackspire.hidden_oratory",
|
||||
"manor_blackspire.tower_observatory",
|
||||
"manor_blackspire.sealed_reliquary",
|
||||
} {
|
||||
if !reachable(g, leaf, "manor_blackspire.boss") {
|
||||
t.Errorf("%s unreachable to boss", leaf)
|
||||
|
||||
@@ -61,6 +61,15 @@ package plugin
|
||||
// Garrison) and the multi-region transition a teeth-y interrupt.
|
||||
// The deep_chasm spur stays anchor-light (harvest, no second trap/elite)
|
||||
// so the CON-gated climb keeps its "denser loot, less fighting" identity.
|
||||
//
|
||||
// N5/D4 turns throne_gallery (R4 tail, which every arm passes through) into
|
||||
// a fork with two secret spokes, both merging to throne_steps so either is
|
||||
// length-neutral vs the inner-hall path:
|
||||
// - Lost Reliquary — the Underdark's own perception-gated secret (DC 17).
|
||||
// - Sealed Vault — the cross-zone key vault opened by an Underforge Seal
|
||||
// (earned in the Underforge's Forge Vault). LockKey "underforge seal".
|
||||
// Placed on the universal R4 tail, not in an arm, so the secrets are
|
||||
// reachable no matter which region the walk took.
|
||||
|
||||
func zoneUnderdarkGraph() ZoneGraph {
|
||||
r1 := "underdark_surface_tunnels"
|
||||
@@ -163,10 +172,22 @@ func zoneUnderdarkGraph() ZoneGraph {
|
||||
Label: "Guard Post", PosX: 24, PosY: 2},
|
||||
{NodeID: "underdark.throne_doors", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Riven Doors", PosX: 25, PosY: 2},
|
||||
{NodeID: "underdark.throne_gallery", Kind: NodeKindExploration, RegionID: r4,
|
||||
{NodeID: "underdark.throne_gallery", Kind: NodeKindFork, RegionID: r4,
|
||||
Label: "Throne Gallery", PosX: 26, PosY: 2},
|
||||
{NodeID: "underdark.throne_inner_hall", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Inner Hall", PosX: 27, PosY: 2},
|
||||
|
||||
// N5/D4 secret spokes off the throne gallery (both merge to
|
||||
// throne_steps, so either detour is length-neutral vs the inner-hall
|
||||
// path). lost_reliquary is the Underdark's own perception-gated secret;
|
||||
// sealed_forge_vault is the cross-zone key vault opened by an Underforge
|
||||
// Seal (earned in the Underforge's Forge Vault).
|
||||
{NodeID: "underdark.lost_reliquary", Kind: NodeKindSecret, RegionID: r4,
|
||||
Label: "Lost Reliquary", PosX: 27, PosY: 0,
|
||||
Content: ZoneNodeContent{LootBias: 2.0}},
|
||||
{NodeID: "underdark.sealed_forge_vault", Kind: NodeKindSecret, RegionID: r4,
|
||||
Label: "Sealed Vault", PosX: 27, PosY: 4,
|
||||
Content: ZoneNodeContent{LootBias: 2.2}},
|
||||
{NodeID: "underdark.throne_steps", Kind: NodeKindExploration, RegionID: r4,
|
||||
Label: "Throne Steps", PosX: 28, PosY: 2},
|
||||
{NodeID: "underdark.boss", Kind: NodeKindBoss, IsBoss: true, RegionID: r4,
|
||||
@@ -232,7 +253,15 @@ func zoneUnderdarkGraph() ZoneGraph {
|
||||
{From: "underdark.throne_antechamber", To: "underdark.throne_guard_post", Lock: LockNone},
|
||||
{From: "underdark.throne_guard_post", To: "underdark.throne_doors", Lock: LockNone},
|
||||
{From: "underdark.throne_doors", To: "underdark.throne_gallery", Lock: LockNone},
|
||||
{From: "underdark.throne_gallery", To: "underdark.throne_inner_hall", Lock: LockNone},
|
||||
{From: "underdark.throne_gallery", To: "underdark.throne_inner_hall", Lock: LockNone, Weight: 1},
|
||||
{From: "underdark.throne_gallery", To: "underdark.lost_reliquary",
|
||||
Lock: LockPerception, LockData: map[string]any{"dc": 17},
|
||||
Hint: "a side-shrine still lit, off the gallery's blind end", Weight: 2},
|
||||
{From: "underdark.throne_gallery", To: "underdark.sealed_forge_vault",
|
||||
Lock: LockKey, LockData: map[string]any{"key_id": "underforge seal"},
|
||||
Hint: "a deep-road door branded by a forge you may have passed", Weight: 3},
|
||||
{From: "underdark.lost_reliquary", To: "underdark.throne_steps", Lock: LockNone},
|
||||
{From: "underdark.sealed_forge_vault", To: "underdark.throne_steps", Lock: LockNone},
|
||||
{From: "underdark.throne_inner_hall", To: "underdark.throne_steps", Lock: LockNone},
|
||||
{From: "underdark.throne_steps", To: "underdark.boss", Lock: LockNone},
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ func TestUnderdarkGraph_Registered(t *testing.T) {
|
||||
}
|
||||
// Long-expedition D1-d widened this zone from 10 → 46 nodes so the
|
||||
// longest entry→boss walk lands in the T4 [28,34] traversal band.
|
||||
if len(g.Nodes) != 46 {
|
||||
t.Errorf("nodes = %d, want 46", len(g.Nodes))
|
||||
// N5/D4 added two throne-gallery secret spokes (Lost Reliquary +
|
||||
// Sealed Vault) → 48.
|
||||
if len(g.Nodes) != 48 {
|
||||
t.Errorf("nodes = %d, want 48", len(g.Nodes))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
9
main.go
9
main.go
@@ -13,6 +13,7 @@ import (
|
||||
"gogobee/internal/bot"
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/dreamclient"
|
||||
"gogobee/internal/peteclient"
|
||||
"gogobee/internal/plugin"
|
||||
"gogobee/internal/util"
|
||||
"gogobee/internal/version"
|
||||
@@ -55,6 +56,11 @@ func main() {
|
||||
}
|
||||
db.RecordStartup(version.Version, version.Commit)
|
||||
|
||||
// Pete adventure-news seam: wire the outbound fact client from the
|
||||
// environment (no-op unless FEATURE_PETE_NEWS=true). The background sender
|
||||
// is started once the run context exists (below).
|
||||
peteclient.Init()
|
||||
|
||||
// Create Matrix session. AUTH_MODE selects the transport:
|
||||
// masdevice (default) — MAS OAuth device grant over /sync.
|
||||
// appservice — as_token auth over Synapse transaction push.
|
||||
@@ -97,6 +103,9 @@ func main() {
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Drain the Pete adventure-news emit queue in the background until shutdown.
|
||||
peteclient.StartSender(ctx)
|
||||
|
||||
// Show the bot online from boot (appservice mode; no-op under masdevice, where
|
||||
// /sync refreshes presence implicitly). Safe before the listener — outbound only.
|
||||
sess.StartPresence(ctx)
|
||||
|
||||
Reference in New Issue
Block a user