11 Commits

Author SHA1 Message Date
prosolis
8122973b74 Link thumbnails: post og:image previews + WOTD default-off
URL link previews now post the page's og:image as an inline m.image
thumbnail above the title/description reply. All outbound fetches in the
preview path (page scrape, image HEAD probe, image download) route through
a new SSRF/DoS-hardened safehttp client so a posted link can't steer
fetches at loopback/RFC1918/cloud-metadata IPs or OOM the parser.

Also flips the daily WOTD auto-post to opt-in (ENABLE_WOTD_POST=true)
instead of opt-out.
2026-06-24 22:00:09 -07:00
prosolis
cbfca525f5 Lift caster trailers: concentration re-tick + Josie caster-aid bootstraps
Diagnosed a cleric "death loop" (L14 dying at T2/T3 bosses while
over-levelled): the boss isn't overtuned — caster sustained DPS is
under-delivered, compounded by a fragile healer build.

Engine fix — concentration AOE re-tick:
- Concentration damage spells (spirit_guardians, heat_metal, spike_growth,
  call_lightning, flaming_sphere) now tick the enemy every round at
  round_end instead of resolving as a one-shot, via a new
  CombatStatuses.ConcentrationDmg armed on cast and round-tripped through
  the turn engine. Closes the long-tracked turn-engine concentration gap;
  the burst still lands the casting round, then the aura lingers.
- Sim picker skips re-casting an already-active aura (models competent play
  and prevents a burst+aura double-dip). Re-baseline (n=30 sweep + n=100
  confirm): bard +47pp T3 (heat_metal), druid +3-7, cleric/mage flat,
  fighter unchanged — no regressions.

Player-data bootstraps (idempotent, run once on Init):
- bootstrapCasterSpellBackfill: ensureSpellsForCharacter only seeds an empty
  book, so defaults added after a character's roll never reach it. Backfill
  missing defaults into known+prepared for existing casters (gives the
  affected cleric inflict_wounds + a working healing_word, since her
  healing_word_spell is a dead alias).
- bootstrapGrantStarterPet: one-off L10 pet for an endgame player who never
  got the morning arrival roll; adds per-round proc damage + deflect.
- TestScenario_JosieCasterAid verifies both against a copy of the live DB,
  incl. idempotency.

Also fix a pre-existing wall-clock flake in
TestFireBriefings_EventAnchoredActivePlayerDelivers (start_date defaulted to
real now, filtering the row out when the suite runs after 06:00 UTC).
2026-06-18 06:34:16 -07:00
prosolis
f4a39b46e9 Fix expedition soft-lock: extract on run idle-timeout + auto-pick stale forks
getActiveZoneRun's 24h stale-run reaper abandoned the run but left the
wrapping expedition status='active' pointing at a dead run. The autopilot
then read run==nil and bailed while briefing/recap tickers kept firing, so
the player soft-locked at the last fork with no way to route on. The reaper
now force-extracts the wrapping active expedition (run-loss seam) when it
reaps that expedition's current run.

Root cause was a background fork: the autopilot can't pick for the player,
so the run idled all the way to the reaper. Add an 8h forkAutoPickTimeout —
a background fork left unanswered that long now auto-picks the first
unlocked route (same path as !zone go) and keeps walking; all-locked forks
are left for the player. Foreground !expedition run still always prompts.

Tests: idle-timeout extracts the expedition; stale fork takes the available
route; all-locked fork stays intact.
2026-06-18 00:28:05 -07:00
prosolis
6a47be34bc Honor IGNORED_BOTS env var: global sender ignore at dispatch
IGNORED_BOTS was set in .env but never read by any code; only the
URL-preview plugin filtered, and via a different unset var
(URL_PREVIEW_IGNORE_USERS). Parse IGNORED_BOTS in NewRegistry and
short-circuit DispatchMessage/DispatchReaction so ignored senders
(e.g. @pete:parodia.dev) are dropped before any plugin runs.
2026-06-05 19:26:49 -07:00
prosolis
667f87f9d0 Fix SQL errors for D&D-only players: seed player_meta on setup confirm
!setup confirm wrote only dnd_character, never the canonical player_meta
seed row. A player who built a character via !setup and played purely in
the D&D/expedition system (which writes inventory directly, bypassing
ensureCharacter) ended up with no player_meta — so every legacy-layer
command's loadAdvCharacter returned 'sql: no rows in result set'.

dndSetupConfirm now routes through ensureCharacter (which seeds
player_meta + tier-0 equipment on first contact) instead of a bare
loadAdvCharacter.

Also fix !wotd force: it was deleting from a non-existent
job_completed/job_key table (silent no-op that left forced re-posts
blocked by the stale dedup row). Now clears the real daily_prefetch
rows and checks the error.
2026-06-03 17:23:53 -07:00
prosolis
1b8d13e0dd J3 D11: T5 difficulty lift (leaders-define-band) + empty-EnemyID combat guard
Boss-only tuning at the L15/L16 mid-range (D8-f #2 had tested T5 at the L12
floor where everything walls). The two T5 zones needed opposite treatment:

- dragons_lair (infernax): impossible wall, leaders 0-2% -> HP 546->405,
  AC 22->20, frightful-presence stun 0.80->0.40, multiattack 49->42.
- abyss_portal (belaxath): leader faceroll 88-92% -> HP 262->300, AC 19->20,
  multiattack 40->41.

Final n=50: leaders 61% at L15 (both zones), 72-79% at L16 -- same
leaders-define-band shape as T4; casters ~0% (J3 caster-track gap, not
monster-tunable). Bosses are the sole binding lever (every run decided at the
boss; standard/elite pools already survivable).

Also harden handleFightCmd: a malformed bestiary entry with an empty ID was
silently persisting an enemy-less combat session that spun to autoDriveCombat's
200-round cap. Now treated as a bestiary miss (fail loud). Writeup:
sim_results/t5_findings.md (gitignored).
2026-05-28 21:55:34 -07:00
prosolis
d80b437525 J3 D10: T4/T5 anchor-room variety (2nd elite + trap per zone)
In-place node kind swaps on fork branches — no node added/removed, longest
path unchanged — adding one Elite + one Trap of anchor variety to each T4/T5
zone, placed to keep fork-choice risk/loot asymmetric:

  underdark   +Elite drow_gate (R2->R4 region-guardian)  +Trap silenced_chamber
  feywild     +Elite singing_orchard (grove branch)      +Trap mire_steps (marsh)
  dragons_lair+Elite coin_strewn_hall (treasure spur)    +Trap hidden_passage (hoard spur)
  abyss_portal+Elite wardens_hall (R3 guardian)          +Trap hush_corridor, seam_threshold

abyss_portal shipped with zero trap nodes; D10 gives it two. A/B sim corpus
(n=480/side): boss-clear flat (+1.5pp aggregate, per-cell within n=15 noise),
median day-counts stable, combats/run rose where anchors hit common paths.
New Test*Graph_*Anchors tests lock the per-zone trap/elite counts.
2026-05-28 20:00:09 -07:00
prosolis
4934383a9a J3 D8-f #2: T4 difficulty lift (leaders-define-band) + feywild fork1 soft-lock fix
T4 monster tuning so martial leaders land in the 60-75% band (underdark
59/80, feywild 65/84 at L10/L12, n=50); casters trail (class-side gap that
monster tuning provably can't compress -- Pass 1 showed casters pinned at 0%
while martials moved). T5 deferred (walls everyone at its L12 floor; needs an
L15-16 corpus).

- dnd_bestiary.go: underdark elite/boss HP+AC up + caster-lethal proc cuts
  (mind_flayer/drow_mage/roper); feywild HP+AC up.
- bestiary_srd.go: feywild multiattack profiles (fomorian/night_hag/green_hag)
  + Thornmother 2->3 lashes -- HP/AC alone didn't move feywild's low-damage
  roster; multiattack is what pulls facerolling martials into band.
- zone_graph_feywild_crossing.go: free fork1's marsh edge. fork1 was the only
  fork in the game with every exit skill-locked (CHA+Perception, no LockNone)
  and deterministic no-retry rolls -- a prod SOFT-LOCK stranding ~60% of
  players (low CHA+WIS). Kept grove's CHA bargain as a bonus route.
- expedition_sim.go: firstUnlockedForkChoice -- sim fork policy picks the
  first unlocked option instead of blind 'go 1' (which looped forever on
  locked forks); halts fork_all_locked if none.
- tests: graph-wide TestZoneGraphs_NoSoftLockedFork + fork1 free-path guard.

Writeup: sim_results/d8f_findings.md
2026-05-28 19:02:58 -07:00
prosolis
a46b773750 J3 D8-f #1: route prod autopilot boss/elite through the turn engine
Prod autopilot resolved boss/elite fights inline via SimulateCombat, which
swings the enemy once per round (Combatant has no ID to look up the SRD
multiattack profile). Manual !fight uses the turn engine, which loops the
full profile — so autopilot players faced strictly weaker bosses than
manual. D8-e confirmed this is the gap, not a turn-engine artifact.

- Promote the sim's autoResolveCombat/simPickCombatAction to shared plugin
  methods autoDriveCombat/pickAutoCombatAction (single source of truth; the
  sim now calls the same code prod does).
- Add MessageContext.Silent + a replyDM helper; the turn-engine combat
  handlers route their DMs through it so the background autopilot can drive
  the real !fight/!attack engine without spamming a DM per round (the EoD
  digest summarizes the outcome).
- tryAutoRun now calls runAutopilotWalkDriven (inlineBossCombat flipped
  true->false): walk->fight->walk loop so one tick still covers ~autoRunRoomCap
  rooms, but boss AND elite now face the player's full kit against the
  enemy's full multiattack. Loss surfaces as stopEnded (run already
  force-extracted by finishCombatSession).

Trash mobs stay on the fast inline path. GOGOBEE_SIM_INLINE_BOSS=1 A/B
toggle preserved. Build + plugin tests green; sim smoke-run unchanged.
2026-05-28 15:30:48 -07:00
prosolis
b80de43db1 J3 D8-e: GOGOBEE_SIM_INLINE_BOSS toggle for engine A/B
Diagnostic env toggle (off by default) routing the sim's boss/elite
doorways through the inline SimulateCombat path instead of the turn
engine, for A/B-ing the martial T4/T5 'regression'. D8-e confirmed the
gap is honest multiattack math (inline swings the enemy once/round; the
turn engine loops the full SRD profile) and that prod autopilot is
secretly easier than manual !fight. Toggle left in for the D8-f parity
work.
2026-05-28 15:08:16 -07:00
prosolis
81dda51907 J3 D8-d-fix: caster AC floor + HP ×1.25
computeAC: lift caster floors (cleric/druid 3→5, bard/warlock 1→3,
mage/sorcerer 0→2). Ranger 3 / rogue 1 / fighter+paladin 6 unchanged.

computeMaxHP: new casterHPMult(class) = 1.25 for the same caster set,
applied multiplicatively on top of phase5BHPMult. Martials unchanged.
New caster L10 HPMax: mage/sorc ~98 (was 78), bard/cleric/druid/warlock
~118 (was 95); martial L10 ~141 unchanged.

bootstrap_caster_hp.go: new bootstrapCasterHPRefresh (job key
caster_hp_refresh_v1) refreshes existing rows once at startup; mirrors
bootstrapPhase5BHPRefresh — only raises HPMax, preserves absolute
wound. Wired in adventure.go.

Measured on n=2500 d8dfix2 corpus (gitignored sim_results) vs d8c:
- T3 manor caster lift confirmed — bard +13 (43→56), druid +8 (88→96),
  warlock +8, sorcerer +5, mage +5, cleric +2. Martials flat.
- Macro deltas: bard +2.6, warlock +2.0, druid +1.4, mage/sorcerer +1.0,
  cleric -0.2. n=500 1σ ≈ 2.2pp — bard/warlock cleanly past noise.
- T4 underdark wall still holds (every caster ~0%). Lift directionally
  correct but undersized for T4 atk-bonus + multiattack — separate
  follow-up.
- T5 dragons_lair universal wall unchanged (martials TPK too).

Diagnostic context: project_d8d_diagnostic. Cleric flat manor likely
the concentration AOE re-tick engine gap (filed for D8-e+).
2026-05-28 09:35:04 -07:00
41 changed files with 2077 additions and 209 deletions

View File

@@ -7,6 +7,11 @@ BOT_DISPLAY_NAME=GogoBee
# Which rooms the bot posts scheduled content to (comma-separated room IDs)
BROADCAST_ROOMS=!roomid:example.com
# The daily 08:00 WOTD auto-post is disabled by default.
# Set to true to enable it. The !wotd command and passive WOTD
# usage tracking work regardless of this setting.
ENABLE_WOTD_POST=false
# Admins who can run admin-only commands (comma-separated Matrix user IDs)
ADMIN_USERS=@yourmxid:example.com

View File

@@ -2,6 +2,8 @@ package bot
import (
"log/slog"
"os"
"strings"
"sync"
"gogobee/internal/plugin"
@@ -11,11 +13,22 @@ import (
type Registry struct {
mu sync.RWMutex
plugins []plugin.Plugin
ignoredBots map[string]struct{}
}
// NewRegistry creates an empty plugin registry.
// NewRegistry creates an empty plugin registry. Senders listed in the
// IGNORED_BOTS env var (comma-separated full Matrix user IDs, e.g.
// "@pete:parodia.dev") are dropped before any plugin sees them.
func NewRegistry() *Registry {
return &Registry{}
ignored := make(map[string]struct{})
if raw := os.Getenv("IGNORED_BOTS"); raw != "" {
for _, u := range strings.Split(raw, ",") {
if u = strings.TrimSpace(u); u != "" {
ignored[u] = struct{}{}
}
}
}
return &Registry{ignoredBots: ignored}
}
// Register adds a plugin to the registry.
@@ -42,6 +55,9 @@ func (r *Registry) Init() error {
// DispatchMessage sends a message context to all plugins in order.
func (r *Registry) DispatchMessage(ctx plugin.MessageContext) {
if _, ok := r.ignoredBots[string(ctx.Sender)]; ok {
return
}
r.mu.RLock()
defer r.mu.RUnlock()
for _, p := range r.plugins {
@@ -71,6 +87,9 @@ func (r *Registry) safeOnMessage(p plugin.Plugin, ctx plugin.MessageContext) {
// DispatchReaction sends a reaction context to all plugins in order.
func (r *Registry) DispatchReaction(ctx plugin.ReactionContext) {
if _, ok := r.ignoredBots[string(ctx.Sender)]; ok {
return
}
r.mu.RLock()
defer r.mu.RUnlock()
for _, p := range r.plugins {

View File

@@ -333,6 +333,9 @@ func runMigrations(d *sql.DB) error {
// engages when a fork / elite / boss / supply pinch actually
// needs a decision. CAS-claim on this column gates re-entry.
`ALTER TABLE dnd_expedition ADD COLUMN last_autorun_at DATETIME`,
// URL link previews now post the page's og:image/twitter:image
// thumbnail; cache it alongside the title/description.
`ALTER TABLE url_cache ADD COLUMN image_url TEXT NOT NULL DEFAULT ''`,
}
for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil {
@@ -1124,6 +1127,7 @@ CREATE TABLE IF NOT EXISTS url_cache (
url TEXT PRIMARY KEY,
title TEXT DEFAULT '',
description TEXT DEFAULT '',
image_url TEXT NOT NULL DEFAULT '',
cached_at INTEGER DEFAULT (unixepoch())
);

View File

@@ -220,6 +220,16 @@ func (p *AdventurePlugin) Init() error {
// migration walks dnd_character once at startup; idempotent via
// JobCompleted gate.
bootstrapPhase5BHPRefresh()
// J3 D8-d-fix: caster HP lift (casterHPMult in dnd.go). Refresh
// existing caster rows once at startup so the lift reaches live
// players without waiting for level-up.
bootstrapCasterHPRefresh()
// 2026-06-18 caster-aid: backfill default spells that postdate a
// character's roll (ensureSpellsForCharacter only seeds an empty book),
// and a one-off pet gift for an endgame player who never got the morning
// arrival roll. Both idempotent via JobCompleted gates.
bootstrapCasterSpellBackfill()
bootstrapGrantStarterPet()
// 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

View File

@@ -83,18 +83,19 @@ var srdProfiles = map[string]SRDProfile{
{Name: "Scourge", AttackBonus: 9, Damage: 13},
{Name: "Scourge", AttackBonus: 9, Damage: 12},
}},
"boss_thornmother": {Attacks: []SRDAttack{ // Attack 18 → ~23
{Name: "Thorned Lash", AttackBonus: 8, Damage: 12},
{Name: "Thorned Lash", AttackBonus: 8, Damage: 11},
"boss_thornmother": {Attacks: []SRDAttack{ // D8-f #2: 2→3 lashes, ~23→~36 (faceroll → band)
{Name: "Thorned Lash", AttackBonus: 9, Damage: 13},
{Name: "Thorned Lash", AttackBonus: 9, Damage: 12},
{Name: "Thorned Lash", AttackBonus: 9, Damage: 11},
}},
"boss_infernax": {Attacks: []SRDAttack{ // Attack 38 → ~49
{Name: "Bite", AttackBonus: 11, Damage: 19},
{Name: "Claw", AttackBonus: 11, Damage: 15},
{Name: "Claw", AttackBonus: 11, Damage: 15},
"boss_infernax": {Attacks: []SRDAttack{ // D11 T5 lift: 49 → ~42 (nerf; was an impossible wall at L15-16)
{Name: "Bite", AttackBonus: 11, Damage: 16},
{Name: "Claw", AttackBonus: 11, Damage: 13},
{Name: "Claw", AttackBonus: 11, Damage: 13},
}},
"boss_belaxath": {Attacks: []SRDAttack{ // Attack 31 → ~40
"boss_belaxath": {Attacks: []SRDAttack{ // D11 T5 lift: 40 → ~41 (buff; was a leader faceroll)
{Name: "Longsword", AttackBonus: 11, Damage: 24},
{Name: "Whip", AttackBonus: 11, Damage: 16},
{Name: "Whip", AttackBonus: 11, Damage: 17},
}},
// ── Multiattack elites ───────────────────────────────────────────────
@@ -169,6 +170,24 @@ var srdProfiles = map[string]SRDProfile{
{Name: "Unarmed Strike", AttackBonus: 6, Damage: 6},
{Name: "Bite", AttackBonus: 6, Damage: 4},
}},
// ── Feywild elites (D8-f #2) ─────────────────────────────────────────
// Feywild martials facerolled at 97100% even after an HP/AC raise: the
// roster was single-attack and low-damage, so tankier monsters just took
// longer to kill. Multiattack is the lever that actually pulls leaders
// into band (mirrors underdark's drow_elite). Casters trail (by design).
"fomorian": {Attacks: []SRDAttack{ // Attack 13 → ~21 (2 big fists)
{Name: "Greatclub", AttackBonus: 9, Damage: 11},
{Name: "Greatclub", AttackBonus: 9, Damage: 10},
}},
"night_hag": {Attacks: []SRDAttack{ // Attack 8 → ~12 (claw flurry)
{Name: "Claws", AttackBonus: 7, Damage: 6},
{Name: "Claws", AttackBonus: 7, Damage: 6},
}},
"green_hag": {Attacks: []SRDAttack{ // Attack 6 → ~10 (claw flurry)
{Name: "Claws", AttackBonus: 6, Damage: 5},
{Name: "Claws", AttackBonus: 6, Damage: 5},
}},
}
// enemyAttackProfile returns the attack rolls a creature makes on its turn.

View File

@@ -0,0 +1,84 @@
package plugin
import (
"log/slog"
"gogobee/internal/db"
)
// bootstrapCasterHPRefresh recomputes hp_max for caster characters after
// the J3 D8-d-fix caster HP multiplier (casterHPMult in dnd.go) shipped.
// Without this, existing caster rows keep their pre-lift hp_max until
// the next computeMaxHP recall (level-up / reset). Mirrors
// bootstrapPhase5BHPRefresh — only ever raises hp_max, preserves the
// absolute wound (delta added to hp_current, capped at new max). Run
// once per startup, idempotent via db.JobCompleted.
func bootstrapCasterHPRefresh() {
const jobName = "caster_hp_refresh_v1"
if db.JobCompleted(jobName, "once") {
return
}
d := db.Get()
rows, err := d.Query(`
SELECT user_id, class, con_score, dnd_level, hp_max, hp_current
FROM dnd_character WHERE dnd_level > 0`)
if err != nil {
slog.Error("caster hp refresh: enumerate failed", "err", err)
return
}
defer rows.Close()
type row struct {
userID string
class DnDClass
conScore int
level int
hpMax int
hpCurrent int
}
var batch []row
for rows.Next() {
var r row
var classStr string
if err := rows.Scan(&r.userID, &classStr, &r.conScore, &r.level, &r.hpMax, &r.hpCurrent); err != nil {
slog.Warn("caster hp refresh: scan failed", "err", err)
continue
}
r.class = DnDClass(classStr)
batch = append(batch, r)
}
refreshed := 0
for _, r := range batch {
if casterHPMult(r.class) == 1.0 {
continue
}
conMod := abilityModifier(r.conScore)
newMax := computeMaxHP(r.class, conMod, r.level)
if newMax <= r.hpMax {
continue
}
delta := newMax - r.hpMax
newCurrent := r.hpCurrent + delta
if newCurrent > newMax {
newCurrent = newMax
}
if newCurrent < 1 {
newCurrent = 1
}
if _, err := d.Exec(`UPDATE dnd_character
SET hp_max = ?, hp_current = ?, updated_at = CURRENT_TIMESTAMP
WHERE user_id = ?`,
newMax, newCurrent, r.userID); err != nil {
slog.Warn("caster hp refresh: update failed", "user", r.userID, "err", err)
continue
}
refreshed++
}
db.MarkJobCompleted(jobName, "once")
if refreshed > 0 {
slog.Info("caster hp refresh: refreshed caster HPMax to D8-d-fix floor", "count", refreshed)
}
}

View File

@@ -0,0 +1,144 @@
package plugin
import (
"log/slog"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// Two one-shot startup bootstraps that lift a low-DPS caster who was stuck in a
// boss-wall "death loop". Diagnosis: the boss isn't overtuned — the player is an
// over-levelled cleric whose damage output is structurally low. Two account
// gaps, fixed idempotently here so they reach the live player without a respec.
//
// 1. bootstrapCasterSpellBackfill — characters created before a default spell
// was added to defaultKnownSpells keep their original spellbook forever:
// ensureSpellsForCharacter only seeds when the known-spell list is EMPTY, so
// later default additions never reach existing casters. This backfills any
// missing default into known+prepared (e.g. inflict_wounds, added to the
// cleric defaults after the affected character was rolled). General + future
// proof — it fixes any caster with the same stale-default gap.
//
// 2. bootstrapGrantStarterPet — a targeted gift of a combat companion to a
// specific endgame player who never received the 25% morning pet-arrival
// roll. A pet adds sustained per-round damage + deflect mitigation, which
// helps caster trailers most.
// bootstrapCasterSpellBackfill adds any missing defaultKnownSpells entry to
// every existing caster as known+prepared. addKnownSpell is idempotent and
// leaves the prepared flag of already-known spells untouched (ON CONFLICT only
// refreshes source), so this only ever adds the genuinely-missing defaults.
func bootstrapCasterSpellBackfill() {
const jobName = "caster_default_spell_backfill_v1"
if db.JobCompleted(jobName, "once") {
return
}
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("bootstrap: caster spell backfill — load characters failed", "err", err)
return
}
added := 0
for _, ac := range chars {
c, err := LoadDnDCharacter(ac.UserID)
if err != nil || c == nil || !isSpellcaster(c) {
continue
}
known, err := listKnownSpells(c.UserID)
if err != nil {
slog.Warn("bootstrap: caster spell backfill — list known failed", "user", c.UserID, "err", err)
continue
}
have := make(map[string]bool, len(known))
for _, k := range known {
have[k.SpellID] = true
}
defaults := defaultKnownSpells(c.Class, c.Level)
if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster {
defaults = arcaneTricksterDefaultSpells(c.Level)
}
for _, sid := range defaults {
if have[sid] {
continue
}
if err := addKnownSpell(c.UserID, sid, "class", true); err != nil {
slog.Error("bootstrap: caster spell backfill — add failed", "user", c.UserID, "spell", sid, "err", err)
continue
}
slog.Info("bootstrap: backfilled default spell", "user", c.UserID, "spell", sid)
added++
}
}
db.MarkJobCompleted(jobName, "once")
if added > 0 {
slog.Warn("bootstrap: caster default-spell backfill complete", "spells_added", added)
}
}
// josieStarterPet identifies the one player the pet gift targets and the pet
// it grants. Kept as data so the intent is legible: this is an admin gift, not
// a game-wide policy.
var josieStarterPet = struct {
userID id.UserID
typ string
name string
level int
}{
userID: "@holymachina:parodia.dev",
typ: "dog",
name: "Biscuit",
level: 10,
}
// bootstrapGrantStarterPet gives the targeted player a combat companion if they
// have none. No-op once they have a pet (this gift, a later arrival, or one
// chased away — we don't override the player's own pet history). Idempotent via
// the job gate AND the has-pet guard.
func bootstrapGrantStarterPet() {
const jobName = "grant_starter_pet_holymachina_v1"
if db.JobCompleted(jobName, "once") {
return
}
g := josieStarterPet
char, err := loadAdvCharacter(g.userID)
if err != nil || char == nil {
// Target not present in this DB (e.g. fresh deploy) — mark done so we
// don't re-scan every startup; the gift is a one-off, not a standing rule.
db.MarkJobCompleted(jobName, "once")
return
}
if char.PetType != "" || char.PetArrived {
slog.Info("bootstrap: starter pet — target already has a pet, skipping", "user", g.userID)
db.MarkJobCompleted(jobName, "once")
return
}
char.PetType = g.typ
char.PetName = g.name
char.PetLevel = g.level
char.PetXP = 0
char.PetArrived = true
char.PetChasedAway = false
if g.level >= 10 {
// Mirror the babysit path that stamps the L10 date when a pet first
// reaches the cap, so milestone/supply-shop logic stays consistent.
char.PetLevel10Date = time.Now().UTC().Format("2006-01-02")
}
if err := saveAdvCharacter(char); err != nil {
slog.Error("bootstrap: starter pet — save failed", "user", g.userID, "err", err)
return
}
if err := upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char)); err != nil {
slog.Error("bootstrap: starter pet — player_meta mirror failed", "user", g.userID, "err", err)
return
}
db.MarkJobCompleted(jobName, "once")
slog.Warn("bootstrap: granted starter pet", "user", g.userID, "pet", g.name, "level", g.level)
}

View File

@@ -27,6 +27,19 @@ func encounterIDForRoom(roomIdx int) string {
return fmt.Sprintf("room%d", roomIdx)
}
// replyDM sends a player-facing combat reply unless ctx.Silent is set. The
// turn-engine combat commands route all their DMs through here so the
// background autopilot can drive a boss/elite fight on the real engine
// (long-expedition D8-f) without spamming the player a DM per round — the
// state mutations (HP, XP, threat, run-clear) still happen; only the
// narration is dropped. Non-silent callers (manual !fight) are unchanged.
func (p *AdventurePlugin) replyDM(ctx MessageContext, text string) error {
if ctx.Silent {
return nil
}
return p.SendDM(ctx.Sender, text)
}
// ── !fight ──────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
@@ -36,25 +49,25 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
run, err := getActiveZoneRun(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
return p.replyDM(ctx, "Couldn't read run state: "+err.Error())
}
if run == nil {
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>` first.")
return p.replyDM(ctx, "No active zone run. Use `!zone enter <id>` first.")
}
roomType := run.CurrentRoomType()
if roomType != RoomElite && roomType != RoomBoss {
return p.SendDM(ctx.Sender, "Nothing to fight here — `!fight` is for Elite and Boss rooms. Use `!zone advance`.")
return p.replyDM(ctx, "Nothing to fight here — `!fight` is for Elite and Boss rooms. Use `!zone advance`.")
}
encID := encounterIDForRoom(run.CurrentRoom)
if existing, _ := getCombatSessionForEncounter(run.RunID, encID); existing != nil {
switch existing.Status {
case CombatStatusActive:
return p.SendDM(ctx.Sender, "You're already in this fight — `!attack` or `!flee`.")
return p.replyDM(ctx, "You're already in this fight — `!attack` or `!flee`.")
case CombatStatusWon:
return p.SendDM(ctx.Sender, "You've already cleared this room. "+continueHint(ctx.Sender))
return p.replyDM(ctx, "You've already cleared this room. "+continueHint(ctx.Sender))
default:
return p.SendDM(ctx.Sender, "This fight is already over. `!zone status` for where you stand.")
return p.replyDM(ctx, "This fight is already over. `!zone status` for where you stand.")
}
}
@@ -67,27 +80,32 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
} else {
monster, ok = pickZoneEnemy(zone, run.RunID, run.CurrentRoom, true)
}
if !ok {
return p.SendDM(ctx.Sender, "_(No bestiary entry for this encounter — file a bug. `!zone abandon` to bail.)_")
if !ok || monster.ID == "" {
// monster.ID == "" guards a malformed bestiary entry (e.g. one whose
// ID field was dropped): startCombatSession would otherwise persist a
// session with an empty EnemyID, and the turn engine — having no enemy
// to resolve — spins inertly until autoDriveCombat's round cap. Fail
// loudly instead of stalling.
return p.replyDM(ctx, "_(No bestiary entry for this encounter — file a bug. `!zone abandon` to bail.)_")
}
player, enemy, _, err := p.buildZoneCombatants(ctx.Sender, monster, int(zone.Tier), run.DMMood)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't set up the fight: "+err.Error())
return p.replyDM(ctx, "Couldn't set up the fight: "+err.Error())
}
playerHP, playerMax := dndHPSnapshot(ctx.Sender)
if playerHP <= 0 {
return p.SendDM(ctx.Sender, "You're in no shape to fight. `!rest` first.")
return p.replyDM(ctx, "You're in no shape to fight. `!rest` first.")
}
enemyHP := enemy.Stats.MaxHP
sess, err := startCombatSession(ctx.Sender, run.RunID, encID, monster.ID, playerHP, playerMax, enemyHP, enemyHP)
if err != nil {
if err == ErrCombatSessionAlreadyActive {
return p.SendDM(ctx.Sender, "You're already in a fight. Finish it with `!attack` / `!flee`.")
return p.replyDM(ctx, "You're already in a fight. Finish it with `!attack` / `!flee`.")
}
return p.SendDM(ctx.Sender, "Couldn't start the fight: "+err.Error())
return p.replyDM(ctx, "Couldn't start the fight: "+err.Error())
}
// Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto
@@ -118,7 +136,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
}
b.WriteString("\n")
b.WriteString(combatTurnPrompt(sess))
return p.SendDM(ctx.Sender, b.String())
return p.replyDM(ctx, b.String())
}
// ── !attack / !flee ─────────────────────────────────────────────────────────
@@ -138,22 +156,22 @@ func (p *AdventurePlugin) handleCombatActionCmd(ctx MessageContext, action Playe
sess, err := getActiveCombatSession(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error())
return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
}
if sess == nil {
return p.SendDM(ctx.Sender, "You're not in a fight. `!fight` at an Elite or Boss room to start one.")
return p.replyDM(ctx, "You're not in a fight. `!fight` at an Elite or Boss room to start one.")
}
player, enemy, err := p.combatantsForSession(sess)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
}
events, err := runCombatRound(sess, &player, &enemy, action)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error())
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
}
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
}
// renderRoundResult turns a resolved round into the player-facing block: the
@@ -393,35 +411,35 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
sess, err := getActiveCombatSession(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error())
return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
}
if sess == nil {
// Race: the fight closed between the route check and the lock.
return p.SendDM(ctx.Sender, "You're not in a fight anymore.")
return p.replyDM(ctx, "You're not in a fight anymore.")
}
advChar, _ := loadAdvCharacter(ctx.Sender)
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
if err != nil || c == nil {
return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.")
return p.replyDM(ctx, "Couldn't load your Adv 2.0 sheet.")
}
if !isSpellcaster(c) {
return p.SendDM(ctx.Sender, fmt.Sprintf(
return p.replyDM(ctx, fmt.Sprintf(
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class)))
}
spell, slotLevel, errMsg := parseCombatCast(ctx.Sender, c, strings.TrimSpace(args))
if errMsg != "" {
return p.SendDM(ctx.Sender, errMsg)
return p.replyDM(ctx, errMsg)
}
if spell.Effect == EffectReaction {
return p.SendDM(ctx.Sender, fmt.Sprintf(
return p.replyDM(ctx, fmt.Sprintf(
"%s is a reaction spell — those still aren't usable. Pick a damage, healing, or control spell.", spell.Name))
}
player, enemy, err := p.combatantsForSession(sess)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
}
var eff *turnActionEffect
@@ -433,11 +451,11 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
applySpellBuff(spell, c, &as, &am, slotLevel)
d := diffTurnBuff(player.Stats, as, player.Mods, am)
if !d.any() {
return p.SendDM(ctx.Sender, fmt.Sprintf(
return p.replyDM(ctx, fmt.Sprintf(
"%s has no effect the turn-based engine can apply yet.", spell.Name))
}
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
return p.SendDM(ctx.Sender, msg)
return p.replyDM(ctx, msg)
}
sess.Statuses.applyBuffDelta(d)
player, enemy, err = p.combatantsForSession(sess)
@@ -445,7 +463,7 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
if spell.Level > 0 {
_ = refundSpellSlot(ctx.Sender, slotLevel)
}
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
}
label := spell.Name + " — active"
if d.heal > 0 {
@@ -458,11 +476,11 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
} else {
out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats)
if !supported {
return p.SendDM(ctx.Sender, fmt.Sprintf(
return p.replyDM(ctx, fmt.Sprintf(
"%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name))
}
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
return p.SendDM(ctx.Sender, msg)
return p.replyDM(ctx, msg)
}
eff = &turnActionEffect{
Label: out.Desc,
@@ -471,6 +489,14 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
PlayerHeal: out.PlayerHeal,
EnemySkip: out.EnemySkip,
}
// Concentration AOE damage spells linger: the burst lands this round
// (EnemyDamage) and the same value re-ticks every round_end after, via
// the engine's concentration aura. spiritual_weapon already covers the
// cleric's bonus-action half of the combo; this restores the action half.
if spell.Concentration &&
(spell.Effect == EffectDamageAuto || spell.Effect == EffectDamageSave) {
eff.ConcentrationDmg = out.EnemyDamage
}
}
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff})
@@ -478,9 +504,9 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
if spell.Level > 0 {
_ = refundSpellSlot(ctx.Sender, slotLevel)
}
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error())
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
}
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
}
// chargeSpellCost debits a spell's material component and leveled slot for a
@@ -551,37 +577,37 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
sess, err := getActiveCombatSession(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error())
return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
}
if sess == nil {
return p.SendDM(ctx.Sender, "`!consume <item>` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically.")
return p.replyDM(ctx, "`!consume <item>` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically.")
}
inv := p.loadConsumableInventory(ctx.Sender)
args = strings.TrimSpace(args)
if args == "" {
if len(inv) == 0 {
return p.SendDM(ctx.Sender, "You have no combat consumables. `!attack` / `!cast` / `!flee`.")
return p.replyDM(ctx, "You have no combat consumables. `!attack` / `!cast` / `!flee`.")
}
names := make([]string, len(inv))
for i, c := range inv {
names[i] = c.Def.Name
}
return p.SendDM(ctx.Sender, "Usage: `!consume <item>`. You're carrying: "+strings.Join(names, ", ")+".")
return p.replyDM(ctx, "Usage: `!consume <item>`. You're carrying: "+strings.Join(names, ", ")+".")
}
item, ambig := matchConsumable(inv, args)
if ambig != "" {
return p.SendDM(ctx.Sender, ambig)
return p.replyDM(ctx, ambig)
}
if item == nil {
return p.SendDM(ctx.Sender, fmt.Sprintf("No consumable matching %q in your inventory.", args))
return p.replyDM(ctx, fmt.Sprintf("No consumable matching %q in your inventory.", args))
}
def := item.Def
player, enemy, err := p.combatantsForSession(sess)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
}
eff := &turnActionEffect{Action: "use_consumable"}
@@ -600,13 +626,13 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}})
d := diffTurnBuff(player.Stats, as, player.Mods, am)
if !d.any() {
return p.SendDM(ctx.Sender, fmt.Sprintf(
return p.replyDM(ctx, fmt.Sprintf(
"**%s** has no effect the turn-based engine can apply yet.", def.Name))
}
sess.Statuses.applyBuffDelta(d)
player, enemy, err = p.combatantsForSession(sess)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
}
eff.Label = def.Name + " — active"
eff.PlayerHeal = d.heal
@@ -615,7 +641,7 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionConsume, Effect: eff})
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error())
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
}
// Round resolved and persisted — now burn the item. A removal failure here
// leaves the player a free use (logged, rare); better than charging them
@@ -623,5 +649,5 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
if rerr := removeAdvInventoryItem(item.InventoryID); rerr != nil {
slog.Error("combat: consume remove inventory item failed", "user", ctx.Sender, "item", def.Name, "err", rerr)
}
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
}

View File

@@ -332,6 +332,14 @@ type combatState struct {
// Phase 10 SUB2b — Abjuration Arcane Ward HP buffer.
arcaneWardHP int
// concentrationDmg — per-round damage of an active concentration AOE
// (Spirit Guardians et al.). Armed by a !cast of a concentration damage
// spell, ticked against the enemy every round_end until the fight ends
// or another concentration spell overwrites it. Only the turn engine
// reads it; SimulateCombat resolves whole fights in one pass and folds
// the aura's value into the picker's concentration multiplier instead.
concentrationDmg int
// Phase 13 bestiary slice 3 — stateful monster-ability effects. Each is
// armed by applyAbility and read by the shared resolution primitives, so
// both engines honour them; the turn-based engine additionally round-trips

View File

@@ -239,6 +239,9 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
case "spirit_weapon_strike":
return fmt.Sprintf(pickRand(narrativeSpiritWeapon), e.Damage)
case "concentration_tick":
return fmt.Sprintf(pickRand(narrativeConcentrationTick), e.Damage)
case "pet_deflect":
return pickRand(narrativePetDeflect)
@@ -536,6 +539,13 @@ var narrativeSpiritWeapon = []string{
"✨ The spectral blade flickers, then bites. %d damage. It does not tire. It does not blink.",
}
var narrativeConcentrationTick = []string{
"🌀 The lingering aura grinds the enemy down — %d damage. Still humming. Still hungry.",
"🌀 Your spell hasn't let go: the spirits sweep through again for %d damage.",
"🌀 The radiant field pulses once more — %d damage. Concentration holds.",
"🌀 The enemy steps wrong and the standing magic answers, %d damage. It does not move on.",
}
var narrativePetDeflect = []string{
"🐾 Your pet intercepts the blow. Damage halved. Your pet is now your best piece of equipment.",
"🐾 Your pet pushes you aside at the last second. Impact reduced. You did not ask to be pushed. Results speak for themselves.",

View File

@@ -77,6 +77,15 @@ type CombatStatuses struct {
ArcaneWardHP int `json:"arcane_ward_hp,omitempty"`
HealChargesLeft int `json:"heal_charges_left,omitempty"`
// ConcentrationDmg is the per-round damage of the player's active
// concentration AOE (Spirit Guardians, Spike Growth, Call Lightning,
// Flaming Sphere…). A one-shot !cast lands its burst the casting round,
// then this re-ticks the aura at round_end every round until the fight
// ends or another concentration spell overwrites it — the lingering half
// of the spell the engine used to drop on the floor, which left clerics
// and druids with no sustained DPS once their burst landed.
ConcentrationDmg int `json:"concentration_dmg,omitempty"`
// Once-per-fight class/race/subclass one-shots: the "already used" flags.
// Without persistence these reset every round on resume, letting a Halfling
// reroll a nat 1 or an Orc rage every single round of a turn-based fight.

View File

@@ -263,6 +263,74 @@ func TestTurnEngine_PoisonTickCanBeLethal(t *testing.T) {
}
}
func TestTurnEngine_ConcentrationTickAtRoundEnd(t *testing.T) {
sess := turnSession(CombatPhaseRoundEnd, 50, 80)
sess.Statuses = CombatStatuses{ConcentrationDmg: 12}
player, enemy := basePlayer(), baseEnemy()
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
if err != nil {
t.Fatal(err)
}
if sess.EnemyHP != 68 {
t.Errorf("enemy_hp = %d, want 68 (80 - 12 aura)", sess.EnemyHP)
}
if sess.Statuses.ConcentrationDmg != 12 {
t.Errorf("concentration_dmg = %d, want 12 (aura persists)", sess.Statuses.ConcentrationDmg)
}
if len(events) != 1 || events[0].Action != "concentration_tick" || events[0].Damage != 12 {
t.Errorf("expected one concentration_tick event, got %+v", events)
}
if sess.Round != 2 || sess.Phase != CombatPhasePlayerTurn {
t.Errorf("round=%d phase=%q, want 2/player_turn", sess.Round, sess.Phase)
}
}
func TestTurnEngine_ConcentrationTickCanBeLethal(t *testing.T) {
sess := turnSession(CombatPhaseRoundEnd, 50, 9)
sess.Statuses = CombatStatuses{ConcentrationDmg: 12}
player, enemy := basePlayer(), baseEnemy()
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
t.Fatal(err)
}
if sess.Status != CombatStatusWon || sess.Phase != CombatPhaseOver {
t.Errorf("status=%q phase=%q, want won/over (aura dropped enemy)", sess.Status, sess.Phase)
}
if sess.EnemyHP != 0 {
t.Errorf("enemy_hp = %d, want 0", sess.EnemyHP)
}
}
// A concentration damage cast lands its burst this round AND arms the aura so
// it re-ticks at every subsequent round_end.
func TestTurnEngine_ConcentrationCastArmsAura(t *testing.T) {
sess := turnSession(CombatPhasePlayerTurn, 50, 200)
player, enemy := basePlayer(), baseEnemy()
eff := &turnActionEffect{
Label: "Spirit Guardians", Action: "spell_cast",
EnemyDamage: 15, ConcentrationDmg: 15,
}
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff}); err != nil {
t.Fatal(err)
}
if sess.EnemyHP != 185 {
t.Errorf("enemy_hp = %d, want 185 (200 - 15 burst)", sess.EnemyHP)
}
if sess.Statuses.ConcentrationDmg != 15 {
t.Fatalf("concentration_dmg = %d, want 15 (aura armed)", sess.Statuses.ConcentrationDmg)
}
// Drive to round_end and confirm the aura bites again.
sess.Phase = CombatPhaseRoundEnd
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
t.Fatal(err)
}
if sess.EnemyHP != 170 {
t.Errorf("enemy_hp = %d, want 170 (185 - 15 aura tick)", sess.EnemyHP)
}
}
func TestTurnEngine_Flee(t *testing.T) {
sess := turnSession(CombatPhasePlayerTurn, 50, 50)
player, enemy := basePlayer(), baseEnemy()

View File

@@ -64,6 +64,12 @@ type turnActionEffect struct {
EnemyDamage int
PlayerHeal int
EnemySkip bool // control spell: enemy forfeits its attack this round
// ConcentrationDmg arms a per-round aura tick when a concentration damage
// spell is cast: EnemyDamage is the burst that lands this round, this is
// what re-ticks at every round_end after. Zero for one-shot spells; a
// non-zero value overwrites any aura already running (5e: one
// concentration at a time).
ConcentrationDmg int
}
// turnEngine wraps a combatState reconstructed from a persisted CombatSession
@@ -125,6 +131,7 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
autoCrit: sess.Statuses.AutoCritFirst,
arcaneWardHP: sess.Statuses.ArcaneWardHP,
healChargesLeft: sess.Statuses.HealChargesLeft,
concentrationDmg: sess.Statuses.ConcentrationDmg,
deathSaveUsed: sess.Statuses.DeathSaveUsed,
luckyUsed: sess.Statuses.LuckyUsed,
raged: sess.Statuses.Raged,
@@ -299,6 +306,12 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
hpCap := max(1, te.sess.PlayerHPMax-st.maxHPDrain)
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
}
// Arm / replace the concentration aura. A new concentration cast overwrites
// the old one (5e: one concentration at a time); non-concentration casts
// leave any running aura alone.
if eff.ConcentrationDmg > 0 {
st.concentrationDmg = eff.ConcentrationDmg
}
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: action,
Damage: enemyDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: eff.Label,
@@ -454,6 +467,20 @@ func (te *turnEngine) stepRoundEnd() {
}
}
}
// Concentration aura (Spirit Guardians et al.): the lingering spell bites
// the enemy each round it stays up. Ticks before enemy regen so a lethal
// pulse settles the fight before the enemy knits its wounds back.
if st.concentrationDmg > 0 && st.enemyHP > 0 {
st.enemyHP = max(0, st.enemyHP-st.concentrationDmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "player", Action: "concentration_tick",
Damage: st.concentrationDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if st.enemyHP <= 0 {
te.finish(CombatStatusWon)
return
}
}
// Regenerate (monster ability): the enemy knits its wounds at round end.
if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP {
st.enemyHP = min(te.enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen)
@@ -499,6 +526,7 @@ func (te *turnEngine) commit() {
s.AutoCritFirst = st.autoCrit
s.ArcaneWardHP = st.arcaneWardHP
s.HealChargesLeft = st.healChargesLeft
s.ConcentrationDmg = st.concentrationDmg
s.DeathSaveUsed = st.deathSaveUsed
s.LuckyUsed = st.luckyUsed
s.Raged = st.raged

View File

@@ -161,6 +161,20 @@ func abilityModifier(score int) int {
// startup to refresh stale rows.
const phase5BHPMult = 1.5
// casterHPMult is the J3 D8-d-fix caster durability lift. T4 sim showed
// caster HPMax sat ~30% below martial (~95 vs 141 at L10) and the AC-floor
// lift alone wasn't enough to crack the wall. Applied multiplicatively in
// computeMaxHP on top of phase5BHPMult so the existing Phase 5-B floor
// stays intact for martials. Refreshed for existing rows by
// bootstrapCasterHPRefresh (job key caster_hp_refresh_v1).
func casterHPMult(class DnDClass) float64 {
switch class {
case ClassCleric, ClassDruid, ClassBard, ClassWarlock, ClassMage, ClassSorcerer:
return 1.25
}
return 1.0
}
// computeMaxHP — D&D5e style. L1: full HP die + CON mod (min 1).
// Per level after: average HP die (roundup of die/2 + 1) + CON mod.
// The result is scaled by phase5BHPMult — see that constant for the
@@ -181,8 +195,8 @@ func computeMaxHP(class DnDClass, conMod, level int) int {
}
hp += gain
}
// Phase 5-B player power floor — round to nearest int.
scaled := int(float64(hp)*phase5BHPMult + 0.5)
// Phase 5-B player power floor + J3 D8-d-fix caster lift — round to nearest int.
scaled := int(float64(hp)*phase5BHPMult*casterHPMult(class) + 0.5)
if scaled < 1 {
scaled = 1
}
@@ -197,10 +211,16 @@ func computeAC(class DnDClass, dexMod int) int {
switch class {
case ClassFighter, ClassPaladin:
floor = 6 // heavy/medium-armor baseline
case ClassCleric, ClassRanger, ClassDruid:
case ClassCleric, ClassDruid:
floor = 5
case ClassRanger:
floor = 3
case ClassRogue, ClassBard, ClassWarlock:
case ClassBard, ClassWarlock:
floor = 3
case ClassRogue:
floor = 1
case ClassMage, ClassSorcerer:
floor = 2
}
return 10 + dexMod + floor
}

View File

@@ -42,6 +42,145 @@ func TestZoneRun_AutoAbandonsAfter24h(t *testing.T) {
}
}
// A run reaped by the idle timeout must also terminate the wrapping active
// expedition, or the expedition is orphaned 'active' over a dead run and the
// player soft-locks (the original feywild "stuck, can't route" bug).
func TestZoneRun_IdleTimeoutExtractsWrappingExpedition(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@orphan-run:example")
defer cleanupExpeditions(uid)
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
if err != nil {
t.Fatalf("startZoneRun: %v", err)
}
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatalf("startExpedition: %v", err)
}
if err := setExpeditionRunID(exp.ID, run.RunID); err != nil {
t.Fatalf("link run: %v", err)
}
// Backdate the run past the 24h stale threshold.
if _, err := dbExecZoneRunBackdate(run.RunID, 25*time.Hour); err != nil {
t.Fatalf("backdate: %v", err)
}
got, err := getActiveZoneRun(uid)
if err != nil {
t.Fatalf("getActiveZoneRun: %v", err)
}
if got != nil {
t.Errorf("expected nil after timeout, got run %q", got.RunID)
}
// The wrapping expedition must no longer be active.
after, err := getExpedition(exp.ID)
if err != nil {
t.Fatalf("getExpedition: %v", err)
}
if after == nil {
t.Fatal("expedition row vanished")
}
if after.Status == ExpeditionStatusActive {
t.Errorf("expedition still active after run idle-timeout; status=%q", after.Status)
}
}
// A stale background fork auto-picks the first unlocked route so the
// expedition keeps moving instead of idling out to the reaper.
func TestAutoPickStaleFork_TakesAvailableRoute(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@stale-fork:example")
defer cleanupExpeditions(uid)
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
if err != nil {
t.Fatalf("startZoneRun: %v", err)
}
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatalf("startExpedition: %v", err)
}
if err := setExpeditionRunID(exp.ID, run.RunID); err != nil {
t.Fatalf("link run: %v", err)
}
pf := pendingFork{
PendingAt: "goblin_warrens.cavern_junction",
Options: []pendingChoice{
{Index: 1, To: "goblin_warrens.guard_post", Label: "Guard Post",
Unlocked: false, Lock: "perception_check", Reason: "Perception 8 vs DC 14"},
{Index: 2, To: "goblin_warrens.kennel_path", Label: "Kennel Path",
Unlocked: true, Lock: "none"},
},
}
if err := writePendingFork(run.RunID, pf); err != nil {
t.Fatalf("writePendingFork: %v", err)
}
r2, _ := getZoneRun(run.RunID)
got, _ := decodePendingFork(r2.NodeChoices)
if got == nil {
t.Fatal("fork not persisted")
}
p := &AdventurePlugin{}
if ok := p.autoPickStaleFork(exp, r2, got); !ok {
t.Fatal("expected auto-pick to commit a route")
}
after, _ := getZoneRun(run.RunID)
if after.CurrentNode != "goblin_warrens.kennel_path" {
t.Errorf("did not advance to the unlocked route: current_node=%q", after.CurrentNode)
}
if pf2, _ := decodePendingFork(after.NodeChoices); pf2 != nil {
t.Errorf("pending fork not cleared after auto-pick")
}
}
// When every option is locked there's nothing safe to auto-pick — leave the
// fork intact for the player (or the reaper).
func TestAutoPickStaleFork_AllLockedLeavesForkIntact(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@locked-fork:example")
defer cleanupExpeditions(uid)
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
if err != nil {
t.Fatalf("startZoneRun: %v", err)
}
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatalf("startExpedition: %v", err)
}
_ = setExpeditionRunID(exp.ID, run.RunID)
pf := pendingFork{
PendingAt: "goblin_warrens.cavern_junction",
Options: []pendingChoice{
{Index: 1, To: "goblin_warrens.guard_post", Label: "Guard Post",
Unlocked: false, Lock: "perception_check", Reason: "DC 14"},
},
}
if err := writePendingFork(run.RunID, pf); err != nil {
t.Fatalf("writePendingFork: %v", err)
}
r2, _ := getZoneRun(run.RunID)
before := r2.CurrentNode
got, _ := decodePendingFork(r2.NodeChoices)
p := &AdventurePlugin{}
if ok := p.autoPickStaleFork(exp, r2, got); ok {
t.Fatal("expected no auto-pick when every option is locked")
}
after, _ := getZoneRun(run.RunID)
if after.CurrentNode != before {
t.Errorf("current_node moved on an all-locked fork: %q → %q", before, after.CurrentNode)
}
if pf2, _ := decodePendingFork(after.NodeChoices); pf2 == nil {
t.Errorf("pending fork was cleared on an all-locked fork")
}
}
func TestZoneRun_FreshRunNotAutoAbandoned(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@fresh-run:example")

View File

@@ -256,7 +256,7 @@ var _ = func() bool {
},
"green_hag": {
ID: "green_hag", Name: "Green Hag",
CR: 3, HP: 82, AC: 17, Attack: 6, AttackBonus: 5, Speed: 12,
CR: 3, HP: 105, AC: 18, Attack: 6, AttackBonus: 5, Speed: 12,
BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Invisible Passage", Phase: "any", ProcChance: 0.35, Effect: "evade"},
XPValue: 700,
@@ -463,7 +463,7 @@ var _ = func() bool {
},
"drow_elite_warrior": {
ID: "drow_elite_warrior", Name: "Drow Elite Warrior",
CR: 5, HP: 71, AC: 18, Attack: 8, AttackBonus: 7, Speed: 12,
CR: 5, HP: 95, AC: 19, Attack: 8, AttackBonus: 7, Speed: 12,
BlockRate: 0.20,
Ability: &MonsterAbility{Name: "Parry", Phase: "any", ProcChance: 0.35, Effect: "block"},
XPValue: 1800,
@@ -471,23 +471,23 @@ var _ = func() bool {
},
"drow_mage": {
ID: "drow_mage", Name: "Drow Mage",
CR: 7, HP: 45, AC: 12, Attack: 12, AttackBonus: 5, Speed: 12,
CR: 7, HP: 65, AC: 12, Attack: 12, AttackBonus: 5, Speed: 12,
BlockRate: 0.05,
Ability: &MonsterAbility{Name: "Lightning Bolt", Phase: "decisive", ProcChance: 0.45, Effect: "aoe"},
Ability: &MonsterAbility{Name: "Lightning Bolt", Phase: "decisive", ProcChance: 0.25, Effect: "aoe"},
XPValue: 2900,
Notes: "Spells: Lightning Bolt, Fly, Darkness, Faerie Fire. Magic Resistance.",
},
"mind_flayer": {
ID: "mind_flayer", Name: "Mind Flayer",
CR: 7, HP: 71, AC: 15, Attack: 12, AttackBonus: 7, Speed: 12,
CR: 7, HP: 90, AC: 15, Attack: 12, AttackBonus: 7, Speed: 12,
BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Mind Blast", Phase: "opening", ProcChance: 0.55, Effect: "stun"},
Ability: &MonsterAbility{Name: "Mind Blast", Phase: "opening", ProcChance: 0.30, Effect: "stun"},
XPValue: 2900,
Notes: "Mind Blast AoE psychic INT DC 15 or Stunned; Extract Brain instakills stunned; telepathy.",
},
"hook_horror": {
ID: "hook_horror", Name: "Hook Horror",
CR: 3, HP: 75, AC: 15, Attack: 6, AttackBonus: 6, Speed: 10,
CR: 3, HP: 95, AC: 16, Attack: 6, AttackBonus: 6, Speed: 10,
BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.30, Effect: "advantage"},
XPValue: 700,
@@ -495,15 +495,15 @@ var _ = func() bool {
},
"roper": {
ID: "roper", Name: "Roper",
CR: 5, HP: 93, AC: 20, Attack: 8, AttackBonus: 7, Speed: 4,
CR: 5, HP: 115, AC: 20, Attack: 8, AttackBonus: 7, Speed: 4,
BlockRate: 0.20,
Ability: &MonsterAbility{Name: "Reel", Phase: "any", ProcChance: 0.50, Effect: "stun"},
Ability: &MonsterAbility{Name: "Reel", Phase: "any", ProcChance: 0.30, Effect: "stun"},
XPValue: 1800,
Notes: "Elite. 6 tendrils grapple+restrain; Reel pulls 25 ft; False Appearance.",
},
"boss_ilvaras_xunyl": {
ID: "boss_ilvaras_xunyl", Name: "Ilvaras Xunyl, Drow High Priestess",
CR: 12, HP: 162, AC: 16, Attack: 19, AttackBonus: 9, Speed: 12,
CR: 12, HP: 210, AC: 18, Attack: 19, AttackBonus: 9, Speed: 12,
BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Divine Word", Phase: "decisive", ProcChance: 0.40, Effect: "execute"},
XPValue: 8400,
@@ -512,7 +512,7 @@ var _ = func() bool {
// ── Feywild Crossing ─────────────────────────────────────────────
"redcap": {
ID: "redcap", Name: "Redcap",
CR: 3, HP: 45, AC: 13, Attack: 6, AttackBonus: 6, Speed: 14,
CR: 3, HP: 60, AC: 15, Attack: 6, AttackBonus: 6, Speed: 14,
BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Outsize Strength", Phase: "any", ProcChance: 0.40, Effect: "bonus_damage"},
XPValue: 700,
@@ -520,7 +520,7 @@ var _ = func() bool {
},
"will_o_wisp": {
ID: "will_o_wisp", Name: "Will-o'-Wisp",
CR: 2, HP: 22, AC: 19, Attack: 4, AttackBonus: 4, Speed: 14,
CR: 2, HP: 30, AC: 19, Attack: 4, AttackBonus: 4, Speed: 14,
BlockRate: 0.0,
Ability: &MonsterAbility{Name: "Consume Life", Phase: "any", ProcChance: 0.40, Effect: "lifesteal"},
XPValue: 450,
@@ -528,7 +528,7 @@ var _ = func() bool {
},
"quickling": {
ID: "quickling", Name: "Quickling",
CR: 1, HP: 10, AC: 16, Attack: 2, AttackBonus: 5, Speed: 18,
CR: 1, HP: 16, AC: 16, Attack: 2, AttackBonus: 5, Speed: 18,
BlockRate: 0.05,
Ability: &MonsterAbility{Name: "Blur", Phase: "any", ProcChance: 0.50, Effect: "evade"},
XPValue: 200,
@@ -536,7 +536,7 @@ var _ = func() bool {
},
"night_hag": {
ID: "night_hag", Name: "Night Hag",
CR: 5, HP: 112, AC: 17, Attack: 8, AttackBonus: 7, Speed: 12,
CR: 5, HP: 135, AC: 18, Attack: 8, AttackBonus: 7, Speed: 12,
BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Etherealness", Phase: "any", ProcChance: 0.30, Effect: "evade"},
XPValue: 1800,
@@ -544,7 +544,7 @@ var _ = func() bool {
},
"fomorian": {
ID: "fomorian", Name: "Fomorian",
CR: 8, HP: 149, AC: 14, Attack: 13, AttackBonus: 9, Speed: 12,
CR: 8, HP: 180, AC: 16, Attack: 13, AttackBonus: 9, Speed: 12,
BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Evil Eye", Phase: "any", ProcChance: 0.45, Effect: "stun"},
XPValue: 3900,
@@ -552,7 +552,7 @@ var _ = func() bool {
},
"boss_thornmother": {
ID: "boss_thornmother", Name: "The Thornmother",
CR: 11, HP: 187, AC: 17, Attack: 18, AttackBonus: 8, Speed: 12,
CR: 11, HP: 235, AC: 18, Attack: 18, AttackBonus: 8, Speed: 12,
BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Beguiling Bargain", Phase: "opening", ProcChance: 0.50, Effect: "stun"},
XPValue: 7200,
@@ -602,9 +602,18 @@ var _ = func() bool {
},
"boss_infernax": {
ID: "boss_infernax", Name: "Infernax the Undying",
CR: 24, HP: 546, AC: 22, Attack: 38, AttackBonus: 11, Speed: 18,
// T5 lift (D11): dragons_lair was an impossible wall — at L15-16
// (mid-range) leaders cleared 0-2%, 100% TPK at the boss (it has
// 546 HP / AC22 / 49-dmg multiattack / near-guaranteed stun, a
// CR24 block fought ~9 levels under its CR). Nerfed to land
// martial leaders in the 60-75% band: HP 546→405, AC 22→20,
// frightful-presence stun 0.80→0.40, multiattack 49→42 (srd).
// (Pass 1's 360/AC20/36 over-nerfed → 98% leaders; Pass 2's
// 460/AC21/42 over-corrected → ~40%; this Pass 3 405/AC20/42
// lands leaders in band.)
CR: 24, HP: 405, AC: 20, Attack: 38, AttackBonus: 11, Speed: 18,
BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Frightful Presence", Phase: "opening", ProcChance: 0.80, Effect: "stun"},
Ability: &MonsterAbility{Name: "Frightful Presence", Phase: "opening", ProcChance: 0.40, Effect: "stun"},
XPValue: 62000,
Notes: "Dragon's Lair boss. Ancient Red Dragon. Legendary Actions; Lair Actions; phase 2 below 50% HP — fire ignores resistance.",
FireAttacker: true,
@@ -652,7 +661,15 @@ var _ = func() bool {
},
"boss_belaxath": {
ID: "boss_belaxath", Name: "Belaxath the Undivided",
CR: 19, HP: 262, AC: 19, Attack: 31, AttackBonus: 11, Speed: 14,
// T5 lift (D11): abyss_portal was a faceroll for leaders — at its
// own floor (L15) fighter/ranger cleared 88-92% (above the 60-75%
// band), killing the 262-HP boss in 6-13 rounds with HP to spare.
// Buffed to bring leaders into band: HP 262→300, AC 19→20,
// multiattack 40→41 (srd). Elites (nalfeshnee/marilith) already
// wall the trailers, so this only moves the leaders. (Pass 1's
// 360/AC20/45 over-buffed → 19-40% leaders; this is the Pass 2
// correction back down toward band.)
CR: 19, HP: 300, AC: 20, Attack: 31, AttackBonus: 11, Speed: 14,
BlockRate: 0.20,
Ability: &MonsterAbility{Name: "Lightning Discharge", Phase: "decisive", ProcChance: 0.40, Effect: "aoe"},
XPValue: 22000,

View File

@@ -2,6 +2,7 @@ package plugin
import (
"fmt"
"log/slog"
"math"
"strconv"
"strings"
@@ -667,6 +668,45 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
// run graph / harvest tally / supplies / threat — same as before, just
// no streamFlow here. compact==true switches the underlying combat
// narration into terse mode and auto-resolves elite (not boss) rooms.
// forkAutoPickTimeout — how long a background fork may sit unanswered
// before the autopilot picks an available route itself. Short enough that
// the expedition keeps moving rather than idling out to the 24h stale-run
// reaper; long enough that a player away for the evening still gets first
// say on a genuine fork.
const forkAutoPickTimeout = 8 * time.Hour
// autoPickStaleFork commits the first unlocked option of a stale background
// fork, advancing the run to that node exactly as `!zone go <n>` would
// (advanceZoneRunNode + region-transition hook). Returns false — no pick —
// when every option is locked, so the caller re-emits the prompt and the
// run idles on toward the reaper. The choice is logged as a narrative entry
// so the end-of-day digest can surface the decision the player missed.
func (p *AdventurePlugin) autoPickStaleFork(exp *Expedition, run *DungeonRun, pf *pendingFork) bool {
var chosen *pendingChoice
for i := range pf.Options {
if pf.Options[i].Unlocked {
chosen = &pf.Options[i]
break
}
}
if chosen == nil {
return false // nothing unlocked — leave it for the player / reaper
}
if err := advanceZoneRunNode(run.RunID, chosen.To); err != nil {
slog.Warn("expedition: auto-pick stale fork",
"user", run.UserID, "run", run.RunID, "err", err)
return false
}
g, _ := loadZoneGraph(run.ZoneID)
fireGraphRegionTransition(run.UserID, g.Nodes[run.CurrentNode], g.Nodes[chosen.To])
if exp != nil {
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
fmt.Sprintf("autopilot took an available path after %dh idle at the fork: %s",
int(forkAutoPickTimeout.Hours()), chosen.Label), "")
}
return true
}
func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact, inlineBossCombat bool) autopilotWalkResult {
exp, err := getActiveExpedition(ctx.Sender)
if err != nil {
@@ -679,12 +719,19 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
return autopilotWalkResult{initErr: "No active region run. Try `!region` to refresh."}
}
// Already standing at a pending fork: autopilot can't pick for the
// player. Re-emit the prompt with rooms=0 so the background DM
// suppression keeps quiet and we don't tick the rooms counter on a
// no-op walk.
// Already standing at a pending fork. The autopilot can't pick for the
// player, so a fresh fork re-emits the prompt with rooms=0 (background
// DM suppression keeps quiet; the rooms counter doesn't tick on a no-op
// walk). But a background fork left unanswered past forkAutoPickTimeout
// would otherwise idle all the way to the 24h stale-run reaper and end
// the expedition — so once it's stale, auto-pick the first available
// (unlocked) route and keep walking instead of stalling out.
if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil {
if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil {
picked := compact &&
time.Since(run.LastActionAt) > forkAutoPickTimeout &&
p.autoPickStaleFork(exp, run, pf)
if !picked {
zone := zoneOrFallback(run.ZoneID)
return autopilotWalkResult{
finalMsg: renderForkPrompt(zone, *pf),
@@ -692,6 +739,9 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
reason: stopFork,
}
}
// Auto-picked: the run now points at the chosen node. Fall
// through into the walk loop so this same tick advances it.
}
}
var stream []string

View File

@@ -239,19 +239,26 @@ func TestFireBriefings_EventAnchoredActivePlayerDelivers(t *testing.T) {
if err != nil {
t.Fatal(err)
}
useEventAnchored(t, exp)
wall := time.Now().UTC()
now := time.Date(wall.Year(), wall.Month(), wall.Day(), 6, 30, 0, 0, time.UTC)
threshold := time.Date(now.Year(), now.Month(), now.Day(),
expeditionBriefingHour, 0, 0, 0, time.UTC)
activeActivity := threshold.Add(15 * time.Minute)
priorBriefing := now.Add(-24 * time.Hour)
// Pin start_date before today's threshold. Left at the default (real
// time.Now()), a suite run after 06:00 UTC lands start_date past the
// threshold and loadExpeditionsNeedingBriefing (start_date < threshold)
// filters the row out — a wall-clock-of-day flake. useEventAnchored runs
// after, so its cutoff tracks the backdated start and the run stays
// event-anchored.
startAt := now.Add(-24 * time.Hour)
exp.StartDate = startAt
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET last_activity = ?, last_briefing_at = ? WHERE expedition_id = ?`,
activeActivity, priorBriefing, exp.ID); err != nil {
`UPDATE dnd_expedition SET start_date = ?, last_activity = ?, last_briefing_at = ? WHERE expedition_id = ?`,
startAt, activeActivity, priorBriefing, exp.ID); err != nil {
t.Fatal(err)
}
useEventAnchored(t, exp)
p := &AdventurePlugin{}
p.fireExpeditionBriefings(now)

View File

@@ -258,7 +258,13 @@ func (p *AdventurePlugin) dndSetupConfirm(ctx MessageContext) error {
// Initial D&D level seeded from existing combat_level (v1.1 §4.1).
// combat_level "freezes" thereafter — dnd_level is canonical.
advChar, _ := loadAdvCharacter(ctx.Sender)
//
// ensureCharacter (not a bare loadAdvCharacter) so the canonical
// player_meta seed row + tier-0 equipment exist for D&D-only players
// who reach !setup confirm without ever touching the legacy adventure
// path. Without this seed, loadAdvCharacter returns "sql: no rows in
// result set" on every legacy-layer command (arena, npcs, events, …).
advChar, _, _ := p.ensureCharacter(ctx.Sender)
startLevel := 1
if advChar != nil {
startLevel = dndLevelFromCombatLevel(advChar.CombatLevel)

View File

@@ -70,10 +70,10 @@ func TestComputeMaxHP_MageLevel5(t *testing.T) {
// Mage d6, CON +1
// L1: 6 + 1 = 7
// L2-5: 4 levels × (avg 4 + 1) = 4 × 5 = 20
// Raw total: 27; Phase 5-B: 27 × 1.5 = 40.5 → 41 (round half-up).
// Raw total: 27; Phase 5-B × casterHPMult: 27 × 1.5 × 1.25 = 50.625 → 51.
got := computeMaxHP(ClassMage, 1, 5)
if got != 41 {
t.Errorf("Mage L5 (CON+1) = %d, want 41 (27 raw × phase5BHPMult)", got)
if got != 51 {
t.Errorf("Mage L5 (CON+1) = %d, want 51 (27 raw × phase5BHPMult × casterHPMult)", got)
}
}
@@ -94,8 +94,12 @@ func TestComputeAC(t *testing.T) {
{ClassFighter, 0, 16}, // 10 + 0 + 6
{ClassFighter, 2, 18}, // 10 + 2 + 6
{ClassRogue, 3, 14}, // 10 + 3 + 1
{ClassMage, 0, 10}, // 10 + 0 + 0
{ClassCleric, 1, 14}, // 10 + 1 + 3
{ClassMage, 0, 12}, // 10 + 0 + 2 (D8-d-fix caster floor)
{ClassSorcerer, 1, 13}, // 10 + 1 + 2
{ClassCleric, 1, 16}, // 10 + 1 + 5 (D8-d-fix caster floor)
{ClassDruid, 0, 15}, // 10 + 0 + 5
{ClassBard, 2, 15}, // 10 + 2 + 3 (D8-d-fix caster floor)
{ClassWarlock, 0, 13}, // 10 + 0 + 3
{ClassRanger, 2, 15}, // 10 + 2 + 3
}
for _, c := range cases {

View File

@@ -477,11 +477,13 @@ func (p *AdventurePlugin) advanceOnce(ctx MessageContext) (advanceResult, error)
// inlineBossCombat (only consulted when compact==true) selects between the
// two background combat paths at a boss/elite doorway. true keeps the
// long-expedition D3 inline auto-resolve (production autorun). false
// returns stopBoss/stopElite after the safety gate so a turn-based driver
// — currently only the headless sim's autoResolveCombat / simPickCombatAction
// — handles the fight via the regular !fight / !attack engine. The sim
// uses false so simPickSpell actually fires; D8-prereq re-wired this seam.
// legacy inline auto-resolve (SimulateCombat — fast, but ignores enemy
// multiattack). false returns stopBoss/stopElite after the safety gate so
// a turn-based driver — autoDriveCombat / pickAutoCombatAction — handles
// the fight via the regular !fight / !attack engine. Both the headless sim
// and the production autorun (long-expedition D8-f) now pass false so the
// real engine (with multiattack) resolves the encounter and simPickSpell
// actually fires; D8-prereq re-wired this seam, D8-f flipped prod onto it.
func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlineBossCombat bool) (advanceResult, error) {
run, err := getActiveZoneRun(ctx.Sender)
if err != nil {
@@ -568,10 +570,10 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin
}
}
if !inlineBossCombat {
// Background caller wants to drive the fight itself (sim's
// autoResolveCombat / simPickCombatAction). Surface the
// doorway like the foreground path does, after the safety
// gate has had a chance to defer the engagement.
// Background caller wants to drive the fight itself via the
// turn engine (autoDriveCombat / pickAutoCombatAction).
// Surface the doorway like the foreground path does, after
// the safety gate has had a chance to defer the engagement.
kind := "Elite"
r := stopElite
if prev == RoomBoss {
@@ -953,6 +955,7 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo
// resolveCombatRoom spawns one roster enemy (elite filter optional),
// runs combat, persists side effects, fires nat-1/nat-20 mood deltas,
// and renders the staged narration. Returns:
//
// intro — pre-combat block (TwinBee combat-start + monster stat block)
// phases — RenderCombatLog output, streamed with delays by the caller
// outcome — post-combat block: nat20/nat1 flavor, kill line, loot, d20 summary
@@ -1287,4 +1290,3 @@ func (p *AdventurePlugin) zoneCmdAbandon(ctx MessageContext) error {
"🚪 Abandoned **%s** at room %d/%d. No rewards.",
zone.Display, run.CurrentRoom+1, run.TotalRooms))
}

View File

@@ -290,6 +290,17 @@ func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) {
}
if time.Since(r.LastActionAt) > zoneRunInactivityTimeout {
_ = abandonZoneRunByID(r.RunID)
// A run reaped by the §4.3 idle timeout must also terminate the
// wrapping active expedition. Without this, the expedition is left
// status='active' pointing at a now-abandoned run: the autopilot's
// runAutopilotWalk reads run==nil and bails, but the briefing/recap
// ambient tickers keep firing — the player soft-locks at the last
// fork, "stuck" with no way to route on. Mirror the run-loss seam,
// but only when this run is the active expedition's current run so
// a standalone (non-expedition) stale run still reaps cleanly.
if exp, _ := getActiveExpedition(userID); exp != nil && exp.RunID == r.RunID {
forceExtractExpeditionForRunLoss(userID, "run idle-timeout (§4.3 stale-run reap)")
}
return nil, nil
}
return r, nil

View File

@@ -138,6 +138,84 @@ func loadExpeditionsForAutoRun(runCutoff, ageCutoff time.Time) ([]string, error)
return ids, rows.Err()
}
// autoRunCombatSegmentCap bounds the walk→fight→walk ping-pong inside a
// single background tick. With autoRunRoomCap == 3 rooms/tick the loop
// can realistically only hit a couple doorways; the cap is a backstop
// against a pathological state where a fight wins but the next walk
// re-presents the same doorway.
const autoRunCombatSegmentCap = 8
// runAutopilotWalkDriven runs the compact background walk and, when it
// halts at a boss/elite doorway, drives that fight through the real turn
// engine (manual `!fight` parity — long-expedition D8-f) before resuming
// the walk. It loops walk→fight→walk so one tick still covers up to
// maxRooms rooms, exactly as the old inline-boss path did, but bosses now
// face the player's full kit against the enemy's full multiattack profile
// instead of the rosier inline SimulateCombat path.
//
// Combat narration is suppressed via a silent ctx — the day digest
// summarizes the outcome, matching the rest of the compact autopilot
// surface. The returned result carries the cumulative room count and the
// reason of whichever non-combat stop ended the loop.
func (p *AdventurePlugin) runAutopilotWalkDriven(ctx MessageContext, maxRooms int) autopilotWalkResult {
silent := ctx
silent.Silent = true
total := 0
for seg := 0; seg < autoRunCombatSegmentCap; seg++ {
budget := maxRooms - total
if budget <= 0 {
return autopilotWalkResult{rooms: total, reason: stopOK, finalMsg: autopilotFooter(stopOK, total)}
}
r := p.runAutopilotWalk(ctx, budget, true, false)
if r.initErr != "" {
return r
}
total += r.rooms
r.rooms = total
if r.reason != stopBoss && r.reason != stopElite {
return r
}
// Standing at an elite/boss doorway — drive the fight on the turn
// engine. handleFightCmd opens the session at the current doorway;
// autoDriveCombat loops until it resolves.
won, err := p.autoDriveCombat(silent)
if err != nil {
slog.Warn("expedition: autopilot turn-engine combat", "user", ctx.Sender, "err", err)
// Leave the doorway stop in place; the next tick retries the
// engagement after the cooldown.
return r
}
if won {
// The won session is recorded; the next walk advances the now-
// cleared room (a boss win surfaces as stopComplete, an elite as
// a normal continue). Loop.
continue
}
// Lost: the turn engine never voluntarily flees, so a non-win means
// the party fell. finishCombatSession (CombatStatusLost) already
// abandoned the run and force-extracted the expedition; surface it
// as a death so the digest + pet-emergence seam fire.
if c, _ := LoadDnDCharacter(ctx.Sender); c == nil || c.HPCurrent <= 0 {
r.reason = stopEnded
r.finalMsg = fmt.Sprintf("💀 The party fell in battle after %s. The expedition is over.", roomsWalkedPhrase(total))
return r
}
// Alive but the session didn't open / resolve to a win (rare —
// bestiary miss or stall). Leave the doorway stop; retry next tick.
return r
}
// Segment cap hit — stop cleanly rather than spin.
return autopilotWalkResult{rooms: total, reason: stopOK, finalMsg: autopilotFooter(stopOK, total)}
}
// roomsWalkedPhrase renders "N room(s)" for autopilot footers.
func roomsWalkedPhrase(rooms int) string {
if rooms == 1 {
return "1 room"
}
return fmt.Sprintf("%d rooms", rooms)
}
// tryAutoRun claims the slot for this expedition, runs one background
// walk, and DMs the result if the suppression rules say to. The CAS-
// update is the only persistent side effect on the autorun column —
@@ -170,7 +248,10 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
}
uid := id.UserID(e.UserID)
r := p.runAutopilotWalk(MessageContext{Sender: uid}, autoRunRoomCap, true, true)
// D8-f — boss/elite encounters route through the real turn engine for
// manual `!fight` parity (enemy multiattack included). Trash mobs still
// auto-resolve on the fast inline path inside the walk.
r := p.runAutopilotWalkDriven(MessageContext{Sender: uid}, autoRunRoomCap)
if r.initErr != "" {
// "no expedition" / "no run" — race with abandon/extract. Silent.
return nil

View File

@@ -14,6 +14,7 @@ package plugin
import (
"encoding/json"
"fmt"
"os"
"sort"
"time"
@@ -22,6 +23,12 @@ import (
"maunium.net/go/mautrix/id"
)
// simInlineBossCombat is a D8-e DIAGNOSTIC toggle. When GOGOBEE_SIM_INLINE_BOSS=1
// the sim routes boss/elite doorways through the inline SimulateCombat path
// (production autopilot behavior) instead of the turn-based !fight engine.
// Used to A/B the martial T4/T5 regression. NOT for production.
func simInlineBossCombat() bool { return os.Getenv("GOGOBEE_SIM_INLINE_BOSS") == "1" }
// SimRunner owns a temp-DB AdventurePlugin + EuroPlugin pair and exposes
// just enough surface to drive synthetic players end-to-end.
type SimRunner struct {
@@ -116,7 +123,7 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D
// 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
// during autoResolveCombat. Counts are deliberately modest — a real
// during autoDriveCombat. Counts are deliberately modest — a real
// L7+ player typically carries 3-6 heals plus a couple of buffs; we
// mirror that band rather than max-stocking, which would mask class
// power gaps.
@@ -429,7 +436,7 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
for i := 0; i < walkCap; i++ {
simNow = simNow.Add(simWalkInterval)
walk := s.P.runAutopilotWalk(ctx, autopilotRoomCap, true, false)
walk := s.P.runAutopilotWalk(ctx, autopilotRoomCap, true, simInlineBossCombat())
if walk.initErr != "" {
res.Outcome = "halted"
res.StopCode = "init:" + walk.initErr
@@ -473,7 +480,7 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
case stopBoss, stopElite:
// Auto-resolve the encounter: !fight to open, then !attack
// per round until the session resolves (won / lost / fled).
killed, err := s.autoResolveCombat(ctx)
killed, err := s.P.autoDriveCombat(ctx)
if err != nil {
res.Outcome = "halted"
res.StopCode = "combat:" + err.Error()
@@ -499,9 +506,22 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
// Boss kill closes the run via combat resolution → continue
// the loop so the next walk picks up the cleared-run state.
case stopFork:
// Deterministic sim policy: always take path 1. Real players
// pick based on intent; the sim just needs to make progress.
if err := s.P.handleDnDExpeditionCmd(ctx, "go 1"); err != nil {
// Deterministic sim policy: take the first UNLOCKED path. The
// old blind "go 1" stalled forever on all-skill-check forks
// (feywild fork1) — resolveForkChoice rejects a locked edge but
// zoneCmdGo swallows it as a sent-DM with a nil return, so the
// run never advanced and burned every walk at the same node. A
// real player reads the menu and picks a passable path; mirror
// that. choice==0 means every edge is locked (a graph soft-lock
// the author must fix) — halt loudly rather than spin.
choice := s.firstUnlockedForkChoice(ctx.Sender)
if choice == 0 {
res.Outcome = "halted"
res.StopCode = "fork_all_locked"
i = walkCap
break
}
if err := s.P.handleDnDExpeditionCmd(ctx, fmt.Sprintf("go %d", choice)); err != nil {
res.Outcome = "halted"
res.StopCode = "fork:" + err.Error()
i = walkCap
@@ -619,6 +639,26 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap, maxDays
return res, nil
}
// firstUnlockedForkChoice returns the 1-based index of the first
// traversable option at the pending fork, or 0 if every edge is locked
// (a graph soft-lock — see feywild fork1, which had no LockNone exit).
func (s *SimRunner) firstUnlockedForkChoice(uid id.UserID) int {
run, err := getActiveZoneRun(uid)
if err != nil || run == nil {
return 1
}
pf, err := decodePendingFork(run.NodeChoices)
if err != nil || pf == nil {
return 1
}
for _, o := range pf.Options {
if o.Unlocked {
return o.Index
}
}
return 0
}
// captureDaySnapshot appends a SimDaySnapshot reflecting current state.
// HP is read from the live character row; SU/threat/day from the live
// expedition. Rooms is the running res.Rooms counter.
@@ -735,16 +775,24 @@ func simMaterialYields(uid id.UserID) (int, map[string]int) {
return total, out
}
// autoResolveCombat dispatches !fight at the current elite/boss gate,
// then loops !attack until the combat session resolves. Returns true
// when the player won (enemy dead, room cleared), false when the
// player lost or fled. autoCombatRoundCap is a safety cap against
// autoDriveCombat dispatches !fight at the current elite/boss gate,
// then loops !attack/!cast/!consume until the combat session resolves.
// Returns true when the player won (enemy dead, room cleared), false when
// the player lost or fled. autoCombatRoundCap is a safety cap against
// pathological stalemates (shouldn't trigger in practice — combat is
// strictly monotone in HP).
//
// Shared by the headless sim and the production background autopilot
// (long-expedition D8-f): a silent ctx (ctx.Silent) suppresses the
// per-round DM narration so the autorun digest can summarize the fight
// without spamming the player a message per round. Driving the real
// !fight/!attack engine here is what gives autopilot bosses true parity
// with manual `!fight` — including enemy multiattack, which the old
// inline SimulateCombat path ignored.
const autoCombatRoundCap = 200
func (s *SimRunner) autoResolveCombat(ctx MessageContext) (bool, error) {
if err := s.P.handleFightCmd(ctx); err != nil {
func (p *AdventurePlugin) autoDriveCombat(ctx MessageContext) (bool, error) {
if err := p.handleFightCmd(ctx); err != nil {
return false, fmt.Errorf("fight: %w", err)
}
sess, err := getActiveCombatSession(ctx.Sender)
@@ -772,15 +820,15 @@ func (s *SimRunner) autoResolveCombat(ctx MessageContext) (bool, error) {
case CombatStatusLost, CombatStatusFled:
return false, nil
}
kind, arg := s.simPickCombatAction(ctx.Sender, cur)
kind, arg := p.pickAutoCombatAction(ctx.Sender, cur)
var dispatchErr error
switch kind {
case "consume":
dispatchErr = s.P.handleConsumeCmd(ctx, arg)
dispatchErr = p.handleConsumeCmd(ctx, arg)
case "cast":
dispatchErr = s.P.handleCombatCastCmd(ctx, arg)
dispatchErr = p.handleCombatCastCmd(ctx, arg)
default:
dispatchErr = s.P.handleAttackCmd(ctx)
dispatchErr = p.handleAttackCmd(ctx)
}
if dispatchErr != nil {
return false, fmt.Errorf("%s iter %d: %w", kind, i, dispatchErr)
@@ -828,7 +876,7 @@ func (s *SimRunner) maybeShortRest(ctx MessageContext, uid id.UserID) {
// one more big hit, not so early that a 1-HP scratch burns a potion.
const simHealHPThresholdPct = 40
// simPickCombatAction is the sim's per-turn decision tree, mirroring
// pickAutoCombatAction is the per-turn decision tree, mirroring
// what a competent prod player would type:
//
// 1. If HP is below simHealHPThresholdPct and the inventory has a heal
@@ -847,14 +895,14 @@ const simHealHPThresholdPct = 40
//
// Pre-J2a the sim looped !attack only, which underweighted every caster
// class — see sim_results/j2_findings.md for the trace evidence.
func (s *SimRunner) simPickCombatAction(uid id.UserID, sess *CombatSession) (kind, arg string) {
func (p *AdventurePlugin) pickAutoCombatAction(uid id.UserID, sess *CombatSession) (kind, arg string) {
c, _ := LoadDnDCharacter(uid)
if c == nil || sess == nil {
return "attack", ""
}
lowHP := sess.PlayerHPMax > 0 && sess.PlayerHP*100 < sess.PlayerHPMax*simHealHPThresholdPct
if lowHP {
inv := s.P.loadConsumableInventory(uid)
inv := p.loadConsumableInventory(uid)
for _, it := range inv {
if it.Def.Effect == EffectHeal {
return "consume", it.Def.Name
@@ -871,7 +919,12 @@ func (s *SimRunner) simPickCombatAction(uid id.UserID, sess *CombatSession) (kin
if id := simPickSpiritualWeapon(c, uid, sess); id != "" {
return "cast", id
}
if id := simPickSpell(c, uid); id != "" {
// Once a concentration aura is up, a competent caster maintains it and
// attacks (or casts a non-concentration spell) rather than burning a
// slot to re-arm the same aura — so the picker excludes concentration
// spells while one is active.
auraActive := sess.Statuses.ConcentrationDmg > 0
if id := simPickSpell(c, uid, auraActive); id != "" {
return "cast", id
}
}
@@ -960,7 +1013,7 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, sess *CombatSession)
// - Among feasible candidates, prefer higher slot level (preserves
// high-slot supremacy and burns the big slots first); tie-break on
// expected damage from the dice string.
func simPickSpell(c *DnDCharacter, uid id.UserID) string {
func simPickSpell(c *DnDCharacter, uid id.UserID, auraActive bool) string {
known, err := listKnownSpells(uid)
if err != nil || len(known) == 0 {
return ""
@@ -989,6 +1042,11 @@ func simPickSpell(c *DnDCharacter, uid id.UserID) string {
if sp.CastTime == CastReaction {
continue
}
// An aura is already ticking — don't re-arm it; prefer attacks or a
// non-concentration spell this turn.
if auraActive && sp.Concentration {
continue
}
onList := false
for _, cl := range sp.Classes {
if cl == c.Class {

View File

@@ -0,0 +1,184 @@
package plugin
import (
"bytes"
"context"
"fmt"
"image"
_ "image/gif" // register GIF decoder for DecodeConfig
_ "image/jpeg" // register JPEG decoder for DecodeConfig
_ "image/png" // register PNG decoder for DecodeConfig
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"gogobee/internal/safehttp"
_ "golang.org/x/image/webp" // register WebP decoder for DecodeConfig
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// thumbnailUserAgent identifies the bot when probing and downloading the
// preview image for a posted link.
const thumbnailUserAgent = "GogoBee/1.0 (+link thumbnail fetch)"
// thumbnailClient validates and downloads link-supplied image URLs. It routes
// through safehttp so a posted link can't steer fetches at loopback / RFC1918 /
// cloud-metadata IPs — the dial-time guard re-checks every redirect target too.
// The 10 MiB download ceiling below bounds memory regardless.
var thumbnailClient = safehttp.NewClient(15 * time.Second)
// resolveURL turns a possibly-relative ref into an absolute URL against base.
func resolveURL(base, ref string) string {
ref = strings.TrimSpace(ref)
if ref == "" {
return ""
}
b, err := url.Parse(base)
if err != nil {
return ref
}
r, err := url.Parse(ref)
if err != nil {
return ref
}
return b.ResolveReference(r).String()
}
// normalizeImageURL rewrites known CDN thumbnail URLs to a higher-resolution
// variant. Currently handles The Guardian's i.guim.co.uk, whose pages hand out
// narrow thumbnails. Signed URLs are left alone (re-signing isn't possible),
// as are unrecognized hosts.
func normalizeImageURL(raw string) string {
if raw == "" {
return raw
}
u, err := url.Parse(raw)
if err != nil || u.Host != "i.guim.co.uk" {
return raw
}
q := u.Query()
if q.Get("width") == "" || q.Get("s") != "" {
return raw
}
q.Set("width", "1200")
u.RawQuery = q.Encode()
return u.String()
}
// validateImageURL HEAD-probes an image URL: it must be http(s), return 200,
// have an image/* content type, and (if a length is declared) exceed 5 KiB so
// tracking pixels are filtered. Returns false with no error on any failure.
func validateImageURL(rawURL string) bool {
if rawURL == "" || safehttp.ValidateURL(rawURL) != nil {
return false
}
req, err := http.NewRequest("HEAD", rawURL, nil)
if err != nil {
return false
}
req.Header.Set("User-Agent", thumbnailUserAgent)
resp, err := thumbnailClient.Do(req)
if err != nil {
slog.Debug("thumbnail: image HEAD failed", "url", rawURL, "err", err)
return false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false
}
if !strings.HasPrefix(resp.Header.Get("Content-Type"), "image/") {
return false
}
if cl := resp.Header.Get("Content-Length"); cl != "" {
if size, err := strconv.ParseInt(cl, 10, 64); err == nil && size <= 5120 {
return false // tracking pixel
}
}
return true
}
// SendImageFromURL downloads imageURL, uploads it to Matrix, and posts it as an
// m.image event in roomID. Returns an error on any failure so callers can fall
// back to a text-only preview. The fetch is SSRF-guarded and size-capped.
func (b *Base) SendImageFromURL(roomID id.RoomID, imageURL string) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil)
if err != nil {
return fmt.Errorf("create image request: %w", err)
}
req.Header.Set("User-Agent", thumbnailUserAgent)
resp, err := thumbnailClient.Do(req)
if err != nil {
return fmt.Errorf("download image: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("image download status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
if err != nil {
return fmt.Errorf("read image: %w", err)
}
contentType := resp.Header.Get("Content-Type")
if i := strings.IndexByte(contentType, ';'); i >= 0 {
contentType = strings.TrimSpace(contentType[:i])
}
if contentType == "" {
contentType = "image/jpeg"
}
if !strings.HasPrefix(contentType, "image/") {
return fmt.Errorf("not an image: %s", contentType)
}
// Decode dimensions so clients render the image inline rather than as a
// downloadable file attachment.
var width, height int
if cfg, _, decErr := image.DecodeConfig(bytes.NewReader(data)); decErr == nil {
width, height = cfg.Width, cfg.Height
}
ext := ".jpg"
switch contentType {
case "image/png":
ext = ".png"
case "image/gif":
ext = ".gif"
case "image/webp":
ext = ".webp"
}
filename := "thumbnail" + ext
uri, err := b.UploadContent(data, contentType, filename)
if err != nil {
return fmt.Errorf("upload image: %w", err)
}
content := &event.MessageEventContent{
MsgType: event.MsgImage,
Body: filename,
FileName: filename,
URL: uri.CUString(),
Info: &event.FileInfo{
MimeType: contentType,
Size: len(data),
Width: width,
Height: height,
},
}
_, err = b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
return err
}

View File

@@ -0,0 +1,40 @@
package plugin
import "testing"
func TestResolveURL(t *testing.T) {
cases := []struct {
base, ref, want string
}{
{"https://e.com/news/story", "https://cdn.e.com/a.jpg", "https://cdn.e.com/a.jpg"},
{"https://e.com/news/story", "//cdn.e.com/a.jpg", "https://cdn.e.com/a.jpg"},
{"https://e.com/news/story", "/img/a.jpg", "https://e.com/img/a.jpg"},
{"https://e.com/news/story", "a.jpg", "https://e.com/news/a.jpg"},
{"https://e.com/news/story", "", ""},
}
for _, c := range cases {
if got := resolveURL(c.base, c.ref); got != c.want {
t.Errorf("resolveURL(%q, %q) = %q, want %q", c.base, c.ref, got, c.want)
}
}
}
func TestNormalizeImageURL(t *testing.T) {
cases := []struct {
name, in, want string
}{
{"non-guardian passthrough", "https://e.com/a.jpg?width=140", "https://e.com/a.jpg?width=140"},
{"signed left alone", "https://i.guim.co.uk/x.jpg?width=140&s=abc", "https://i.guim.co.uk/x.jpg?width=140&s=abc"},
{"no width passthrough", "https://i.guim.co.uk/x.jpg", "https://i.guim.co.uk/x.jpg"},
{"empty", "", ""},
}
for _, c := range cases {
if got := normalizeImageURL(c.in); got != c.want {
t.Errorf("%s: normalizeImageURL(%q) = %q, want %q", c.name, c.in, got, c.want)
}
}
// Unsigned Guardian thumbnail gets bumped to width=1200.
if got := normalizeImageURL("https://i.guim.co.uk/x.jpg?width=140"); got != "https://i.guim.co.uk/x.jpg?width=1200" {
t.Errorf("normalizeImageURL unsigned = %q, want width=1200", got)
}
}

View File

@@ -41,6 +41,13 @@ type MessageContext struct {
// routes DM commands to the player's game room). When set, sender-private
// replies should go here instead of the rewritten RoomID.
OriginRoomID id.RoomID
// Silent suppresses player-facing replies for handlers that honor it
// (currently the turn-engine combat commands via replyDM). Set by the
// background autopilot when it drives a boss/elite fight through the
// real !fight/!attack engine for manual parity (long-expedition D8-f) —
// the day digest summarizes the outcome, so the per-round narration is
// dropped rather than DM'd round-by-round.
Silent bool
}
// ReactionContext holds the context for a reaction event.

View File

@@ -0,0 +1,429 @@
package plugin
// Scenario tests run against a copy of the prod DB (data/gogobee.db).
// Gated on GOGOBEE_PRODDB_SCENARIOS=1 so they don't run on default
// `go test ./...` invocations. Pattern mirrors setupAuditTestDB.
//
// Run with: GOGOBEE_PRODDB_SCENARIOS=1 go test -run TestScenario_ -v \
// ./internal/plugin/
import (
"os"
"regexp"
"strings"
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func requireScenarioEnv(t *testing.T) {
t.Helper()
if os.Getenv("GOGOBEE_PRODDB_SCENARIOS") != "1" {
t.Skip("scenario tests gated on GOGOBEE_PRODDB_SCENARIOS=1")
}
}
// ── Scenario: Josie caster-aid bootstraps ──────────────────────────────────
//
// Verifies (against a copy of the live DB) that the two 2026-06-18 caster-aid
// bootstraps land for @holymachina: the spell backfill adds inflict_wounds to
// her known+prepared book, and the pet gift grants a L10 dog mirrored into
// player_meta. Both must be idempotent on a second run.
func TestScenario_JosieCasterAid(t *testing.T) {
requireScenarioEnv(t)
setupAuditTestDB(t)
const uid = id.UserID("@holymachina:parodia.dev")
hasInflict := func() bool {
known, err := listKnownSpells(uid)
if err != nil {
t.Fatalf("list known: %v", err)
}
for _, k := range known {
if k.SpellID == "inflict_wounds" {
return k.Prepared
}
}
return false
}
if hasInflict() {
t.Fatal("precondition failed: target already knows inflict_wounds")
}
char, err := loadAdvCharacter(uid)
if err != nil || char == nil {
t.Fatalf("load target char: %v", err)
}
if char.PetArrived || char.PetType != "" {
t.Fatal("precondition failed: target already has a pet")
}
// Run twice to prove idempotency.
for i := 0; i < 2; i++ {
bootstrapCasterSpellBackfill()
bootstrapGrantStarterPet()
}
if !hasInflict() {
t.Error("spell backfill did not add inflict_wounds as prepared")
}
got, err := loadAdvCharacter(uid)
if err != nil || got == nil {
t.Fatalf("reload target char: %v", err)
}
if !got.PetArrived || got.PetType != "dog" || got.PetLevel != 10 {
t.Errorf("pet grant: arrived=%v type=%q level=%d, want true/dog/10",
got.PetArrived, got.PetType, got.PetLevel)
}
pet, err := loadPetState(uid)
if err != nil {
t.Fatalf("load pet state: %v", err)
}
if !pet.HasPet() || pet.Level != 10 {
t.Errorf("player_meta pet mirror: hasPet=%v level=%d, want true/10", pet.HasPet(), pet.Level)
}
}
// ── Scenario 1: Phase 5-B HP bootstrap ─────────────────────────────────────
//
// Expected: bootstrapPhase5BHPRefresh() walks dnd_character rows where
// dnd_level > 0, refreshes hp_max upward toward computeMaxHP (which
// applies phase5BHPMult=1.5), bumps hp_current by the same delta, and
// marks the daily_prefetch job key so reruns are no-ops.
func TestScenario_Phase5BHPBootstrap(t *testing.T) {
requireScenarioEnv(t)
setupAuditTestDB(t)
type charRow struct {
userID string
class string
level int
conScore int
hpMax int
hpCurrent int
}
snapshot := func() map[string]charRow {
rows, err := db.Get().Query(`
SELECT user_id, class, dnd_level, con_score, hp_max, hp_current
FROM dnd_character WHERE dnd_level > 0`)
if err != nil {
t.Fatalf("query: %v", err)
}
defer rows.Close()
out := map[string]charRow{}
for rows.Next() {
var r charRow
if err := rows.Scan(&r.userID, &r.class, &r.level, &r.conScore, &r.hpMax, &r.hpCurrent); err != nil {
t.Fatalf("scan: %v", err)
}
out[r.userID] = r
}
return out
}
before := snapshot()
t.Logf("[pre-bootstrap] %d characters with dnd_level > 0", len(before))
for _, r := range before {
t.Logf(" %s class=%q L%d con=%d hp=%d/%d",
r.userID, r.class, r.level, r.conScore, r.hpCurrent, r.hpMax)
}
if db.JobCompleted("phase5b_hp_refresh_v1", "once") {
t.Fatalf("job already marked completed before bootstrap — snapshot was post-bootstrap?")
}
bootstrapPhase5BHPRefresh()
after := snapshot()
if !db.JobCompleted("phase5b_hp_refresh_v1", "once") {
t.Errorf("expected JobCompleted=true after bootstrap")
}
refreshed := 0
for uid, b := range before {
a := after[uid]
conMod := abilityModifier(b.conScore)
_, ok := classInfo(DnDClass(b.class))
var expectedMax int
if !ok {
expectedMax = 1 // computeMaxHP returns 1 for unknown class.
} else {
expectedMax = computeMaxHP(DnDClass(b.class), conMod, b.level)
}
// Bootstrap skips rows where newMax <= oldMax (never lowers HP).
if expectedMax <= b.hpMax {
if a.hpMax != b.hpMax {
t.Errorf("%s: expected hp_max unchanged (%d), got %d", uid, b.hpMax, a.hpMax)
}
continue
}
delta := expectedMax - b.hpMax
expectedCurrent := b.hpCurrent + delta
if expectedCurrent > expectedMax {
expectedCurrent = expectedMax
}
if expectedCurrent < 1 {
expectedCurrent = 1
}
if a.hpMax != expectedMax {
t.Errorf("%s: hp_max want %d got %d", uid, expectedMax, a.hpMax)
}
if a.hpCurrent != expectedCurrent {
t.Errorf("%s: hp_current want %d (was %d, +delta %d) got %d",
uid, expectedCurrent, b.hpCurrent, delta, a.hpCurrent)
}
// Wound-preservation invariant: absolute wound (max-current) stays
// constant unless clamped at floor 1 or at the new ceiling.
preWound := b.hpMax - b.hpCurrent
postWound := a.hpMax - a.hpCurrent
if preWound != postWound && expectedCurrent != 1 && expectedCurrent != expectedMax {
t.Errorf("%s: wound size changed pre=%d post=%d (no clamp expected)",
uid, preWound, postWound)
}
refreshed++
t.Logf("[refreshed] %s: hp_max %d→%d (+%d), hp_current %d→%d",
uid, b.hpMax, a.hpMax, delta, b.hpCurrent, a.hpCurrent)
}
t.Logf("[post-bootstrap] %d/%d characters refreshed", refreshed, len(before))
// Idempotency: second call is a no-op.
bootstrapPhase5BHPRefresh()
after2 := snapshot()
for uid, a := range after {
if after2[uid].hpMax != a.hpMax || after2[uid].hpCurrent != a.hpCurrent {
t.Errorf("%s: second bootstrap call mutated HP", uid)
}
}
}
// ── Scenario 2: Magic-item plumbing ────────────────────────────────────────
//
// Expected:
// - magic_item_equipped table exists (Phase 5 migration).
// - magicItemRegistry is non-empty; rarity index covers every rarity.
// - Slot classifier output (baked into magic_items_srd_data.go via gen)
// puts known edge-case items in the right slot per the UX S4 fix.
// - dailyCuriosStock() returns a non-empty rotating shelf.
func TestScenario_MagicItemPlumbing(t *testing.T) {
requireScenarioEnv(t)
setupAuditTestDB(t)
// (a) Migration created the table.
var n int
err := db.Get().QueryRow(`
SELECT COUNT(*) FROM sqlite_master
WHERE type='table' AND name='magic_item_equipped'`).Scan(&n)
if err != nil || n != 1 {
t.Fatalf("magic_item_equipped table missing (n=%d err=%v)", n, err)
}
// (b) Schema sanity — required columns.
cols, err := db.Get().Query(`PRAGMA table_info(magic_item_equipped)`)
if err != nil {
t.Fatalf("pragma: %v", err)
}
defer cols.Close()
have := map[string]bool{}
for cols.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt any
_ = cols.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk)
have[name] = true
}
for _, want := range []string{"user_id", "item_id", "slot"} {
if !have[want] {
t.Errorf("magic_item_equipped missing column %q (have: %v)", want, have)
}
}
// (c) Registry populated and rarity index covers every rarity.
if len(magicItemRegistry) == 0 {
t.Fatalf("magicItemRegistry is empty")
}
t.Logf("magicItemRegistry: %d items", len(magicItemRegistry))
byRarity := magicItemsByRarity()
for _, r := range []DnDRarity{RarityCommon, RarityUncommon, RarityRare, RarityVeryRare, RarityLegendary} {
if len(byRarity[r]) == 0 {
t.Errorf("rarity %q has no items in index", r)
} else {
t.Logf(" rarity %s: %d items", r, len(byRarity[r]))
}
}
// (d) Slot baking — known edge-case items from UX S4 B4 should land
// in the slot the fix intended. Slots are baked into the generated
// data file by the importer's classifier; the lookup is a fixed table.
type slotCheck struct {
id string
wantSlot DnDSlot
mustNotBeRingForSubstring string // sanity vs word-boundary regressions
}
checks := []slotCheck{
{"ring_of_protection", DnDSlotRing1, ""},
{"boots_of_striding_and_springing", DnDSlotFeet, "springing"},
{"gloves_of_missile_snaring", DnDSlotHands, "snaring"},
{"bag_of_devouring", "", "devouring"}, // wondrous w/ no carryable noun
{"cloak_of_displacement", DnDSlotCloak, ""},
}
for _, c := range checks {
item, ok := magicItemRegistry[c.id]
if !ok {
t.Errorf("registry missing %q", c.id)
continue
}
if c.wantSlot != "" && item.Slot != c.wantSlot {
t.Errorf("%s: slot=%q want %q", c.id, item.Slot, c.wantSlot)
}
if item.Slot == DnDSlotRing1 || item.Slot == DnDSlotRing2 {
if c.mustNotBeRingForSubstring != "" {
t.Errorf("%s: misclassified as ring (substring trap %q)",
c.id, c.mustNotBeRingForSubstring)
}
}
t.Logf(" %s → kind=%s slot=%q rarity=%s attune=%v",
c.id, item.Kind, item.Slot, item.Rarity, item.Attunement)
}
// (e) Daily curios shelf rotates and returns a non-empty list.
shelf := dailyCuriosStock()
if len(shelf) == 0 {
t.Errorf("dailyCuriosStock returned empty")
} else {
t.Logf("dailyCuriosStock: %d items (first: %s @ %d, rarity %s)",
len(shelf), shelf[0].Name, shelf[0].Value, shelf[0].Rarity)
}
}
// ── Scenario 3: Expedition autopilot plumbing ──────────────────────────────
//
// Expected:
// - dnd_expedition.last_ambient_at column exists (Phase 3 migration).
// - autopilotFooter renders non-empty paused-state copy for pause
// reasons; renders empty for terminal/already-narrated reasons.
// - Ambient event pool has positive weights and non-empty flavor pools.
func TestScenario_ExpeditionAutopilotPlumbing(t *testing.T) {
requireScenarioEnv(t)
setupAuditTestDB(t)
// (a) Migration column present.
cols, err := db.Get().Query(`PRAGMA table_info(dnd_expedition)`)
if err != nil {
t.Fatalf("pragma: %v", err)
}
defer cols.Close()
have := map[string]bool{}
for cols.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt any
_ = cols.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk)
have[name] = true
}
if !have["last_ambient_at"] {
t.Errorf("dnd_expedition.last_ambient_at missing — Phase 3 migration didn't run")
}
// (b) Stop-reason footers — pause reasons render copy, terminal
// reasons render empty (death narration / completion block / etc.
// is the final).
type footerCheck struct {
reason stopReason
wantText bool
}
for _, c := range []footerCheck{
{stopFork, true},
{stopElite, true},
{stopBoss, true},
{stopHarvestCombat, true},
{stopOK, true}, // hit room cap → "stretch complete"
{stopEnded, false},
{stopComplete, false},
{stopBlocked, false},
{stopBossSafety, false}, // res.final carries the held-back line
} {
got := autopilotFooter(c.reason, 3)
if c.wantText && got == "" {
t.Errorf("stop reason %v: expected non-empty footer, got empty", c.reason)
}
if !c.wantText && got != "" {
t.Errorf("stop reason %v: expected empty footer, got %q", c.reason, got)
}
t.Logf(" %v (3 rooms) → %q", c.reason, got)
}
// (c) Ambient event pool — every event has a positive weight and a
// non-empty flavor pool. Build a temporary Expedition to satisfy
// pickAmbientEvent's eligibility predicates without persisting.
events := ambientEvents()
if len(events) == 0 {
t.Fatalf("ambientEvents() empty")
}
t.Logf("ambient pool: %d events", len(events))
for _, ev := range events {
if ev.Weight <= 0 {
t.Errorf("ambient event %q has non-positive weight %d", ev.Kind, ev.Weight)
}
if len(ev.Pool) == 0 {
t.Errorf("ambient event %q has empty pool", ev.Kind)
} else {
t.Logf(" %-22s weight=%d pool=%d sample=%q",
ev.Kind, ev.Weight, len(ev.Pool), ev.Pool[0])
}
}
}
// ── Scenario 4: Spell/help jargon regression ───────────────────────────────
//
// Expected: live merged spell registry has no banned-phrase leaks across
// Description. Mirrors TestSpellDescriptionsAreJargonFree at runtime so
// it shows up in this pass.
func TestScenario_SpellJargonRegression(t *testing.T) {
requireScenarioEnv(t)
setupAuditTestDB(t)
// Mirror dnd_spells_prose_test's TestSpellDescriptionsAreJargonFree:
// substring matches for jargon, regex for the SRD-importer placeholder
// signature ("Whatever " + lowercase, distinct from legit contractions).
bannedSubstrings := []string{
"saving throw", "Saving Throw",
"spell slot of", "Spell Slot of",
"ability modifier", "Ability Modifier",
"hit points equal to",
}
bannedRegexes := []*regexp.Regexp{
regexp.MustCompile(`\bd(4|6|8|10|12|20|100)\b`),
regexp.MustCompile(`\b\d+d\d+\b`),
regexp.MustCompile(`\bWhatever [a-z]`), // placeholder signature
regexp.MustCompile(`(?:\.\.\.|…)\s*$`), // trailing ellipsis
regexp.MustCompile(`\b[a-z]{1,3}(?:\.\.\.|…)\s*$`), // truncated word
regexp.MustCompile(`(?i)\bno larger than in any\b`),
}
totalSpells := 0
leaks := 0
for id, s := range dndSpellRegistry {
totalSpells++
desc := s.Description
if desc == "" {
continue
}
for _, b := range bannedSubstrings {
if strings.Contains(desc, b) {
t.Errorf("spell %q leaks banned phrase %q: %q", id, b, desc)
leaks++
}
}
for _, re := range bannedRegexes {
if re.MatchString(desc) {
t.Errorf("spell %q matches banned pattern %v: %q", id, re, desc)
leaks++
}
}
}
t.Logf("scanned %d spells; %d jargon leaks", totalSpells, leaks)
}

View File

@@ -10,6 +10,7 @@ import (
"time"
"gogobee/internal/db"
"gogobee/internal/safehttp"
"github.com/PuerkitoBio/goquery"
"maunium.net/go/mautrix"
@@ -40,9 +41,10 @@ func NewURLsPlugin(client *mautrix.Client) *URLsPlugin {
Base: NewBase(client),
enabled: enabled,
ignoreUsers: ignore,
httpClient: &http.Client{
Timeout: 3 * time.Second,
},
// Route page scrapes through safehttp so a posted link can't steer the
// fetch at loopback / RFC1918 / cloud-metadata IPs (re-checked on every
// redirect), and can't OOM the parser by streaming an unbounded body.
httpClient: safehttp.NewClient(8 * time.Second),
}
}
@@ -90,12 +92,23 @@ func (p *URLsPlugin) OnMessage(ctx MessageContext) error {
}
func (p *URLsPlugin) previewURL(ctx MessageContext, targetURL string) {
title, desc, err := p.fetchPreview(targetURL)
title, desc, image, err := p.fetchPreview(targetURL)
if err != nil {
slog.Debug("urls: fetch preview failed", "url", targetURL, "err", err)
return
}
if title == "" && desc == "" && image == "" {
return
}
// Post the thumbnail first (best-effort), so it renders above the text.
if image != "" && validateImageURL(image) {
if err := p.SendImageFromURL(ctx.RoomID, image); err != nil {
slog.Debug("urls: thumbnail post failed", "url", targetURL, "image", image, "err", err)
}
}
if title == "" && desc == "" {
return
}
@@ -120,63 +133,69 @@ func (p *URLsPlugin) previewURL(ctx MessageContext, targetURL string) {
}
}
// fetchPreview retrieves og:title and og:description, checking cache first.
func (p *URLsPlugin) fetchPreview(rawURL string) (string, string, error) {
// fetchPreview retrieves og:title, og:description and og:image, checking cache first.
func (p *URLsPlugin) fetchPreview(rawURL string) (title, desc, image string, err error) {
d := db.Get()
now := time.Now().UTC().Unix()
cacheTTL := int64(24 * 60 * 60)
// Check cache
var title, desc string
var cachedAt int64
err := d.QueryRow(
`SELECT title, description, cached_at FROM url_cache WHERE url = ?`, rawURL,
).Scan(&title, &desc, &cachedAt)
err = d.QueryRow(
`SELECT title, description, image_url, cached_at FROM url_cache WHERE url = ?`, rawURL,
).Scan(&title, &desc, &image, &cachedAt)
if err == nil && now-cachedAt < cacheTTL {
return title, desc, nil
return title, desc, image, nil
}
// Fetch from web
title, desc, err = p.scrapeOG(rawURL)
title, desc, image, err = p.scrapeOG(rawURL)
if err != nil {
return "", "", err
return "", "", "", err
}
// Cache the result
db.Exec("urls: cache write",
`INSERT INTO url_cache (url, title, description, cached_at) VALUES (?, ?, ?, ?)
ON CONFLICT(url) DO UPDATE SET title = ?, description = ?, cached_at = ?`,
rawURL, title, desc, now, title, desc, now,
`INSERT INTO url_cache (url, title, description, image_url, cached_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(url) DO UPDATE SET title = ?, description = ?, image_url = ?, cached_at = ?`,
rawURL, title, desc, image, now, title, desc, image, now,
)
return title, desc, nil
return title, desc, image, nil
}
// scrapeOG fetches a URL and extracts og:title, og:description and og:image.
func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, string, error) {
if err := safehttp.ValidateURL(rawURL); err != nil {
return "", "", "", err
}
// scrapeOG fetches a URL and extracts og:title and og:description.
func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, error) {
req, err := http.NewRequest("GET", rawURL, nil)
if err != nil {
return "", "", fmt.Errorf("create request: %w", err)
return "", "", "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("User-Agent", "GogoBee Bot/1.0")
req.Header.Set("Accept", "text/html,application/xhtml+xml")
resp, err := p.httpClient.Do(req)
if err != nil {
return "", "", fmt.Errorf("fetch: %w", err)
return "", "", "", fmt.Errorf("fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("status %d", resp.StatusCode)
return "", "", "", fmt.Errorf("status %d", resp.StatusCode)
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
// Cap the parsed body at 2 MiB — og: tags live in <head>, near the top.
doc, err := goquery.NewDocumentFromReader(safehttp.LimitedBody(resp.Body, 2*1024*1024))
if err != nil {
return "", "", fmt.Errorf("parse HTML: %w", err)
return "", "", "", fmt.Errorf("parse HTML: %w", err)
}
title := ""
desc := ""
image := ""
doc.Find("meta").Each(func(_ int, s *goquery.Selection) {
prop, _ := s.Attr("property")
@@ -186,6 +205,10 @@ func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, error) {
title = content
case "og:description":
desc = content
case "og:image:secure_url", "og:image:url", "og:image":
if image == "" && strings.TrimSpace(content) != "" {
image = content
}
}
})
@@ -205,5 +228,9 @@ func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, error) {
})
}
return strings.TrimSpace(title), strings.TrimSpace(desc), nil
if image != "" {
image = normalizeImageURL(resolveURL(rawURL, strings.TrimSpace(image)))
}
return strings.TrimSpace(title), strings.TrimSpace(desc), image, nil
}

View File

@@ -247,8 +247,17 @@ func (p *WOTDPlugin) prefetchWord(force bool) error {
if _, delErr := d.Exec(`DELETE FROM wotd_log WHERE date = ?`, today); delErr != nil {
slog.Error("wotd: force delete failed", "err", delErr)
}
// Also clear job-completed flags so PostWOTD will re-post
d.Exec(`DELETE FROM job_completed WHERE job_name = 'wotd' AND job_key LIKE ?`, today+"%")
// Also clear job-completed flags so PostWOTD will re-post. The
// per-room dedup keys are "<date>:<roomID>" in daily_prefetch, so
// match every room's flag for today. (Was targeting a non-existent
// job_completed/job_key table — a silent no-op that left forced
// re-posts blocked by the stale dedup row.)
if _, delErr := d.Exec(
`DELETE FROM daily_prefetch WHERE job_name = 'wotd' AND date LIKE ?`,
today+"%",
); delErr != nil {
slog.Error("wotd: force clear dedup flags failed", "err", delErr)
}
}
_, err := d.Exec(

View File

@@ -56,6 +56,18 @@ package plugin
// Longest entry→boss walk: 10 (R1) + 10 (R2) + 10 (R3) + 10 (R4) = 40
// nodes, inside [36, 44]. All four meaningful walks (fork1 × fork2 ×
// capstone) hit the same node count by construction.
//
// D10 anchor variety (in-place kind swaps, no length change): the Abyss
// shipped with NO trap node and only the fork2 vrock ELITE, so D10 adds
// two traps and a region-guardian elite —
// - Hush Corridor (fork1 silent_chambers Perception-loot spur) and
// Seam Threshold (fork3 reality_seam SECRET spur) become TRAPs, so
// the two loot/secret branches each carry a hazard.
// - Warden's Hall (R3 wardens_post buildup, main path) becomes an
// ELITE — the region literally named for its wardens finally has
// one, giving every walk a mid-zone region-guardian.
// The burning_wastes / mind_corridor / void_charge / usurper_throne
// routes stay clean, preserving the safe-vs-loot fork trade-off.
func zoneAbyssPortalGraph() ZoneGraph {
r1 := "abyss_outer_rift"
@@ -96,7 +108,7 @@ func zoneAbyssPortalGraph() ZoneGraph {
{NodeID: "abyss_portal.silent_chambers", Kind: NodeKindExploration, RegionID: r2,
Label: "Silent Chambers", PosX: 10, PosY: 3},
{NodeID: "abyss_portal.hush_corridor", Kind: NodeKindExploration, RegionID: r2,
{NodeID: "abyss_portal.hush_corridor", Kind: NodeKindTrap, RegionID: r2,
Label: "Hush Corridor", PosX: 11, PosY: 3},
{NodeID: "abyss_portal.listening_room", Kind: NodeKindExploration, RegionID: r2,
Label: "Listening Room", PosX: 12, PosY: 3},
@@ -135,7 +147,7 @@ func zoneAbyssPortalGraph() ZoneGraph {
// R3 buildup to fork3.
{NodeID: "abyss_portal.wardens_outer_post", Kind: NodeKindExploration, RegionID: r3,
Label: "Outer Warden Post", PosX: 23, PosY: 2},
{NodeID: "abyss_portal.wardens_hall", Kind: NodeKindExploration, RegionID: r3,
{NodeID: "abyss_portal.wardens_hall", Kind: NodeKindElite, RegionID: r3,
Label: "Warden's Hall", PosX: 24, PosY: 2},
{NodeID: "abyss_portal.wardens_chapel", Kind: NodeKindExploration, RegionID: r3,
Label: "Warden's Chapel", PosX: 25, PosY: 2},
@@ -163,7 +175,7 @@ func zoneAbyssPortalGraph() ZoneGraph {
{NodeID: "abyss_portal.claimed_path", Kind: NodeKindExploration, RegionID: r4,
Label: "Claimed Path", PosX: 32, PosY: 2},
{NodeID: "abyss_portal.seam_threshold", Kind: NodeKindExploration, RegionID: r4,
{NodeID: "abyss_portal.seam_threshold", Kind: NodeKindTrap, RegionID: r4,
Label: "Seam Threshold", PosX: 30, PosY: 3},
{NodeID: "abyss_portal.reality_seam", Kind: NodeKindSecret, RegionID: r4,
Label: "Reality Seam", PosX: 31, PosY: 3,

View File

@@ -119,3 +119,34 @@ func TestAbyssPortalGraph_RealitySeamHighestBias(t *testing.T) {
t.Errorf("reality_seam LootBias = %v, want >= 3.0 (Abyss capstone)", seam.Content.LootBias)
}
}
// TestAbyssPortalGraph_D10Anchors verifies the D10 anchor-variety pass.
// The Abyss shipped with no Trap node and a single fork2 Elite, so D10
// adds two branch Traps (Hush Corridor, Seam Threshold) and a main-path
// region-guardian Elite (Warden's Hall). Counts: 2 traps, 2 elites.
func TestAbyssPortalGraph_D10Anchors(t *testing.T) {
g := zoneAbyssPortalGraph()
var trapCount, eliteCount int
for _, n := range g.Nodes {
switch n.Kind {
case NodeKindTrap:
trapCount++
case NodeKindElite:
eliteCount++
}
}
if trapCount != 2 {
t.Errorf("trap nodes = %d, want 2 (D10 hush_corridor + seam_threshold)", trapCount)
}
if eliteCount != 2 {
t.Errorf("elite nodes = %d, want 2 (vrock_aerie + D10 wardens_hall)", eliteCount)
}
if g.Nodes["abyss_portal.wardens_hall"].Kind != NodeKindElite {
t.Error("D10: wardens_hall (R3 region-guardian) should be an Elite")
}
for _, id := range []string{"abyss_portal.hush_corridor", "abyss_portal.seam_threshold"} {
if g.Nodes[id].Kind != NodeKindTrap {
t.Errorf("D10: %s should be a Trap", id)
}
}
}

View File

@@ -52,6 +52,17 @@ package plugin
// Longest entry→boss walk: 10 (R1) + 10 (R2) + 9 (R3) + 10 (R4) = 39
// nodes, inside [36, 44]. The two R2 spurs and three R4 capstones each
// reach the boss in the same node count by construction.
//
// D10 anchor variety (in-place kind swaps, no length change): the two
// loot-leaning branches each gain a guard, so "the richer route costs
// more" —
// - Coin-Strewn Hall (treasure_vault spur) becomes an ELITE; the
// Perception loot route is now guarded, mirroring the ash_bridge
// spur's TRAP so both fork1 branches carry an anchor.
// - Hidden Passage (hoard_pillar SECRET capstone spur) becomes a
// TRAP guarding the densest loot in the zone.
// The direct-confrontation and dragon-bargain routes stay clean, so the
// fork choice trades safety for loot.
func zoneDragonsLairGraph() ZoneGraph {
r1 := "dragons_lair_kobold_warrens"
@@ -110,7 +121,7 @@ func zoneDragonsLairGraph() ZoneGraph {
{NodeID: "dragons_lair.treasure_vault", Kind: NodeKindExploration, RegionID: r2,
Label: "Treasure Vault", PosX: 17, PosY: 2,
Content: ZoneNodeContent{LootBias: 1.5}},
{NodeID: "dragons_lair.coin_strewn_hall", Kind: NodeKindExploration, RegionID: r2,
{NodeID: "dragons_lair.coin_strewn_hall", Kind: NodeKindElite, RegionID: r2,
Label: "Coin-Strewn Hall", PosX: 18, PosY: 2},
{NodeID: "dragons_lair.vault_passage", Kind: NodeKindExploration, RegionID: r2,
Label: "Vault Passage", PosX: 19, PosY: 2},
@@ -152,7 +163,7 @@ func zoneDragonsLairGraph() ZoneGraph {
Label: "Audience Hall", PosX: 31, PosY: 1},
// R4 hoard_pillar spur (Perception 17, SECRET).
{NodeID: "dragons_lair.hidden_passage", Kind: NodeKindExploration, RegionID: r4,
{NodeID: "dragons_lair.hidden_passage", Kind: NodeKindTrap, RegionID: r4,
Label: "Hidden Passage", PosX: 29, PosY: 2},
{NodeID: "dragons_lair.hoard_pillar", Kind: NodeKindSecret, RegionID: r4,
Label: "Hidden Hoard Pillar", PosX: 30, PosY: 2,

View File

@@ -111,3 +111,32 @@ func TestDragonsLairGraph_LootBiasEscalation(t *testing.T) {
t.Errorf("hoard_pillar LootBias = %v, want >= 2.5 (T5 secret)", hoard.Content.LootBias)
}
}
// TestDragonsLairGraph_D10Anchors verifies the D10 anchor-variety pass:
// the treasure_vault spur gains a guarding Elite (Coin-Strewn Hall) to
// mirror the ash_bridge spur's Trap, and the hoard_pillar SECRET capstone
// spur gains a Trap (Hidden Passage). Counts: 2 traps, 2 elites.
func TestDragonsLairGraph_D10Anchors(t *testing.T) {
g := zoneDragonsLairGraph()
var trapCount, eliteCount int
for _, n := range g.Nodes {
switch n.Kind {
case NodeKindTrap:
trapCount++
case NodeKindElite:
eliteCount++
}
}
if trapCount != 2 {
t.Errorf("trap nodes = %d, want 2 (ash_bridge + D10 hidden_passage)", trapCount)
}
if eliteCount != 2 {
t.Errorf("elite nodes = %d, want 2 (wyrmlings_nest + D10 coin_strewn_hall)", eliteCount)
}
if g.Nodes["dragons_lair.coin_strewn_hall"].Kind != NodeKindElite {
t.Error("D10: coin_strewn_hall (treasure_vault spur) should be an Elite")
}
if g.Nodes["dragons_lair.hidden_passage"].Kind != NodeKindTrap {
t.Error("D10: hidden_passage (hoard_pillar spur) should be a Trap")
}
}

View File

@@ -18,14 +18,24 @@ package plugin
// Also new: cursed_thicket TRAP anchor in the preamble — every walk
// hits it. The original G8f graph had no Trap node.
//
// D10 anchor variety (in-place kind swaps, no length change): the two
// first-stage approaches each gain one distinct anchor so the fork
// choice carries a flavor, not just a skill gate —
// - Grove approach: Singing Orchard becomes an ELITE (the CHA-bonus
// route is guarded; you pay for the bargain in blood).
// - Marsh approach: Mire Steps becomes a TRAP (the free route is
// hazardous footing rather than a fight).
// The time_eddy / illusion_garden exclusive endings stay anchor-light so
// they keep their "skip the hag elite for loot" trade-off.
//
// Preamble (10 nodes, ending in fork1):
// entry → twilight_path → veiled_glade → faerie_lights →
// cursed_thicket (TRAP) → revel_road → moonshadow_bridge →
// bargain_walk → fey_market → fork1
//
// Fork1 → both options locked (CHA vs Perception, no free choice):
// Fork1 → marsh (free default) | grove (CHA DC 14 bonus — the bargain):
//
// Grove approach (8 nodes, CHA DC 14):
// Grove approach (8 nodes, CHA DC 14 bonus):
// grove_threshold → starlight_path → singing_orchard → mirror_pond
// → prismatic_arbor → moonpetal_clearing → dawnglow_walk →
// glamoured_grove (fork2a)
@@ -87,7 +97,7 @@ func zoneFeywildCrossingGraph() ZoneGraph {
Label: "Grove Threshold", PosX: 10, PosY: 0},
{NodeID: "feywild_crossing.starlight_path", Kind: NodeKindExploration,
Label: "Starlight Path", PosX: 11, PosY: 0},
{NodeID: "feywild_crossing.singing_orchard", Kind: NodeKindExploration,
{NodeID: "feywild_crossing.singing_orchard", Kind: NodeKindElite,
Label: "Singing Orchard", PosX: 12, PosY: 0},
{NodeID: "feywild_crossing.mirror_pond", Kind: NodeKindExploration,
Label: "Mirror Pond", PosX: 13, PosY: 0},
@@ -109,7 +119,7 @@ func zoneFeywildCrossingGraph() ZoneGraph {
Label: "Fog Basin", PosX: 12, PosY: 4},
{NodeID: "feywild_crossing.moaning_reeds", Kind: NodeKindExploration,
Label: "Moaning Reeds", PosX: 13, PosY: 4},
{NodeID: "feywild_crossing.mire_steps", Kind: NodeKindExploration,
{NodeID: "feywild_crossing.mire_steps", Kind: NodeKindTrap,
Label: "Mire Steps", PosX: 14, PosY: 4},
{NodeID: "feywild_crossing.submerged_stones", Kind: NodeKindExploration,
Label: "Submerged Stones", PosX: 15, PosY: 4},
@@ -203,13 +213,19 @@ func zoneFeywildCrossingGraph() ZoneGraph {
{From: "feywild_crossing.bargain_walk", To: "feywild_crossing.fey_market", Lock: LockNone},
{From: "feywild_crossing.fey_market", To: "feywild_crossing.fork1", Lock: LockNone},
// Fork1 — both options locked (CHA vs. Perception, no LockNone).
// Fork1 — marsh is the free default path; grove is a CHA-gated
// bonus route (the fey bargain). (Both were skill-locked originally,
// with no LockNone exit — a soft-lock: any character failing both
// the CHA and Perception checks was permanently stranded here, since
// fork rolls are deterministic with no retry. Every other zone fork
// has a free path; freeing marsh restores that invariant while
// keeping the signature CHA fey-bargain route as a bonus.)
{From: "feywild_crossing.fork1", To: "feywild_crossing.grove_threshold",
Lock: LockStatCheck, LockData: map[string]any{"stat": "CHA", "dc": 14},
Hint: "a starlight creature offering a deal — you'd have to play along", Weight: 1},
{From: "feywild_crossing.fork1", To: "feywild_crossing.marsh_threshold",
Lock: LockPerception, LockData: map[string]any{"dc": 14},
Hint: "wisp-light flickering between two trees — they look like the same tree", Weight: 1},
Lock: LockNone,
Hint: "wisp-light flickering between two trees — pick your way through", Weight: 1},
// Grove approach.
{From: "feywild_crossing.grove_threshold", To: "feywild_crossing.starlight_path", Lock: LockNone},

View File

@@ -55,16 +55,23 @@ func TestFeywildCrossingGraph_PartialOverlap(t *testing.T) {
}
}
// TestFeywildCrossingGraph_NoFreeChoiceAtFork1 captures the design
// intent: both fork1 outgoing edges are locked. The player must succeed
// at CHA or Perception to enter; no LockNone fallback.
func TestFeywildCrossingGraph_NoFreeChoiceAtFork1(t *testing.T) {
// TestFeywildCrossingGraph_Fork1HasFreePath guards the no-soft-lock
// invariant: fork1 must offer at least one LockNone exit. The original
// design locked BOTH edges (CHA + Perception) with no fallback — fork
// rolls are deterministic with no retry, so a character failing both was
// permanently stranded (D8-f part 2 found this stranded ~60% of runs).
// Every other zone fork has a free path; fork1 must too.
func TestFeywildCrossingGraph_Fork1HasFreePath(t *testing.T) {
g := zoneFeywildCrossingGraph()
free := 0
for _, e := range g.outgoingEdges("feywild_crossing.fork1") {
if e.Lock == LockNone {
t.Errorf("fork1 has unlocked edge to %s — expected all locked", e.To)
if e.Lock == LockNone || e.Lock == "" {
free++
}
}
if free == 0 {
t.Error("fork1 has no free (LockNone) exit — soft-lock: a player failing every skill check is permanently stranded")
}
}
// TestFeywildCrossingGraph_FirstCHALock confirms this zone uses
@@ -86,6 +93,8 @@ func TestFeywildCrossingGraph_FirstCHALock(t *testing.T) {
// TestFeywildCrossingGraph_TrapAnchor verifies D1-d added the missing
// Trap node. Original G8f graph had elite/secret but no trap.
// TestFeywildCrossingGraph_TrapAnchor verifies the preamble trap plus
// the D10 marsh-branch trap (Mire Steps).
func TestFeywildCrossingGraph_TrapAnchor(t *testing.T) {
g := zoneFeywildCrossingGraph()
var trapCount int
@@ -94,8 +103,30 @@ func TestFeywildCrossingGraph_TrapAnchor(t *testing.T) {
trapCount++
}
}
if trapCount != 1 {
t.Errorf("trap nodes = %d, want 1", trapCount)
if trapCount != 2 {
t.Errorf("trap nodes = %d, want 2 (cursed_thicket + D10 mire_steps)", trapCount)
}
if g.Nodes["feywild_crossing.mire_steps"].Kind != NodeKindTrap {
t.Error("D10: mire_steps (marsh branch) should be a Trap")
}
}
// TestFeywildCrossingGraph_EliteAnchors verifies the shared hag_circle
// elite plus the D10 grove-branch elite (Singing Orchard), so the first
// fork carries a per-branch anchor (grove=elite, marsh=trap).
func TestFeywildCrossingGraph_EliteAnchors(t *testing.T) {
g := zoneFeywildCrossingGraph()
var eliteCount int
for _, n := range g.Nodes {
if n.Kind == NodeKindElite {
eliteCount++
}
}
if eliteCount != 2 {
t.Errorf("elite nodes = %d, want 2 (hag_circle + D10 singing_orchard)", eliteCount)
}
if g.Nodes["feywild_crossing.singing_orchard"].Kind != NodeKindElite {
t.Error("D10: singing_orchard (grove branch) should be an Elite")
}
}

View File

@@ -190,6 +190,40 @@ func TestCompileLegacyZoneGraph_AllRegistered(t *testing.T) {
}
}
// TestZoneGraphs_NoSoftLockedFork guards the invariant that every fork
// node offers at least one traversable (LockNone) exit. A fork whose
// every edge is skill-locked strands any player who fails all the checks
// — fork rolls are deterministic with no retry. D8-f part 2 found
// feywild fork1 violating this (it stranded ~60% of runs). Catch any
// future author who locks every exit of a fork.
func TestZoneGraphs_NoSoftLockedFork(t *testing.T) {
for _, z := range allZones() {
g, ok := loadZoneGraph(z.ID)
if !ok {
continue
}
for nodeID, node := range g.Nodes {
if node.Kind != NodeKindFork {
continue
}
outs := g.outgoingEdges(nodeID)
if len(outs) == 0 {
continue
}
free := false
for _, e := range outs {
if e.Lock == LockNone || e.Lock == "" {
free = true
break
}
}
if !free {
t.Errorf("zone %q fork %q: every exit is locked — soft-lock (a player failing all checks is stranded; add a LockNone fallback)", z.ID, nodeID)
}
}
}
}
func TestDeriveLegacyNodeID_StableShape(t *testing.T) {
got := deriveLegacyNodeID("crypt_valdris", 0)
want := "crypt_valdris.r1"

View File

@@ -51,6 +51,16 @@ package plugin
//
// Trap anchor (Collapsed Arch) is new — the original G8i graph had no
// Trap node. Placed in the R1 preamble so every walk hits it.
//
// D10 anchor variety (in-place kind swaps, no length change):
// - Silenced Chamber (R3 illithid arm) becomes a TRAP — the second
// trap, on a fork branch so the illithid route reads riskier than
// the drow route.
// - Drow Gate Garrison (R2 arm tail, at the R2→R4 boundary) becomes a
// region-guardian ELITE, giving the drow arm two elites (Captain +
// 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.
func zoneUnderdarkGraph() ZoneGraph {
r1 := "underdark_surface_tunnels"
@@ -111,15 +121,15 @@ func zoneUnderdarkGraph() ZoneGraph {
Label: "Drow Descent", PosX: 17, PosY: 0},
{NodeID: "underdark.drow_passage", Kind: NodeKindExploration, RegionID: r2,
Label: "Lower Passage", PosX: 18, PosY: 0},
{NodeID: "underdark.drow_gate", Kind: NodeKindExploration, RegionID: r2,
Label: "Drow Gate", PosX: 19, PosY: 0},
{NodeID: "underdark.drow_gate", Kind: NodeKindElite, RegionID: r2,
Label: "Drow Gate Garrison", PosX: 19, PosY: 0},
// R3 illithid_warren arm.
{NodeID: "underdark.psionic_corridor", Kind: NodeKindExploration, RegionID: r3,
Label: "Psionic Corridor", PosX: 8, PosY: 2},
{NodeID: "underdark.whispering_hall", Kind: NodeKindExploration, RegionID: r3,
Label: "Whispering Hall", PosX: 9, PosY: 2},
{NodeID: "underdark.silenced_chamber", Kind: NodeKindExploration, RegionID: r3,
{NodeID: "underdark.silenced_chamber", Kind: NodeKindTrap, RegionID: r3,
Label: "Silenced Chamber", PosX: 10, PosY: 2},
{NodeID: "underdark.mind_tank_room", Kind: NodeKindExploration, RegionID: r3,
Label: "Brine Tanks", PosX: 11, PosY: 2},

View File

@@ -106,8 +106,8 @@ func TestUnderdarkGraph_AllArmsReachBoss(t *testing.T) {
}
}
// TestUnderdarkGraph_TrapAnchor verifies D1-d added the missing Trap
// node. Original G8i graph had elite/boss/harvest but no trap.
// TestUnderdarkGraph_TrapAnchor verifies D1-d added the missing R1 Trap
// (collapsed_arch) and D10 added the illithid-arm Trap (silenced_chamber).
func TestUnderdarkGraph_TrapAnchor(t *testing.T) {
g := zoneUnderdarkGraph()
var trapCount int
@@ -116,7 +116,30 @@ func TestUnderdarkGraph_TrapAnchor(t *testing.T) {
trapCount++
}
}
if trapCount != 1 {
t.Errorf("trap nodes = %d, want 1", trapCount)
if trapCount != 2 {
t.Errorf("trap nodes = %d, want 2 (collapsed_arch + D10 silenced_chamber)", trapCount)
}
if g.Nodes["underdark.silenced_chamber"].Kind != NodeKindTrap {
t.Error("D10: silenced_chamber (illithid arm) should be a Trap")
}
}
// TestUnderdarkGraph_EliteAnchors verifies the per-arm elites (drow
// Captain, illithid Mind Flayer) plus the D10 region-guardian elite at
// the drow→throne boundary (drow_gate), so the drow arm carries two
// elites while the chasm spur stays anchor-light.
func TestUnderdarkGraph_EliteAnchors(t *testing.T) {
g := zoneUnderdarkGraph()
var eliteCount int
for _, n := range g.Nodes {
if n.Kind == NodeKindElite {
eliteCount++
}
}
if eliteCount != 3 {
t.Errorf("elite nodes = %d, want 3 (drow_captain + mind_flayer + D10 drow_gate)", eliteCount)
}
if g.Nodes["underdark.drow_gate"].Kind != NodeKindElite {
t.Error("D10: drow_gate (R2→R4 boundary) should be a region-guardian Elite")
}
}

View File

@@ -0,0 +1,146 @@
// Package safehttp provides an http.Client hardened against SSRF and
// memory-DoS via hostile upstreams. Every outbound fetch the bot makes
// against feed-supplied URLs (RSS articles, image hosts) should go through
// one of these clients so a malicious feed can't steer the bot at loopback,
// link-local, RFC1918, or cloud metadata IPs, and can't OOM the process by
// streaming an unbounded body.
package safehttp
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// ErrBlockedHost is returned when a URL resolves to a non-public IP.
var ErrBlockedHost = errors.New("safehttp: blocked non-public host")
// AllowPrivate, when true, disables the loopback/RFC1918 dial guard. It
// exists for tests that spin up httptest.NewServer on 127.0.0.1 — never
// set this in production.
var AllowPrivate bool
// safeDialContext refuses connections to non-public IPs. It runs after
// DNS resolution, so a hostile DNS rebinding that returns 127.0.0.1
// still gets blocked at dial time.
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := (&net.Resolver{}).LookupIP(ctx, "ip", host)
if err != nil {
return nil, err
}
var allowed net.IP
for _, ip := range ips {
if AllowPrivate || isPublicIP(ip) {
allowed = ip
break
}
}
if allowed == nil {
return nil, fmt.Errorf("%w: %s", ErrBlockedHost, host)
}
d := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}
return d.DialContext(ctx, network, net.JoinHostPort(allowed.String(), port))
}
// isPublicIP reports whether ip is a globally routable unicast address.
// Rejects loopback, link-local, multicast, RFC1918, CGNAT, and the
// AWS/GCP/Azure metadata IPs 169.254.169.254 / fd00:ec2::254 (these
// already fall under link-local but spell it out for clarity).
func isPublicIP(ip net.IP) bool {
if ip == nil || ip.IsUnspecified() || ip.IsLoopback() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsMulticast() || ip.IsPrivate() {
return false
}
// 100.64.0.0/10 (CGNAT) is not covered by IsPrivate on older Go.
if v4 := ip.To4(); v4 != nil {
if v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 {
return false
}
// 0.0.0.0/8 and friends.
if v4[0] == 0 {
return false
}
}
return true
}
// ValidateURL returns nil if the URL is http(s) and parseable. It does
// not resolve DNS — the dial step does that — but it does reject bare
// schemes (file://, gopher://, etc.) before we even open a connection.
func ValidateURL(raw string) error {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return err
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("safehttp: unsupported scheme %q", u.Scheme)
}
if u.Host == "" {
return errors.New("safehttp: empty host")
}
return nil
}
// NewClient returns an http.Client whose transport blocks non-public
// destinations at dial time, caps redirects at 5, and re-validates each
// redirect target's scheme. timeout is the per-request overall budget.
func NewClient(timeout time.Duration) *http.Client {
tr := &http.Transport{
DialContext: safeDialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 32,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
}
return &http.Client{
Transport: tr,
Timeout: timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("safehttp: stopped after 5 redirects")
}
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
return fmt.Errorf("safehttp: unsupported redirect scheme %q", req.URL.Scheme)
}
return nil
},
}
}
// LimitedBody wraps r in a reader that errors once more than max bytes have
// been read. Use to cap how much of a response body downstream parsers
// (goquery, image.Decode) will ever see — a hostile origin streaming an
// endless body otherwise OOMs the process.
func LimitedBody(r io.Reader, max int64) io.Reader {
return &limitedReader{R: r, N: max}
}
type limitedReader struct {
R io.Reader
N int64
}
func (l *limitedReader) Read(p []byte) (int, error) {
if l.N <= 0 {
return 0, fmt.Errorf("safehttp: response body exceeded cap")
}
if int64(len(p)) > l.N {
p = p[:l.N]
}
n, err := l.R.Read(p)
l.N -= int64(n)
return n, err
}

View File

@@ -406,8 +406,8 @@ func setupScheduledJobs(
}
})
// WOTD post at 08:00
if strings.ToLower(os.Getenv("DISABLE_WOTD_POST")) != "true" {
// WOTD post at 08:00 (disabled by default; opt in via ENABLE_WOTD_POST=true)
if strings.ToLower(os.Getenv("ENABLE_WOTD_POST")) == "true" {
c.AddFunc("0 8 * * *", func() {
slog.Info("scheduler: posting WOTD")
for _, r := range rooms {
@@ -415,7 +415,7 @@ func setupScheduledJobs(
}
})
} else {
slog.Info("scheduler: WOTD daily post disabled via DISABLE_WOTD_POST")
slog.Info("scheduler: WOTD daily post disabled (set ENABLE_WOTD_POST=true to enable)")
}
// Game releases Monday 09:00