2 Commits

Author SHA1 Message Date
prosolis
97b8f4584f 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 21:55:25 -07:00
prosolis
6e4928ca17 Merge pull request #11 from prosolis/forex-crypto-coingecko
Forex: convert to/from crypto via CoinGecko (keyless)
2026-05-21 22:21:52 -07:00
31 changed files with 595 additions and 808 deletions

View File

@@ -7,6 +7,11 @@ BOT_DISPLAY_NAME=GogoBee
# Which rooms the bot posts scheduled content to (comma-separated room IDs) # Which rooms the bot posts scheduled content to (comma-separated room IDs)
BROADCAST_ROOMS=!roomid:example.com 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) # Admins who can run admin-only commands (comma-separated Matrix user IDs)
ADMIN_USERS=@yourmxid:example.com ADMIN_USERS=@yourmxid:example.com

View File

@@ -333,6 +333,9 @@ func runMigrations(d *sql.DB) error {
// engages when a fork / elite / boss / supply pinch actually // engages when a fork / elite / boss / supply pinch actually
// needs a decision. CAS-claim on this column gates re-entry. // needs a decision. CAS-claim on this column gates re-entry.
`ALTER TABLE dnd_expedition ADD COLUMN last_autorun_at DATETIME`, `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 { for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil { if _, err := d.Exec(stmt); err != nil {
@@ -1124,6 +1127,7 @@ CREATE TABLE IF NOT EXISTS url_cache (
url TEXT PRIMARY KEY, url TEXT PRIMARY KEY,
title TEXT DEFAULT '', title TEXT DEFAULT '',
description TEXT DEFAULT '', description TEXT DEFAULT '',
image_url TEXT NOT NULL DEFAULT '',
cached_at INTEGER DEFAULT (unixepoch()) cached_at INTEGER DEFAULT (unixepoch())
); );

View File

@@ -98,7 +98,7 @@ func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) erro
return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+ return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+
"Hire a babysitter to look after your camp and tend the pet while you sleep:\n"+ "Hire a babysitter to look after your camp and tend the pet while you sleep:\n"+
" • Daily pet XP trickle (your pet still grows while you focus elsewhere)\n"+ " • Daily pet XP trickle (your pet still grows while you focus elsewhere)\n"+
" • Standard camps act like fortified ones — rest deeply, no need to have downed the zone boss\n"+ " • Standard camps act like fortified ones — rest deeply, no need for boss-cleared rooms\n"+
" • Rival duels declined on your behalf\n\n"+ " • Rival duels declined on your behalf\n\n"+
"`!adventure babysit week` — 7 days of service\n"+ "`!adventure babysit week` — 7 days of service\n"+
"`!adventure babysit month` — 30 days of service\n"+ "`!adventure babysit month` — 30 days of service\n"+

View File

@@ -311,30 +311,7 @@ func (c *AdventureCharacter) CanDoHarvest(isHoliday bool) bool {
} }
func (c *AdventureCharacter) HasActedToday() bool { func (c *AdventureCharacter) HasActedToday() bool {
if c.CombatActionsUsed > 0 || c.HarvestActionsUsed > 0 { return c.CombatActionsUsed > 0 || c.HarvestActionsUsed > 0
return true
}
// DnD-side flows (expedition / zone / rest / autopilot) don't touch
// the legacy action counters; they credit the day via LastActionDate
// instead. Honor that so a player who ran an expedition all day and
// extracted before midnight still counts as having acted.
return c.LastActionDate == time.Now().UTC().Format("2006-01-02")
}
// markActedToday stamps the player's LastActionDate to today so the
// midnight reset credits the day. Safe to call on every DnD-side action;
// no-ops if the date is already today.
func markActedToday(userID id.UserID) {
today := time.Now().UTC().Format("2006-01-02")
c, err := loadAdvCharacter(userID)
if err != nil || c == nil {
return
}
if c.LastActionDate == today {
return
}
c.LastActionDate = today
_ = saveAdvCharacter(c)
} }
func (c *AdventureCharacter) AllActionsUsed(isHoliday bool) bool { func (c *AdventureCharacter) AllActionsUsed(isHoliday bool) bool {

View File

@@ -206,25 +206,6 @@ func petShouldArrive(pet PetState, house HouseState) bool {
return rand.Float64() < 0.30 return rand.Float64() < 0.30
} }
// maybeRollPetArrivalOnEmerge rolls the pet-arrival check when a player
// surfaces from an expedition (voluntary extract, abandon, or a survived
// forced extraction) or revives after death. The arrival roll lives on the
// emergence seam — not the legacy 08:00 overworld morning DM — because
// expedition players are almost never in the overworld at the scheduled hour,
// so the morning roll never reached them. Story beat: while the player was
// underground, an animal wandered into the empty house looking for food.
//
// Safe to call unconditionally on any emergence: petShouldArrive gates on
// house tier / not-yet-arrived, and petArrivalDM won't clobber an existing
// pending interaction.
func (p *AdventurePlugin) maybeRollPetArrivalOnEmerge(userID id.UserID) {
pet, _ := loadPetState(userID)
house, _ := loadHouseState(userID)
if petShouldArrive(pet, house) {
p.petArrivalDM(userID)
}
}
// petArrivalDM sends the initial "there's an animal in your house" DM. // petArrivalDM sends the initial "there's an animal in your house" DM.
func (p *AdventurePlugin) petArrivalDM(userID id.UserID) { func (p *AdventurePlugin) petArrivalDM(userID id.UserID) {
// Don't overwrite an existing pending interaction // Don't overwrite an existing pending interaction

View File

@@ -82,12 +82,6 @@ func (p *AdventurePlugin) sendMorningDMs() {
if err := p.SendDM(char.UserID, text); err != nil { if err := p.SendDM(char.UserID, text); err != nil {
slog.Error("adventure: failed to send respawn DM", "user", char.UserID, "err", err) slog.Error("adventure: failed to send respawn DM", "user", char.UserID, "err", err)
} }
// Emergence seam (death case): a player who died underground
// "comes home" on respawn. This is the deferred half of the
// emergence roll — survived extractions roll at their exit
// site; deaths roll here. See maybeRollPetArrivalOnEmerge.
p.maybeRollPetArrivalOnEmerge(char.UserID)
} }
// Babysitting: pet-care trickle (no harvest actions; safe-rest perk // Babysitting: pet-care trickle (no harvest actions; safe-rest perk
@@ -139,12 +133,13 @@ func (p *AdventurePlugin) sendMorningDMs() {
continue continue
} }
// Pet arrival no longer rolls here. The 08:00 overworld morning DM // Pet arrival check (fires before normal morning DM)
// is skipped for anyone underground (expedition gate above), so it house, _ := loadHouseState(char.UserID)
// never reached expedition players. Arrival now fires on the
// emergence seam — see maybeRollPetArrivalOnEmerge, called from the
// extract/abandon/forced-extract and respawn paths.
pet, _ := loadPetState(char.UserID) pet, _ := loadPetState(char.UserID)
if petShouldArrive(pet, house) {
p.petArrivalDM(char.UserID)
continue
}
// Morning pet event // Morning pet event
petEvent := petMorningEvent(pet) petEvent := petMorningEvent(pet)
@@ -407,16 +402,7 @@ func (p *AdventurePlugin) midnightReset() error {
dmsSent := 0 dmsSent := 0
for _, char := range chars { for _, char := range chars {
// This runs at 00:00 of the new UTC day, closing out the day that just if !char.HasActedToday() {
// ended (= yesterday). HasActedToday() compares LastActionDate against
// time.Now()'s date (the *new* day), so DnD-side players who credited
// the closing day via LastActionDate=yesterday would read as idle and
// get their streak halved. Credit activity on the closing day directly:
// legacy counters are still non-zero here (reset happens further below),
// and LastActionDate==yesterday means they played the day we're closing.
acted := char.CombatActionsUsed > 0 || char.HarvestActionsUsed > 0 ||
char.LastActionDate == today || char.LastActionDate == yesterday
if !acted {
// If the player died today or yesterday, they couldn't act — no shame, // If the player died today or yesterday, they couldn't act — no shame,
// no streak reset. This covers both currently-dead players and players // no streak reset. This covers both currently-dead players and players
// who were just revived at midnight (Alive already flipped to true by // who were just revived at midnight (Alive already flipped to true by
@@ -486,11 +472,6 @@ func (p *AdventurePlugin) midnightReset() error {
if char.CurrentStreak > char.BestStreak { if char.CurrentStreak > char.BestStreak {
char.BestStreak = char.CurrentStreak char.BestStreak = char.CurrentStreak
} }
// Restamp to today (mirrors the busy branch above). A purely-legacy
// actor whose action path bumps CombatActionsUsed/HarvestActionsUsed
// but never stamps LastActionDate lands here with a stale date; without
// this its streak would reset to 1 every night even with continuous play.
char.LastActionDate = today
_ = saveAdvCharacter(&char) _ = saveAdvCharacter(&char)
} }
} }
@@ -509,12 +490,6 @@ func (p *AdventurePlugin) midnightReset() error {
return fmt.Errorf("reset daily actions after 3 attempts: %w", resetErr) return fmt.Errorf("reset daily actions after 3 attempts: %w", resetErr)
} }
// Clear the one-day pet morning-defense buff so it re-rolls fresh each
// morning (briefing or overworld DM) instead of leaking permanently.
if err := resetAllPetMorningDefense(); err != nil {
slog.Error("adventure: failed to reset pet morning defense", "err", err)
}
// Prune expired buffs // Prune expired buffs
if err := pruneAdvExpiredBuffs(); err != nil { if err := pruneAdvExpiredBuffs(); err != nil {
slog.Error("adventure: failed to prune expired buffs", "err", err) slog.Error("adventure: failed to prune expired buffs", "err", err)

View File

@@ -52,7 +52,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
case CombatStatusActive: case CombatStatusActive:
return p.SendDM(ctx.Sender, "You're already in this fight — `!attack` or `!flee`.") return p.SendDM(ctx.Sender, "You're already in this fight — `!attack` or `!flee`.")
case CombatStatusWon: case CombatStatusWon:
return p.SendDM(ctx.Sender, "You've already cleared this room. "+continueHint(ctx.Sender)) return p.SendDM(ctx.Sender, "You've already cleared this room. `!zone advance` to move on.")
default: default:
return p.SendDM(ctx.Sender, "This fight is already over. `!zone status` for where you stand.") return p.SendDM(ctx.Sender, "This fight is already over. `!zone status` for where you stand.")
} }
@@ -91,9 +91,11 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
} }
// Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto // Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto
// the session so they survive the turn engine's resume/commit cycle. The // the session so they survive the turn engine's resume/commit cycle, and
// pet now rolls per-turn inside the engine, so there's no fight-start roll. // make the one-and-only per-fight pet-attack roll.
if seedCombatSessionOneShots(sess, player.Mods) { seeded := seedCombatSessionOneShots(sess, player.Mods)
pet := rollCombatSessionPetProc(sess, player.Mods)
if seeded || pet {
if err := saveCombatSession(sess); err != nil { if err := saveCombatSession(sess); err != nil {
slog.Error("combat: seed session one-shots", "user", ctx.Sender, "err", err) slog.Error("combat: seed session one-shots", "user", ctx.Sender, "err", err)
} }
@@ -202,18 +204,6 @@ func combatTurnPrompt(sess *CombatSession) string {
// ── close-out ─────────────────────────────────────────────────────────────── // ── close-out ───────────────────────────────────────────────────────────────
// continueHint returns the verb the player uses to keep moving after a
// manual Elite/Boss fight, phrased for their current mode. On an
// expedition the autopilot drives the walk, so `!zone advance` is the
// wrong surface — point them at `!expedition run` instead. Standalone
// zone runs still advance with `!zone advance`.
func continueHint(userID id.UserID) string {
if exp, err := getActiveExpedition(userID); err == nil && exp != nil {
return "`!expedition run` to keep going."
}
return "`!zone advance` to move on."
}
// finishCombatSession runs the post-fight side effects once a CombatSession // finishCombatSession runs the post-fight side effects once a CombatSession
// has reached a terminal status, and returns the player-facing outcome block. // has reached a terminal status, and returns the player-facing outcome block.
// The graph is NOT advanced here: the terminal session row is the record that // The graph is NOT advanced here: the terminal session row is the record that
@@ -251,13 +241,11 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
slog.Error("combat: grantDnDXP turn-based", "user", userID, "err", err) slog.Error("combat: grantDnDXP turn-based", "user", userID, "err", err)
} }
} }
bossOnExpedition := false
if !elite { if !elite {
// §8.1 — zone boss defeat drops expedition threat. Silent no-op // §8.1 — zone boss defeat drops expedition threat. Silent no-op
// for standalone zone runs (no active expedition). // for standalone zone runs (no active expedition).
if exp, eerr := getActiveExpedition(userID); eerr == nil && exp != nil { if exp, eerr := getActiveExpedition(userID); eerr == nil && exp != nil {
_ = applyBossDefeatThreat(exp.ID) _ = applyBossDefeatThreat(exp.ID)
bossOnExpedition = true
} }
} }
if line := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence); line != "" { if line := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence); line != "" {
@@ -272,15 +260,7 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
if drop := p.dropZoneLoot(userID, zone.ID, monster, !elite); drop != "" { if drop := p.dropZoneLoot(userID, zone.ID, monster, !elite); drop != "" {
b.WriteString(drop + "\n") b.WriteString(drop + "\n")
} }
if bossOnExpedition { b.WriteString("`!zone advance` to move on.")
// The boss is the expedition's climax. Frame the close-out as
// the win rather than a "keep walking" nudge. One more
// `!expedition run` walks out the cleared room and triggers
// finalizeExpeditionOnZoneClear (rewards + status flip).
b.WriteString("🎉 **Zone cleared — the expedition is won.** `!expedition run` to march out and claim your spoils.")
} else {
b.WriteString(continueHint(userID))
}
case CombatStatusLost: case CombatStatusLost:
if run != nil { if run != nil {

View File

@@ -319,6 +319,10 @@ type combatState struct {
// the enemy would otherwise attack). // the enemy would otherwise attack).
enemySkipFirst bool enemySkipFirst bool
// Phase 13 turn-based — pet attack decided once at fight start; the pet
// strikes once on the player's first acting turn, which clears this.
petProcReady bool
// Phase 10 SUB2a-ii first-attack one-shots. // Phase 10 SUB2a-ii first-attack one-shots.
firstAttackBonusUsed bool firstAttackBonusUsed bool
assassinateRerollUsed bool assassinateRerollUsed bool

View File

@@ -65,6 +65,14 @@ type CombatStatuses struct {
// combatState, so the flag must survive the commit between them. // combatState, so the flag must survive the commit between them.
EnemySkipNext bool `json:"enemy_skip_next,omitempty"` EnemySkipNext bool `json:"enemy_skip_next,omitempty"`
// PetProcReady is the per-fight pet-attack outcome. Auto-resolve rolls the
// pet proc every round; a manual fight can run many rounds, so the roll is
// decided once at fight start (rollCombatSessionPetProc) and parked here.
// The pet then lands a single hit on the player's first acting turn, which
// clears the flag — persisted so a suspend/resume or reaper auto-play sees
// the same outcome.
PetProcReady bool `json:"pet_proc_ready,omitempty"`
// Fight-scoped depleting resources — mirror the combatState charges that // Fight-scoped depleting resources — mirror the combatState charges that
// genuinely carry round-to-round. Seeded at fight start (Arcane Ward) or by // genuinely carry round-to-round. Seeded at fight start (Arcane Ward) or by
// a mid-fight !cast / !consume, restored into combatState on every resume, // a mid-fight !cast / !consume, restored into combatState on every resume,

View File

@@ -425,120 +425,72 @@ func TestTurnEngine_OneShotsRoundTrip(t *testing.T) {
} }
} }
// ── Pet proc (per-round) ─────────────────────────────────────────────────── // ── Pet proc (per-fight) ───────────────────────────────────────────────────
// TestTurnEngine_PetStrike confirms a pet with a guaranteed proc strikes on func TestRollCombatSessionPetProc(t *testing.T) {
// every player-acting turn — the per-round roll model, matching auto-resolve. // No pet proc on the player → never rolls true, never touches statuses.
none := &CombatSession{SessionID: "no-pet"}
if rollCombatSessionPetProc(none, CombatModifiers{}) || none.Statuses.PetProcReady {
t.Error("zero PetAttackProc should not arm a pet strike")
}
// A guaranteed proc arms the flag; the draw is deterministic per session id.
sure := &CombatSession{SessionID: "pet-fight"}
if !rollCombatSessionPetProc(sure, CombatModifiers{PetAttackProc: 1.0}) || !sure.Statuses.PetProcReady {
t.Error("PetAttackProc 1.0 should always arm a pet strike")
}
again := &CombatSession{SessionID: "pet-fight"}
rollCombatSessionPetProc(again, CombatModifiers{PetAttackProc: 1.0})
if again.Statuses.PetProcReady != sure.Statuses.PetProcReady {
t.Error("same session id should roll the pet proc deterministically")
}
}
// TestTurnEngine_PetStrike confirms an armed pet lands exactly one hit on the
// player's first acting turn, then never again — the per-fight roll model.
func TestTurnEngine_PetStrike(t *testing.T) { func TestTurnEngine_PetStrike(t *testing.T) {
// Pools huge so no phase can end the fight; isolate the pet behaviour. // Pools huge so no phase can end the fight; isolate the pet behaviour.
sess := turnSession(CombatPhasePlayerTurn, 100000, 100000) sess := turnSession(CombatPhasePlayerTurn, 100000, 100000)
sess.Statuses.PetProcReady = true
player, enemy := basePlayer(), baseEnemy() player, enemy := basePlayer(), baseEnemy()
player.Mods.PetAttackProc = 1.0
player.Mods.PetAttackDmg = 8 player.Mods.PetAttackDmg = 8
petHitsOnPlayerTurn := func() int {
events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack}) events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
hits := 0 petHits := 0
for _, e := range events { for _, e := range events {
if e.Action == "pet_attack" { if e.Action == "pet_attack" {
hits++ petHits++
if e.Damage <= 0 { if e.Damage <= 0 {
t.Errorf("pet_attack damage = %d, want > 0", e.Damage) t.Errorf("pet_attack damage = %d, want > 0", e.Damage)
} }
} }
} }
return hits if petHits != 1 {
t.Fatalf("pet_attack events = %d on first player turn, want 1", petHits)
}
if sess.Statuses.PetProcReady {
t.Error("PetProcReady should clear after the pet strikes")
} }
if got := petHitsOnPlayerTurn(); got != 1 { // Cycle back to the next player turn — the pet must not strike again.
t.Fatalf("pet_attack events = %d on first player turn, want 1", got)
}
// Cycle back to the next player turn — the pet must strike again.
for sess.Phase != CombatPhasePlayerTurn { for sess.Phase != CombatPhasePlayerTurn {
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil { if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
if got := petHitsOnPlayerTurn(); got != 1 { events, err = stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack})
t.Errorf("pet_attack events = %d on second player turn, want 1 (per-round)", got)
}
}
// TestTurnEngine_PetNoProc confirms a player without the pet proc never sees a
// pet strike.
func TestTurnEngine_PetNoProc(t *testing.T) {
sess := turnSession(CombatPhasePlayerTurn, 100000, 100000)
player, enemy := basePlayer(), baseEnemy()
player.Mods.PetAttackProc = 0
events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
for _, e := range events { for _, e := range events {
if e.Action == "pet_attack" { if e.Action == "pet_attack" {
t.Error("pet struck with zero PetAttackProc") t.Error("pet struck twice — the roll is per fight, not per round")
} }
} }
} }
// TestTurnEngine_PetWhiff confirms a guaranteed whiff makes the enemy's turn a
// total miss — a pet_whiff event fires and the player takes no damage.
func TestTurnEngine_PetWhiff(t *testing.T) {
sess := turnSession(CombatPhaseEnemyTurn, 100000, 100000)
player, enemy := basePlayer(), baseEnemy()
player.Mods.PetWhiffProc = 1.0
enemy.Stats.AttackBonus = 50 // would connect easily if not whiffed
startHP := sess.PlayerHP
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
if err != nil {
t.Fatal(err)
}
whiffs := 0
for _, e := range events {
if e.Action == "pet_whiff" {
whiffs++
}
if e.Actor == "enemy" && e.Action == "hit" {
t.Error("enemy landed a hit despite a guaranteed pet whiff")
}
}
if whiffs == 0 {
t.Error("expected a pet_whiff event with PetWhiffProc 1.0")
}
if sess.PlayerHP != startHP {
t.Errorf("player took %d damage on a whiffed turn, want 0", startHP-sess.PlayerHP)
}
}
// TestTurnEngine_PetDeflect confirms a guaranteed deflect emits a pet_deflect
// event on a connecting enemy attack.
func TestTurnEngine_PetDeflect(t *testing.T) {
sess := turnSession(CombatPhaseEnemyTurn, 100000, 100000)
player, enemy := basePlayer(), baseEnemy()
player.Mods.PetDeflectProc = 1.0
enemy.Stats.AttackBonus = 50 // guarantee the swing connects
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
if err != nil {
t.Fatal(err)
}
deflects := 0
for _, e := range events {
if e.Action == "pet_deflect" {
deflects++
}
}
if deflects == 0 {
t.Error("expected a pet_deflect event with PetDeflectProc 1.0 on a connecting hit")
}
}
func TestCombatSessionRNG_DeterministicPerPhase(t *testing.T) { func TestCombatSessionRNG_DeterministicPerPhase(t *testing.T) {
sess := &CombatSession{SessionID: "abc123", Round: 2, Phase: CombatPhaseEnemyTurn} sess := &CombatSession{SessionID: "abc123", Round: 2, Phase: CombatPhaseEnemyTurn}
a := combatSessionRNG(sess) a := combatSessionRNG(sess)

View File

@@ -116,6 +116,7 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
armorBroken: sess.Statuses.ArmorBroken, armorBroken: sess.Statuses.ArmorBroken,
armorBreakAmt: sess.Statuses.ArmorBreakAmt, armorBreakAmt: sess.Statuses.ArmorBreakAmt,
enemySkipFirst: sess.Statuses.EnemySkipNext, enemySkipFirst: sess.Statuses.EnemySkipNext,
petProcReady: sess.Statuses.PetProcReady,
// Fight-scoped depleting resources + once-per-fight one-shots: restored // Fight-scoped depleting resources + once-per-fight one-shots: restored
// from the persisted statuses so a charge or "already used" flag can't // from the persisted statuses so a charge or "already used" flag can't
// reset across a suspend/resume. commit writes the updated values back. // reset across a suspend/resume. commit writes the updated values back.
@@ -222,19 +223,19 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
te.sess.Phase = CombatPhaseEnemyTurn te.sess.Phase = CombatPhaseEnemyTurn
} }
// petStrike resolves the player's pet attack for a turn-based fight. The pet // petStrike resolves the player's pet attack for a turn-based fight. Whether
// rolls fresh on every player-acting turn (PetAttackProc), mirroring the // the pet lands a hit was decided once at fight start (rollCombatSessionPetProc)
// auto-resolve engine's per-round chance rather than a once-per-fight strike. // and parked on the session; the pet then strikes a single time on the player's
// The roll rides the per-(round,phase) step RNG, so a suspend/resume or reaper // first acting turn — this clears the flag so it never repeats. Damage reuses
// auto-play of the same turn reproduces the same outcome. Damage reuses the // the auto-resolve formula (PetAttackDmg + d5), and PetAttackDmg already carries
// auto-resolve formula (PetAttackDmg + d5), and PetAttackDmg already carries any // any mid-fight buff delta via applySessionBuffs. Returns true if the strike
// mid-fight buff delta via applySessionBuffs. Returns true if the strike dropped // dropped the enemy.
// the enemy.
func (te *turnEngine) petStrike() bool { func (te *turnEngine) petStrike() bool {
st := te.st st := te.st
if te.player.Mods.PetAttackProc <= 0 || st.randFloat() >= te.player.Mods.PetAttackProc { if !st.petProcReady {
return false return false
} }
st.petProcReady = false
petDmg := te.player.Mods.PetAttackDmg + st.roll(5) petDmg := te.player.Mods.PetAttackDmg + st.roll(5)
st.enemyHP = max(0, st.enemyHP-petDmg) st.enemyHP = max(0, st.enemyHP-petDmg)
st.events = append(st.events, CombatEvent{ st.events = append(st.events, CombatEvent{
@@ -244,6 +245,32 @@ func (te *turnEngine) petStrike() bool {
return enemyDown(st, turnCombatPhase.Name) return enemyDown(st, turnCombatPhase.Name)
} }
// rollCombatSessionPetProc makes the one-and-only per-fight pet-attack roll and
// parks the result on the session. Called once at fight start. The draw is
// deterministic — seeded off the session id on a stream distinct from the
// per-(round,phase) combat streams — so a reaper auto-play of an abandoned
// fight reproduces the same outcome. Returns true if the pet will attack (so
// the caller can decide whether the session needs persisting).
//
// Note: only the base PetAttackProc (class/race/subclass passives) is rolled
// here — a pet-proc buff cast mid-fight gets no fresh roll, consistent with the
// per-fight rule. Such a buff still raises PetAttackDmg if the pet does strike.
func rollCombatSessionPetProc(sess *CombatSession, playerMods CombatModifiers) bool {
if playerMods.PetAttackProc <= 0 {
return false
}
var seed uint64 = 1469598103934665603
for _, c := range sess.SessionID {
seed = (seed ^ uint64(c)) * 1099511628211
}
rng := rand.New(rand.NewPCG(seed, 0x9E3779B97F4A7C15))
if rngFloat(rng) < playerMods.PetAttackProc {
sess.Statuses.PetProcReady = true
return true
}
return false
}
// stepPlayerActionEffect resolves a !cast / !consume turn: the command handler // stepPlayerActionEffect resolves a !cast / !consume turn: the command handler
// has already rolled the spell / picked the item and spent the resource, so the // has already rolled the spell / picked the item and spent the resource, so the
// engine only applies the HP deltas and emits the event before handing off to // engine only applies the HP deltas and emits the event before handing off to
@@ -348,32 +375,17 @@ func (te *turnEngine) stepEnemyTurn() {
} }
if !abilityDealtDamage { if !abilityDealtDamage {
// Pet defensive procs are a single proc per enemy turn: roll once, then
// spend it on the first swing only. Whiff makes that one swing a
// guaranteed miss; deflect halves its damage. Against a multiattack the
// remaining swings resolve normally — a single proc shouldn't nullify a
// boss's whole multiattack round. (This deliberately diverges from the
// auto-resolve engine's apply-to-all model.)
petWhiff := te.player.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.player.Mods.PetWhiffProc
petDeflect := te.player.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.player.Mods.PetDeflectProc
if petDeflect {
te.result.PetDeflected = true
}
// SRD multiattack: each profile entry is one attack roll resolved // SRD multiattack: each profile entry is one attack roll resolved
// through the shared primitive. A registered elite/boss swings its full // through the shared primitive. A registered elite/boss swings its full
// profile; everyone else gets a single attack from the template stats. // profile; everyone else gets a single attack from the template stats.
// resolveEnemyAttack returns true when the fight is decided — either the // resolveEnemyAttack returns true when the fight is decided — either the
// player went down without a death save, or a reflect consumable killed // player went down without a death save, or a reflect consumable killed
// the enemy. Disambiguate by inspecting HP. // the enemy. Disambiguate by inspecting HP.
for i, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) { for _, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) {
swing := *te.enemy swing := *te.enemy
swing.Stats.Attack = atk.Damage swing.Stats.Attack = atk.Damage
swing.Stats.AttackBonus = atk.AttackBonus swing.Stats.AttackBonus = atk.AttackBonus
// Spend the proc on the first swing only; later swings see false. decided := resolveEnemyAttack(te.st, te.player, &swing, &turnCombatPhase, te.result, false, false, false)
swingWhiff := petWhiff && i == 0
swingDeflect := petDeflect && i == 0
decided := resolveEnemyAttack(te.st, te.player, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false)
if te.st.playerHP <= 0 { if te.st.playerHP <= 0 {
te.finish(CombatStatusLost) te.finish(CombatStatusLost)
return return
@@ -467,6 +479,7 @@ func (te *turnEngine) commit() {
s.ArmorBroken = st.armorBroken s.ArmorBroken = st.armorBroken
s.ArmorBreakAmt = st.armorBreakAmt s.ArmorBreakAmt = st.armorBreakAmt
s.EnemySkipNext = st.enemySkipFirst s.EnemySkipNext = st.enemySkipFirst
s.PetProcReady = st.petProcReady
s.WardCharges = st.wardCharges s.WardCharges = st.wardCharges
s.SporeRounds = st.sporeRounds s.SporeRounds = st.sporeRounds
s.ReflectFrac = st.reflectFrac s.ReflectFrac = st.reflectFrac

View File

@@ -79,9 +79,12 @@ func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error {
case "rough", "standard": case "rough", "standard":
// allowed in E1e // allowed in E1e
case "fortified": case "fortified":
// E2d: §5.1 — boss-cleared room or cache site. Cache sites
// are zone-specific waypoints (E3+), so for now we require the
// expedition's boss to have been defeated.
if !exp.BossDefeated { if !exp.BossDefeated {
return p.SendDM(ctx.Sender, return p.SendDM(ctx.Sender,
"Fortified camps require a defeated zone boss. Clear the zone first.") "Fortified camps require a boss-cleared room or cache site. Defeat the zone boss (or find a cache) first.")
} }
case "base": case "base":
// E4d: §11.1 — base camps unlock per region after the region // E4d: §11.1 — base camps unlock per region after the region
@@ -119,7 +122,7 @@ func campHelpText(exp *Expedition) string {
b.WriteString("**!camp <type>** — establish camp during an expedition.\n\n") b.WriteString("**!camp <type>** — establish camp during an expedition.\n\n")
b.WriteString("`!camp rough` — partial rest (any location, +0.5 SU, high night risk)\n") b.WriteString("`!camp rough` — partial rest (any location, +0.5 SU, high night risk)\n")
b.WriteString("`!camp standard` — full long rest (cleared room, +1 SU, medium risk)\n") b.WriteString("`!camp standard` — full long rest (cleared room, +1 SU, medium risk)\n")
b.WriteString("`!camp fortified` — long rest + bonus (zone boss defeated, +2 SU, low risk)\n") b.WriteString("`!camp fortified` — long rest + bonus (boss-cleared room, +2 SU, low risk)\n")
b.WriteString("`!camp base` — persistent waypoint (region-boss-cleared base-camp site, +3 SU, very low risk)\n") b.WriteString("`!camp base` — persistent waypoint (region-boss-cleared base-camp site, +3 SU, very low risk)\n")
b.WriteString("`!camp break` — break camp\n\n") b.WriteString("`!camp break` — break camp\n\n")
if exp.Camp != nil && exp.Camp.Active { if exp.Camp != nil && exp.Camp.Active {
@@ -143,10 +146,10 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
return p.SendDM(ctx.Sender, problem) return p.SendDM(ctx.Sender, problem)
} }
if !cleared && kind == CampTypeStandard { if !cleared && kind == CampTypeStandard {
// §5.2: standard camp requires a cleared room. Reject explicitly // §5.2: non-cleared room forces rough.
// rather than silently downgrading — the player should make the call. kind = CampTypeRough
return p.SendDM(ctx.Sender, _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
"Standard camp needs a cleared room. This room isn't cleared yet — clear it first, or `!camp rough` for a partial rest here.") "intended standard camp; downgraded to rough (room not cleared)", "")
} }
cost, ok := campSupplyCost[kind] cost, ok := campSupplyCost[kind]
@@ -356,25 +359,15 @@ func campLocationCheck(exp *Expedition) (cleared bool, problem string) {
case RoomTrap: case RoomTrap:
return false, "You can't camp in a trap room — even a disarmed one." return false, "You can't camp in a trap room — even a disarmed one."
} }
// Active-enemy detection requires combat-state lookup; defer to E2.
cleared = false
for _, idx := range run.RoomsCleared { for _, idx := range run.RoomsCleared {
if idx == run.CurrentRoom { if idx == run.CurrentRoom {
return true, "" cleared = true
break
} }
} }
// Not yet advanced-past, but the only thing that bars a rest is a live return cleared, ""
// fight. Forward-only navigation means players pause right after a kill
// before advancing, and peaceful/exploration/loot rooms never spawn an
// encounter at all — both are safe to rest in. The "cleared" flag would
// otherwise reject standard camp here with a misleading "clear it first".
encID := encounterIDForRoom(run.CurrentRoom)
sess, err := getCombatSessionForEncounter(run.RunID, encID)
if err != nil {
return false, ""
}
if sess != nil && sess.Status == CombatStatusActive {
return false, "You can't camp mid-fight — finish the encounter first."
}
return true, ""
} }
// campCurrentRoomIndex returns 0 (entry) when no room context exists. // campCurrentRoomIndex returns 0 (entry) when no room context exists.

View File

@@ -261,7 +261,6 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
// Log the start with prewritten flavor. // Log the start with prewritten flavor.
startLine := flavor.Pick(flavor.ExpeditionStart) startLine := flavor.Pick(flavor.ExpeditionStart)
_ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine) _ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine)
markActedToday(ctx.Sender)
var b strings.Builder var b strings.Builder
b.WriteString(fmt.Sprintf("🗺 **Expedition begins — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier))) b.WriteString(fmt.Sprintf("🗺 **Expedition begins — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier)))
@@ -485,17 +484,11 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
if err := abandonExpedition(ctx.Sender); err != nil { if err := abandonExpedition(ctx.Sender); err != nil {
return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error())
} }
markActedToday(ctx.Sender)
_ = retireAllRegionRuns(exp) _ = retireAllRegionRuns(exp)
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "") _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "")
if err := p.SendDM(ctx.Sender, fmt.Sprintf( return p.SendDM(ctx.Sender, fmt.Sprintf(
"Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.", "Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.",
zone.Display, exp.CurrentDay)); err != nil { zone.Display, exp.CurrentDay))
return err
}
// Emergence seam: see maybeRollPetArrivalOnEmerge.
p.maybeRollPetArrivalOnEmerge(ctx.Sender)
return nil
} }
// helper: ensure we don't shadow id.UserID import in test harness. // helper: ensure we don't shadow id.UserID import in test harness.
@@ -556,19 +549,7 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
if r.initErr != "" { if r.initErr != "" {
return p.SendDM(ctx.Sender, r.initErr) return p.SendDM(ctx.Sender, r.initErr)
} }
// Emergence seam: a natural run-complete (boss down / dead-end node) return p.streamFlow(ctx.Sender, r.stream, r.finalMsg)
// surfaces the player alive just like an extract or abandon — roll pet
// arrival here too. The roll lives in the real callers, not in
// runAutopilotWalk, so the sim path (which calls the walk directly)
// never fires arrival DMs. See maybeRollPetArrivalOnEmerge. Defer it
// behind the paced stream so the "animal in your house" DM lands after
// the "Run complete" beat, not before it.
var after func()
if r.reason == stopComplete {
uid := ctx.Sender
after = func() { p.maybeRollPetArrivalOnEmerge(uid) }
}
return p.streamFlowThen(ctx.Sender, r.stream, r.finalMsg, after)
} }
// runAutopilotWalk runs the autopilot loop up to maxRooms times and // runAutopilotWalk runs the autopilot loop up to maxRooms times and
@@ -589,21 +570,6 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
return autopilotWalkResult{initErr: "No active region run. Try `!region` to refresh."} 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.
if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil {
if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil {
zone := zoneOrFallback(run.ZoneID)
return autopilotWalkResult{
finalMsg: renderForkPrompt(zone, *pf),
rooms: 0,
reason: stopFork,
}
}
}
var stream []string var stream []string
var finalMsg string var finalMsg string
rooms := 0 rooms := 0
@@ -646,46 +612,6 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
rooms++ rooms++
} }
// Multi-region auto-advance: a mid-zone region clear completes the
// region's run (stopComplete) but leaves the wrapping expedition
// active. Rather than dead-stopping the walk at every region
// boundary, cross into the next region — burning the transit day +
// supplies exactly like manual `!region travel` — and keep walking
// within the remaining room budget. A full zone clear instead flips
// the expedition to 'complete' (getActiveExpedition → nil) and falls
// through to the normal stop below.
if res.reason == stopComplete {
if fresh, ferr := getActiveExpedition(ctx.Sender); ferr == nil && fresh != nil &&
IsMultiRegionZone(fresh.ZoneID) {
if cur, ok := CurrentRegion(fresh); ok {
if next, ok := nextRegion(fresh.ZoneID, cur.ID); ok {
// A region crossing burns a transit day + supplies and
// draws unprotected wandering damage. On the background
// walk, don't cross while the player is weak — preflight
// HP/SU and hand the crossing back to a foreground
// `!region travel` / `!expedition run` if either is low.
if compact {
if msg, stop := autopilotPreflight(ctx.Sender, fresh); stop {
finalMsg = res.final + "\n\n" + msg
reason = stopPreflight
break
}
}
stream = append(stream, res.final)
transit, terr := p.advanceToNextRegion(ctx.Sender, fresh, cur, next)
if terr != nil {
finalMsg = res.final + "\n\n⏸ **Autopilot paused — region transit failed.** `!region travel` to cross over manually."
reason = stopComplete
break
}
stream = append(stream, transit)
exp = fresh
continue
}
}
}
}
if res.reason != stopOK { if res.reason != stopOK {
footer := autopilotFooter(res.reason, rooms) footer := autopilotFooter(res.reason, rooms)
if footer != "" { if footer != "" {

View File

@@ -300,33 +300,9 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
} }
if uid := id.UserID(e.UserID); uid != "" { if uid := id.UserID(e.UserID); uid != "" {
// The legacy overworld morning DM is skipped while underground, so
// its 25% morning pet event fires here instead, granting the one-day
// defense buff (reset at midnight via resetAllPetMorningDefense).
// Pet *arrival* is handled separately on the emergence seam below —
// not queued here — so we only roll the morning event.
if pet, perr := loadPetState(uid); perr == nil {
if petEvent := petMorningEvent(pet); petEvent != "" {
if char, cerr := loadAdvCharacter(uid); cerr == nil {
char.PetMorningDefense = true
if serr := saveAdvCharacter(char); serr != nil {
slog.Warn("expedition: save pet morning defense", "user", uid, "err", serr)
}
}
body = fmt.Sprintf("🐾 *%s*\n\n%s", petEvent, body)
}
}
if err := p.SendDM(uid, body); err != nil { if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: send briefing DM", "user", uid, "err", err) slog.Warn("expedition: send briefing DM", "user", uid, "err", err)
} }
// Emergence seam: a briefing-time forced extraction (starvation /
// abyss collapse) surfaces the player alive — roll pet arrival.
// Combat/patrol deaths never reach deliverBriefing (the row is
// already abandoned), so an abandoned status here means a survived
// emergence; those death paths roll on respawn instead.
if e.Status == ExpeditionStatusAbandoned {
p.maybeRollPetArrivalOnEmerge(uid)
}
} }
if err := appendExpeditionLog(e.ID, e.CurrentDay, "briefing", if err := appendExpeditionLog(e.ID, e.CurrentDay, "briefing",
fmt.Sprintf("morning briefing — %.1f SU consumed overnight", burn), line); err != nil { fmt.Sprintf("morning briefing — %.1f SU consumed overnight", burn), line); err != nil {

View File

@@ -110,89 +110,6 @@ func forceExtractExpeditionForRunLoss(userID id.UserID, reason string) {
} }
} }
// finalizeExpeditionOnZoneClear bridges the run-complete seam (boss down,
// no outgoing edges) into expedition completion — the success-path twin of
// forceExtractExpeditionForRunLoss. When the cleared run is the active
// expedition's current run AND the clear finishes the whole zone (a
// single-region zone, or the zone-boss region of a multi-region zone), it
// flips the expedition to 'complete', records boss_defeated, and awards
// completion milestones. Returns the rendered milestone lines for the caller
// to append to the run-complete message.
//
// No-op (nil) for standalone runs and for mid-zone region clears, which
// leave the expedition active so inter-region travel can continue. Without
// this, a cleared zone leaves the expedition 'active' forever and the
// ambient ticker keeps DMing about a dungeon the player already finished.
func (p *AdventurePlugin) finalizeExpeditionOnZoneClear(userID id.UserID, runID string) []string {
exp, err := getActiveExpedition(userID)
if err != nil || exp == nil {
return nil
}
if exp.RunID != runID {
return nil // the completed run isn't this expedition's current run
}
if IsMultiRegionZone(exp.ZoneID) {
region, ok := CurrentRegion(exp)
if !ok {
return nil
}
if _, err := MarkRegionBossDefeated(exp, region.ID); err != nil {
slog.Warn("expedition: mark region boss defeated",
"user", userID, "expedition", exp.ID, "region", region.ID, "err", err)
}
if !region.IsZoneBoss {
return nil // region cleared; expedition continues to the next region
}
} else {
// Single-region zone: there's no region registry to flip through
// MarkRegionBossDefeated, so set the zone-level flag directly.
exp.BossDefeated = true
if _, err := db.Get().Exec(`
UPDATE dnd_expedition
SET boss_defeated = 1,
last_activity = CURRENT_TIMESTAMP
WHERE expedition_id = ?`, exp.ID); err != nil {
slog.Warn("expedition: set boss defeated on zone clear",
"user", userID, "expedition", exp.ID, "err", err)
}
}
// completeExpedition must run before AwardCompletionMilestones — the
// latter gates on status == 'complete'.
if err := completeExpedition(exp.ID, ExpeditionStatusComplete); err != nil {
slog.Warn("expedition: complete on zone clear",
"user", userID, "expedition", exp.ID, "err", err)
return nil
}
exp.Status = ExpeditionStatusComplete
_ = retireAllRegionRuns(exp)
return p.AwardCompletionMilestones(exp, false)
}
// midZoneRegionClear reports whether the just-completed run (runID) is the
// active expedition's current run AND finishes a non-boss region of a
// multi-region zone — i.e. a region clear that leaves the expedition active
// with a next region to cross into. Returns the cleared region, the next
// region, and true in that case; zero-values + false for a full zone clear,
// a standalone run, or any read error. Used to word the run-complete message
// (region-cleared vs zone-cleared) without re-deriving region state inline.
func midZoneRegionClear(userID id.UserID, runID string) (cur, next ExpeditionRegion, ok bool) {
exp, err := getActiveExpedition(userID)
if err != nil || exp == nil || exp.RunID != runID || !IsMultiRegionZone(exp.ZoneID) {
return ExpeditionRegion{}, ExpeditionRegion{}, false
}
region, found := CurrentRegion(exp)
if !found || region.IsZoneBoss {
return ExpeditionRegion{}, ExpeditionRegion{}, false
}
nxt, found := nextRegion(exp.ZoneID, region.ID)
if !found {
return ExpeditionRegion{}, ExpeditionRegion{}, false
}
return region, nxt, true
}
// getResumableExpedition returns the most recent 'extracting' row for the // getResumableExpedition returns the most recent 'extracting' row for the
// user, regardless of age (caller checks the 7-day window). // user, regardless of age (caller checks the 7-day window).
func getResumableExpedition(userID id.UserID) (*Expedition, error) { func getResumableExpedition(userID id.UserID) (*Expedition, error) {
@@ -252,7 +169,6 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error {
line := flavor.Pick(flavor.ExtractionVoluntary) line := flavor.Pick(flavor.ExtractionVoluntary)
_ = appendExpeditionLog(updated.ID, updated.CurrentDay, "narrative", _ = appendExpeditionLog(updated.ID, updated.CurrentDay, "narrative",
"voluntary extraction", line) "voluntary extraction", line)
markActedToday(ctx.Sender)
var b strings.Builder var b strings.Builder
b.WriteString(fmt.Sprintf("🚪 **Extraction — %s, Day %d**\n\n", b.WriteString(fmt.Sprintf("🚪 **Extraction — %s, Day %d**\n\n",
@@ -263,13 +179,7 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error {
} }
b.WriteString(fmt.Sprintf("Loot, XP, and coins are kept. The dungeon stays where you left it — `!resume` within 7 days to come back. After %s the expedition expires.", b.WriteString(fmt.Sprintf("Loot, XP, and coins are kept. The dungeon stays where you left it — `!resume` within 7 days to come back. After %s the expedition expires.",
(time.Now().UTC().Add(extractResumeWindow)).Format("2006-01-02 15:04 MST"))) (time.Now().UTC().Add(extractResumeWindow)).Format("2006-01-02 15:04 MST")))
if err := p.SendDM(ctx.Sender, b.String()); err != nil { return p.SendDM(ctx.Sender, b.String())
return err
}
// Emergence seam: surfacing from a run is when an animal may have moved
// into the empty house.
p.maybeRollPetArrivalOnEmerge(ctx.Sender)
return nil
} }
// ── !resume command ───────────────────────────────────────────────────────── // ── !resume command ─────────────────────────────────────────────────────────

View File

@@ -119,9 +119,6 @@ func (p *AdventurePlugin) handleExpeditionMapCmd(ctx MessageContext, _ string) e
b.WriteString(renderZoneGraphMap(g, run)) b.WriteString(renderZoneGraphMap(g, run))
b.WriteString("\n```\n") b.WriteString("\n```\n")
b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending locked_") b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending locked_")
if path := renderVisitedPath(g, run); path != "" {
b.WriteString("\n**Path:** " + path)
}
if chain != "" { if chain != "" {
b.WriteString("\n_Regions: ") b.WriteString("\n_Regions: ")
b.WriteString(renderRegionLegend(exp)) b.WriteString(renderRegionLegend(exp))

View File

@@ -6,8 +6,6 @@ import (
"strings" "strings"
"gogobee/internal/flavor" "gogobee/internal/flavor"
"maunium.net/go/mautrix/id"
) )
// Phase 12 E4c — !region command surface and inter-region travel. // Phase 12 E4c — !region command surface and inter-region travel.
@@ -122,41 +120,24 @@ func (p *AdventurePlugin) regionCmdTravel(ctx MessageContext, exp *Expedition) e
"You're already in **%s** — the final region of this zone. Defeat the zone boss to complete the expedition.", "You're already in **%s** — the final region of this zone. Defeat the zone boss to complete the expedition.",
cur.Name)) cur.Name))
} }
narrative, err := p.advanceToNextRegion(ctx.Sender, exp, cur, next)
if err != nil {
return p.SendDM(ctx.Sender, "Region transit failed: "+err.Error())
}
return p.SendDM(ctx.Sender, narrative)
}
// advanceToNextRegion performs an inter-region transition: burns the transit
// day + supplies, fires the transit wandering check, retires the outgoing
// region's DungeonRun, bumps CurrentRegion + the visited list, and spawns the
// incoming region's run (pinning exp.RunID). Returns the rendered transit
// narrative block. Shared by the manual `!region travel` command and the
// autopilot walk's mid-zone auto-advance — keeping the transit cost identical
// on both paths.
func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition, cur, next ExpeditionRegion) (string, error) {
// Burn one day of supplies (transit day). // Burn one day of supplies (transit day).
siege := exp.SiegeMode siege := exp.SiegeMode
harsh := exp.ThreatLevel > 60 || zoneTemporalHarsh(exp) harsh := exp.ThreatLevel > 60 || zoneTemporalHarsh(exp)
newSupplies, burned := applyDailyBurn(exp.Supplies, harsh, siege) newSupplies, burned := applyDailyBurn(exp.Supplies, harsh, siege)
exp.Supplies = newSupplies exp.Supplies = newSupplies
if err := updateSupplies(exp.ID, exp.Supplies); err != nil { if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
return "", fmt.Errorf("apply transit supply burn: %w", err) return p.SendDM(ctx.Sender, "Couldn't apply transit supply burn: "+err.Error())
} }
if err := advanceExpeditionDay(exp.ID); err != nil { if err := advanceExpeditionDay(exp.ID); err != nil {
return "", fmt.Errorf("advance expedition day: %w", err) return p.SendDM(ctx.Sender, "Couldn't advance the expedition day: "+err.Error())
} }
exp.CurrentDay++ exp.CurrentDay++
// Transit wandering check (no camp protection — campMod = 0). // Transit wandering check (no camp protection — campMod = 0).
c, _ := LoadDnDCharacter(userID) c, _ := LoadDnDCharacter(ctx.Sender)
var charClass DnDClass var charClass DnDClass
charLevel := 1
if c != nil { if c != nil {
charClass = c.Class charClass = c.Class
charLevel = c.Level
} }
tc := resolveTransitWanderingCheck(exp, charClass, nil) tc := resolveTransitWanderingCheck(exp, charClass, nil)
_ = processTransitWanderingCheck(exp, tc) _ = processTransitWanderingCheck(exp, tc)
@@ -164,20 +145,24 @@ func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition,
// R2 — retire the outgoing region's DungeonRun before mutating // R2 — retire the outgoing region's DungeonRun before mutating
// CurrentRegion so retireRegionRun keys the right region. // CurrentRegion so retireRegionRun keys the right region.
if err := retireRegionRun(exp, cur.ID); err != nil { if err := retireRegionRun(exp, cur.ID); err != nil {
return "", fmt.Errorf("retire previous region run: %w", err) return p.SendDM(ctx.Sender, "Couldn't retire previous region run: "+err.Error())
} }
// Update CurrentRegion + visited list. // Update CurrentRegion + visited list.
if err := setCurrentRegion(exp, next.ID); err != nil { if err := setCurrentRegion(exp, next.ID); err != nil {
return "", fmt.Errorf("update current region: %w", err) return p.SendDM(ctx.Sender, "Couldn't update current region: "+err.Error())
} }
if _, err := MarkRegionVisited(exp, next.ID); err != nil { if _, err := MarkRegionVisited(exp, next.ID); err != nil {
return "", fmt.Errorf("mark region visited: %w", err) return p.SendDM(ctx.Sender, "Couldn't mark region visited: "+err.Error())
} }
// Spawn the new region's DungeonRun and pin exp.RunID. // Spawn the new region's DungeonRun and pin exp.RunID.
charLevel := 1
if c != nil {
charLevel = c.Level
}
if _, err := ensureRegionRun(exp, charLevel); err != nil { if _, err := ensureRegionRun(exp, charLevel); err != nil {
return "", fmt.Errorf("outfit the new region: %w", err) return p.SendDM(ctx.Sender, "Couldn't outfit the new region: "+err.Error())
} }
// Log + flavor. // Log + flavor.
@@ -204,7 +189,7 @@ func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition,
if next.IsZoneBoss { if next.IsZoneBoss {
b.WriteString("\n_★ Final region — the zone boss is here._") b.WriteString("\n_★ Final region — the zone boss is here._")
} }
return b.String(), nil return p.SendDM(ctx.Sender, b.String())
} }
// resolveTransitWanderingCheck mirrors resolveWanderingCheck (§6.1) but // resolveTransitWanderingCheck mirrors resolveWanderingCheck (§6.1) but

View File

@@ -1,116 +0,0 @@
package plugin
import (
"testing"
"maunium.net/go/mautrix/id"
)
// Single-region zone: clearing the run completes the wrapping expedition
// and flips BossDefeated.
func TestFinalizeExpeditionOnZoneClear_SingleRegionCompletes(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@zclear-single:example.org")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneSunkenTemple, "run-1",
ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1})
if err != nil {
t.Fatal(err)
}
if exp.RunID != "run-1" {
t.Fatalf("setup: RunID = %q", exp.RunID)
}
p := &AdventurePlugin{}
p.finalizeExpeditionOnZoneClear(uid, "run-1")
if act, _ := getActiveExpedition(uid); act != nil {
t.Fatalf("expedition still active after zone clear: status=%s", act.Status)
}
loaded, _ := getExpedition(exp.ID)
if loaded.Status != ExpeditionStatusComplete {
t.Errorf("status = %q, want %q", loaded.Status, ExpeditionStatusComplete)
}
if !loaded.BossDefeated {
t.Error("BossDefeated not set after single-region zone clear")
}
}
// A run that isn't the expedition's current run must not complete it.
func TestFinalizeExpeditionOnZoneClear_RunMismatchNoop(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@zclear-mismatch:example.org")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneSunkenTemple, "run-1",
ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1})
if err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
p.finalizeExpeditionOnZoneClear(uid, "some-other-run")
loaded, _ := getExpedition(exp.ID)
if loaded.Status != ExpeditionStatusActive {
t.Errorf("status = %q, want active (mismatched run should be no-op)", loaded.Status)
}
}
// Multi-region zone: clearing a non-boss region marks it cleared but leaves
// the expedition active so inter-region travel can continue.
func TestFinalizeExpeditionOnZoneClear_MidZoneRegionStaysActive(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@zclear-midzone:example.org")
defer cleanupExpeditions(uid)
// CurrentRegion defaults to the first region (underdark_surface_tunnels,
// not the zone boss).
exp, err := startExpedition(uid, ZoneUnderdark, "run-1",
ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1})
if err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
p.finalizeExpeditionOnZoneClear(uid, "run-1")
loaded, _ := getExpedition(exp.ID)
if loaded.Status != ExpeditionStatusActive {
t.Errorf("status = %q, want active after non-boss region clear", loaded.Status)
}
if !IsRegionCleared(loaded, "underdark_surface_tunnels") {
t.Error("first region not marked cleared")
}
if loaded.BossDefeated {
t.Error("BossDefeated set on a non-boss region clear")
}
}
// Multi-region zone: clearing the zone-boss region completes the expedition.
func TestFinalizeExpeditionOnZoneClear_ZoneBossRegionCompletes(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@zclear-zoneboss:example.org")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneUnderdark, "run-1",
ExpeditionSupplies{Max: 10, Current: 10, DailyBurn: 1})
if err != nil {
t.Fatal(err)
}
if err := setCurrentRegion(exp, "underdark_deep_throne"); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
p.finalizeExpeditionOnZoneClear(uid, "run-1")
loaded, _ := getExpedition(exp.ID)
if loaded.Status != ExpeditionStatusComplete {
t.Errorf("status = %q, want complete after zone-boss region clear", loaded.Status)
}
if !loaded.BossDefeated {
t.Error("BossDefeated not set after zone-boss region clear")
}
}

View File

@@ -114,7 +114,6 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error {
if err := SaveDnDCharacter(c); err != nil { if err := SaveDnDCharacter(c); err != nil {
return p.SendDM(ctx.Sender, "Couldn't save rest state: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't save rest state: "+err.Error())
} }
markActedToday(ctx.Sender)
var msg string var msg string
if c.HPCurrent > before { if c.HPCurrent > before {
@@ -209,7 +208,6 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error {
if err := SaveDnDCharacter(c); err != nil { if err := SaveDnDCharacter(c); err != nil {
return p.SendDM(ctx.Sender, "Couldn't save rest state: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't save rest state: "+err.Error())
} }
markActedToday(ctx.Sender)
_ = refreshAllResources(ctx.Sender) _ = refreshAllResources(ctx.Sender)
// Phase 9: spell slots refresh on long rest. // Phase 9: spell slots refresh on long rest.
_ = refreshSpellSlots(ctx.Sender) _ = refreshSpellSlots(ctx.Sender)

View File

@@ -308,9 +308,6 @@ func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error {
b.WriteString(renderZoneGraphMap(g, run)) b.WriteString(renderZoneGraphMap(g, run))
b.WriteString("\n```\n") b.WriteString("\n```\n")
b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending locked_") b.WriteString("_E=Entry ?=Exploration T=Trap ★=Elite ☠=Boss ⚿=Secret · ✓ cleared ▶ here · pending locked_")
if path := renderVisitedPath(g, run); path != "" {
b.WriteString("\n**Path:** " + path)
}
return p.SendDM(ctx.Sender, b.String()) return p.SendDM(ctx.Sender, b.String())
} }
// No registered graph (defensive — every zone has one post-G8). // No registered graph (defensive — every zone has one post-G8).
@@ -460,23 +457,6 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
} }
_ = applyMoodDecayIfStale(run) _ = applyMoodDecayIfStale(run)
zone := zoneOrFallback(run.ZoneID) zone := zoneOrFallback(run.ZoneID)
// A pending fork means advanceTransitionGraph already cleared the
// current room and stopped — re-running resolveRoom would re-fire
// combat and re-drop loot on the same room. Re-emit the fork prompt
// and let the caller surface it; the player commits via !zone go <n>.
// This returns *before* crediting the daily streak: spamming `!zone
// advance` at a fork resolves no room, so it must not keep the streak alive.
if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil {
return advanceResult{
final: renderForkPrompt(zone, *pf),
reason: stopFork,
}, nil
}
// compact==true is the background auto-walk path; only credit
// player-initiated advances toward the daily streak.
if !compact {
markActedToday(ctx.Sender)
}
prev := run.CurrentRoomType() prev := run.CurrentRoomType()
prevIdx := run.CurrentRoom prevIdx := run.CurrentRoom
@@ -613,16 +593,7 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
b.WriteString(outcome) b.WriteString(outcome)
b.WriteString("\n\n") b.WriteString("\n\n")
} }
// A "complete" run is only a full zone clear when it isn't a mid-zone
// region clear of a multi-region zone. For the latter, name the region
// and point at the next one — "Cleared {zone}. Run complete." reads
// wrong right before the auto-advance transit block (and is shared with
// manual `!region travel`, which advances next).
if region, next, midZone := midZoneRegionClear(ctx.Sender, run.RunID); midZone {
b.WriteString(fmt.Sprintf("🏁 **Cleared %s.** The way to %s opens ahead.\n\n", region.Name, next.Name))
} else {
b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Run complete.\n\n", zone.Display)) b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Run complete.\n\n", zone.Display))
}
if line := twinBeeLine(zone.ID, DMZoneComplete, run.RunID, prevIdx); line != "" { if line := twinBeeLine(zone.ID, DMZoneComplete, run.RunID, prevIdx); line != "" {
b.WriteString(line) b.WriteString(line)
b.WriteString("\n\n") b.WriteString("\n\n")
@@ -633,16 +604,6 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
b.WriteString("• " + id + "\n") b.WriteString("• " + id + "\n")
} }
} }
// Success-path expedition close-out: flip the wrapping expedition to
// 'complete' (when this clear finishes the whole zone) and surface any
// completion milestones. No-op for standalone runs / mid-zone region
// clears.
if lines := p.finalizeExpeditionOnZoneClear(ctx.Sender, run.RunID); len(lines) > 0 {
b.WriteString("\n")
for _, line := range lines {
b.WriteString(line)
}
}
return advanceResult{ return advanceResult{
preStream: preStream, preStream: preStream,
intro: intro, intro: intro,
@@ -773,7 +734,7 @@ func (p *AdventurePlugin) formatNextRoomMessage(run *DungeonRun, zone ZoneDefini
case RoomElite: case RoomElite:
b.WriteString("`!fight` when ready.") b.WriteString("`!fight` when ready.")
default: default:
b.WriteString(continueHint(id.UserID(run.UserID))) b.WriteString("`!zone advance` to continue.")
} }
return b.String() return b.String()
} }
@@ -817,30 +778,6 @@ func (p *AdventurePlugin) streamFlow(userID id.UserID, phaseMessages []string, f
return nil return nil
} }
// streamFlowThen behaves like streamFlow but runs after() once the final
// message has actually been delivered. Emergence follow-ups (e.g. the pet
// arrival DM) must land strictly *after* the paced run narration — rolling
// them before streamFlow returns races the streamer's goroutine and surfaces
// "there's an animal in your house" ahead of the "Run complete" beat the
// player is still waiting on. after may be nil.
func (p *AdventurePlugin) streamFlowThen(userID id.UserID, phaseMessages []string, finalMessage string, after func()) error {
if len(phaseMessages) == 0 {
err := p.SendDM(userID, finalMessage)
if after != nil {
after()
}
return err
}
done := p.sendZoneCombatMessages(userID, phaseMessages, finalMessage)
if after != nil {
go func() {
<-done
after()
}()
}
return nil
}
// resolveRoom dispatches to the per-room-type resolver. Returns staged // resolveRoom dispatches to the per-room-type resolver. Returns staged
// messages (intro, phases, outcome) so combat rooms can be paced with // messages (intro, phases, outcome) so combat rooms can be paced with
// inter-phase delays — see resolveCombatRoom for the contract. For // inter-phase delays — see resolveCombatRoom for the contract. For

View File

@@ -169,7 +169,7 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
return p.SendDM(ctx.Sender, "Couldn't decode pending fork: "+derr.Error()) return p.SendDM(ctx.Sender, "Couldn't decode pending fork: "+derr.Error())
} }
if pf == nil { if pf == nil {
return p.SendDM(ctx.Sender, "No fork pending. Use "+continueHint(ctx.Sender)) return p.SendDM(ctx.Sender, "No fork pending. Use `!zone advance` to continue.")
} }
rest = strings.TrimSpace(rest) rest = strings.TrimSpace(rest)
if rest == "" { if rest == "" {
@@ -187,7 +187,6 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
if aerr := advanceZoneRunNode(run.RunID, chosen.To); aerr != nil { if aerr := advanceZoneRunNode(run.RunID, chosen.To); aerr != nil {
return p.SendDM(ctx.Sender, "Couldn't advance: "+aerr.Error()) return p.SendDM(ctx.Sender, "Couldn't advance: "+aerr.Error())
} }
markActedToday(ctx.Sender)
g, _ := loadZoneGraph(run.ZoneID) g, _ := loadZoneGraph(run.ZoneID)
zone := zoneOrFallback(run.ZoneID) zone := zoneOrFallback(run.ZoneID)
fromNode := g.Nodes[run.CurrentNode] fromNode := g.Nodes[run.CurrentNode]
@@ -208,14 +207,7 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
b.WriteString("\n\n") b.WriteString("\n\n")
} }
} }
b.WriteString(fmt.Sprintf("**Room %d/%d — %s.** ", nextIdx+1, run.TotalRooms, prettyRoomType(nextRoom))) b.WriteString(fmt.Sprintf("**Room %d/%d — %s.** `!zone advance` to continue.",
switch nextRoom { nextIdx+1, run.TotalRooms, prettyRoomType(nextRoom)))
case RoomBoss:
b.WriteString("`!fight` when you're ready for the boss.")
case RoomElite:
b.WriteString("`!fight` when ready.")
default:
b.WriteString(continueHint(ctx.Sender))
}
return p.SendDM(ctx.Sender, b.String()) return p.SendDM(ctx.Sender, b.String())
} }

View File

@@ -34,13 +34,12 @@ const (
// autoRunTickInterval — how often the ticker wakes. The per-expedition // autoRunTickInterval — how often the ticker wakes. The per-expedition
// cooldown is what actually paces; the tick just has to be frequent // cooldown is what actually paces; the tick just has to be frequent
// enough that the cooldown is enforced cleanly. // enough that the cooldown is enforced cleanly.
autoRunTickInterval = 15 * time.Minute autoRunTickInterval = 5 * time.Minute
// autoRunCooldown — minimum gap between background walks for one // autoRunCooldown — minimum gap between background walks for one
// expedition. 2h is the slow cadence: the dungeon still moves on its // expedition. 15 min keeps the dungeon moving while leaving room for
// own while the player is away, but the auto-walk DM stays a // the player to step in and steer if they want.
// once-in-a-while ping instead of a steady drip. autoRunCooldown = 15 * time.Minute
autoRunCooldown = 2 * time.Hour
// autoRunMinExpeditionAge — don't auto-walk a brand-new expedition; // autoRunMinExpeditionAge — don't auto-walk a brand-new expedition;
// let the player walk the first beat manually so the opening reads // let the player walk the first beat manually so the opening reads
@@ -94,14 +93,6 @@ func (p *AdventurePlugin) fireExpeditionAutoRuns(now time.Time) {
if hasActiveCombatSession(uid) { if hasActiveCombatSession(uid) {
continue continue
} }
// Honor the rest lockout — short/long rest set RestingUntil and
// foreground !expedition run checks it; background must too, or
// the auto-walk ticker fires through a long rest.
if c, cerr := LoadDnDCharacter(uid); cerr == nil && c != nil {
if restingLockoutRemaining(c) > 0 {
continue
}
}
if err := p.tryAutoRun(e, now); err != nil { if err := p.tryAutoRun(e, now); err != nil {
slog.Warn("expedition: autorun step", "expedition", e.ID, "err", err) slog.Warn("expedition: autorun step", "expedition", e.ID, "err", err)
} }
@@ -163,19 +154,14 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
// "no expedition" / "no run" — race with abandon/extract. Silent. // "no expedition" / "no run" — race with abandon/extract. Silent.
return nil return nil
} }
// Emergence seam: a run-complete reached by the background ticker is if !shouldDMAutoRun(r) {
// still a live emergence — roll pet arrival. See maybeRollPetArrivalOnEmerge. return nil
// Deferred until after the run-summary DM below so the "animal in your }
// house" prompt lands after the summary, not ahead of it.
if shouldDMAutoRun(r) {
body := renderAutoRunDM(r) body := renderAutoRunDM(r)
if err := p.SendDM(uid, body); err != nil { if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: autorun DM", "user", uid, "err", err) slog.Warn("expedition: autorun DM", "user", uid, "err", err)
} }
}
if r.reason == stopComplete {
p.maybeRollPetArrivalOnEmerge(uid)
}
return nil return nil
} }

View File

@@ -363,29 +363,6 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*S
res.Outcome = "tpk" res.Outcome = "tpk"
i = walkCap // exit i = walkCap // exit
case stopComplete: case stopComplete:
// A stopComplete that reaches the sim is normally a full zone
// clear (the walk auto-advances mid-zone region boundaries
// internally). But a mid-zone region clear can still surface
// here — e.g. the walk's auto-advance hit a transit error and
// returned stopComplete. Mirror runAutopilotWalk: if the
// expedition is still active in a multi-region zone with a
// next region, cross into it and keep simulating instead of
// scoring a premature "cleared".
if fresh, ferr := getActiveExpedition(uid); ferr == nil && fresh != nil &&
IsMultiRegionZone(fresh.ZoneID) {
if cur, ok := CurrentRegion(fresh); ok {
if next, ok := nextRegion(fresh.ZoneID, cur.ID); ok {
if _, terr := s.P.advanceToNextRegion(uid, fresh, cur, next); terr != nil {
res.Outcome = "halted"
res.StopCode = "region_transit:" + terr.Error()
i = walkCap
break
}
exp = fresh
break // continue the outer walk loop in the next region
}
}
}
res.Outcome = "cleared" res.Outcome = "cleared"
i = walkCap i = walkCap
case stopBoss, stopElite: case stopBoss, stopElite:

View File

@@ -122,10 +122,6 @@ const fxHelpText = "**Forex Commands**\n\n" +
// ── Command Handlers ──────────────────────────────────────────────────────── // ── Command Handlers ────────────────────────────────────────────────────────
func (p *ForexPlugin) cmdRate(ctx MessageContext, args []string) error { func (p *ForexPlugin) cmdRate(ctx MessageContext, args []string) error {
if msg := fxValidateAnalysisArgs(args); msg != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
// Check for cross-pair syntax like EUR/USD or USD/JPY // Check for cross-pair syntax like EUR/USD or USD/JPY
if pair := fxParsePair(args, false); pair != nil { if pair := fxParsePair(args, false); pair != nil {
return p.cmdPairRateOrReport(ctx, pair, false) return p.cmdPairRateOrReport(ctx, pair, false)
@@ -281,9 +277,6 @@ func (p *ForexPlugin) fxPerUSD(cur string, fiatRates map[string]float64) (float6
} }
func (p *ForexPlugin) cmdReport(ctx MessageContext, args []string) error { func (p *ForexPlugin) cmdReport(ctx MessageContext, args []string) error {
if msg := fxValidateAnalysisArgs(args); msg != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
if pair := fxParsePair(args, false); pair != nil { if pair := fxParsePair(args, false); pair != nil {
return p.cmdPairRateOrReport(ctx, pair, true) return p.cmdPairRateOrReport(ctx, pair, true)
} }
@@ -411,34 +404,6 @@ func (p *ForexPlugin) checkAlerts(rates map[string]float64) {
// ── Helpers ───────────────────────────────────────────────────────────────── // ── Helpers ─────────────────────────────────────────────────────────────────
// fxValidateAnalysisArgs vets the currency tokens passed to the fiat-only
// rate/report path. It returns a player-facing error message if any token is
// unusable here, or "" when the args are fine (including empty args, which fall
// back to the default tracked set). Crypto tokens get a dedicated nudge since
// crypto only works on the conversion path; anything else is just invalid.
func fxValidateAnalysisArgs(args []string) string {
for _, a := range args {
// Split pair syntax (BTC/USD) into its sides so each is checked.
raw := strings.ToUpper(a)
toks := []string{raw}
for _, sep := range []string{"/", "|", "-"} {
if strings.Contains(raw, sep) {
toks = strings.SplitN(raw, sep, 2)
break
}
}
for _, t := range toks {
if fxIsCrypto(t) {
return "Silly rabbit. Crypto is for kids! (In other words.. that's an invalid command — crypto only works for conversions like `!fx 100 USD to BTC`.)"
}
if !fxIsSupported(t) {
return fmt.Sprintf("Don't know the currency `%s`. Try `!fx help`.", t)
}
}
}
return ""
}
func fxParseCurrencies(args []string) []string { func fxParseCurrencies(args []string) []string {
var out []string var out []string
for _, a := range args { for _, a := range args {

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

@@ -909,19 +909,6 @@ func resetAllPlayerMetaDailyActions() error {
return err return err
} }
// resetAllPetMorningDefense clears the one-day pet morning-defense buff for
// every player. The buff is a fresh morning grant (25% roll in the briefing /
// overworld DM), so it must not survive past the midnight rollover. The flag
// lives inside pet_flags_json (see petFlagsJSON), so patch that key in place;
// only rows where it's currently set are touched.
func resetAllPetMorningDefense() error {
_, err := db.Get().Exec(`
UPDATE player_meta
SET pet_flags_json = json_set(pet_flags_json, '$.morning_defense', json('false'))
WHERE json_extract(pet_flags_json, '$.morning_defense') = 1`)
return err
}
// DeathState mirrors player_meta's death-state columns. Phase L5e ports // DeathState mirrors player_meta's death-state columns. Phase L5e ports
// these fields off AdvCharacter (gogobee_legacy_migration.md §7.3 L5e). // these fields off AdvCharacter (gogobee_legacy_migration.md §7.3 L5e).
// Mutation surface is large (~50 saveAdvCharacter sites touch death // Mutation surface is large (~50 saveAdvCharacter sites touch death

View File

@@ -10,6 +10,7 @@ import (
"time" "time"
"gogobee/internal/db" "gogobee/internal/db"
"gogobee/internal/safehttp"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
@@ -40,9 +41,10 @@ func NewURLsPlugin(client *mautrix.Client) *URLsPlugin {
Base: NewBase(client), Base: NewBase(client),
enabled: enabled, enabled: enabled,
ignoreUsers: ignore, ignoreUsers: ignore,
httpClient: &http.Client{ // Route page scrapes through safehttp so a posted link can't steer the
Timeout: 3 * time.Second, // 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) { func (p *URLsPlugin) previewURL(ctx MessageContext, targetURL string) {
title, desc, err := p.fetchPreview(targetURL) title, desc, image, err := p.fetchPreview(targetURL)
if err != nil { if err != nil {
slog.Debug("urls: fetch preview failed", "url", targetURL, "err", err) slog.Debug("urls: fetch preview failed", "url", targetURL, "err", err)
return 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 == "" { if title == "" && desc == "" {
return return
} }
@@ -120,63 +133,69 @@ func (p *URLsPlugin) previewURL(ctx MessageContext, targetURL string) {
} }
} }
// fetchPreview retrieves og:title and og:description, checking cache first. // fetchPreview retrieves og:title, og:description and og:image, checking cache first.
func (p *URLsPlugin) fetchPreview(rawURL string) (string, string, error) { func (p *URLsPlugin) fetchPreview(rawURL string) (title, desc, image string, err error) {
d := db.Get() d := db.Get()
now := time.Now().UTC().Unix() now := time.Now().UTC().Unix()
cacheTTL := int64(24 * 60 * 60) cacheTTL := int64(24 * 60 * 60)
// Check cache // Check cache
var title, desc string
var cachedAt int64 var cachedAt int64
err := d.QueryRow( err = d.QueryRow(
`SELECT title, description, cached_at FROM url_cache WHERE url = ?`, rawURL, `SELECT title, description, image_url, cached_at FROM url_cache WHERE url = ?`, rawURL,
).Scan(&title, &desc, &cachedAt) ).Scan(&title, &desc, &image, &cachedAt)
if err == nil && now-cachedAt < cacheTTL { if err == nil && now-cachedAt < cacheTTL {
return title, desc, nil return title, desc, image, nil
} }
// Fetch from web // Fetch from web
title, desc, err = p.scrapeOG(rawURL) title, desc, image, err = p.scrapeOG(rawURL)
if err != nil { if err != nil {
return "", "", err return "", "", "", err
} }
// Cache the result // Cache the result
db.Exec("urls: cache write", db.Exec("urls: cache write",
`INSERT INTO url_cache (url, title, description, cached_at) VALUES (?, ?, ?, ?) `INSERT INTO url_cache (url, title, description, image_url, cached_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(url) DO UPDATE SET title = ?, description = ?, cached_at = ?`, ON CONFLICT(url) DO UPDATE SET title = ?, description = ?, image_url = ?, cached_at = ?`,
rawURL, title, desc, now, title, desc, now, 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) req, err := http.NewRequest("GET", rawURL, nil)
if err != 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("User-Agent", "GogoBee Bot/1.0")
req.Header.Set("Accept", "text/html,application/xhtml+xml")
resp, err := p.httpClient.Do(req) resp, err := p.httpClient.Do(req)
if err != nil { if err != nil {
return "", "", fmt.Errorf("fetch: %w", err) return "", "", "", fmt.Errorf("fetch: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { 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 { if err != nil {
return "", "", fmt.Errorf("parse HTML: %w", err) return "", "", "", fmt.Errorf("parse HTML: %w", err)
} }
title := "" title := ""
desc := "" desc := ""
image := ""
doc.Find("meta").Each(func(_ int, s *goquery.Selection) { doc.Find("meta").Each(func(_ int, s *goquery.Selection) {
prop, _ := s.Attr("property") prop, _ := s.Attr("property")
@@ -186,6 +205,10 @@ func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, error) {
title = content title = content
case "og:description": case "og:description":
desc = content 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

@@ -172,28 +172,6 @@ func nodeGlyph(n ZoneNode) string {
return "·" return "·"
} }
// renderVisitedPath renders a one-line numbered breadcrumb of the
// player's path so they can reference rooms by index (e.g. !revisit 2).
// Numbers are 1-indexed to match every other surface ("Room 2/7", etc.).
func renderVisitedPath(g ZoneGraph, run *DungeonRun) string {
if run == nil || len(run.VisitedNodes) == 0 {
return ""
}
parts := make([]string, 0, len(run.VisitedNodes))
for i, nodeID := range run.VisitedNodes {
glyph := "·"
if n, ok := g.Nodes[nodeID]; ok {
glyph = nodeGlyph(n)
}
mark := "✓"
if nodeID == run.CurrentNode {
mark = "▶"
}
parts = append(parts, fmt.Sprintf("[%d]%s%s", i+1, glyph, mark))
}
return strings.Join(parts, " → ")
}
// debugDump (test-only crutch) prints the raw column layout. Currently // debugDump (test-only crutch) prints the raw column layout. Currently
// unused outside potential debugging — kept off the unused-warning list // unused outside potential debugging — kept off the unused-warning list
// by routing fmt through it. // by routing fmt through it.

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