mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 17:02:42 +00:00
Compare commits
11 Commits
d7ced471a1
...
long-exped
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8122973b74 | ||
|
|
cbfca525f5 | ||
|
|
f4a39b46e9 | ||
|
|
6a47be34bc | ||
|
|
667f87f9d0 | ||
|
|
1b8d13e0dd | ||
|
|
d80b437525 | ||
|
|
4934383a9a | ||
|
|
a46b773750 | ||
|
|
b80de43db1 | ||
|
|
81dda51907 |
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 97–100% 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.
|
||||
|
||||
84
internal/plugin/bootstrap_caster_hp.go
Normal file
84
internal/plugin/bootstrap_caster_hp.go
Normal 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)
|
||||
}
|
||||
}
|
||||
144
internal/plugin/bootstrap_josie_caster_aid.go
Normal file
144
internal/plugin/bootstrap_josie_caster_aid.go
Normal 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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
184
internal/plugin/link_thumbnail.go
Normal file
184
internal/plugin/link_thumbnail.go
Normal 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
|
||||
}
|
||||
40
internal/plugin/link_thumbnail_test.go
Normal file
40
internal/plugin/link_thumbnail_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
429
internal/plugin/scenario_proddb_test.go
Normal file
429
internal/plugin/scenario_proddb_test.go
Normal 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, ¬null, &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, ¬null, &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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
146
internal/safehttp/safehttp.go
Normal file
146
internal/safehttp/safehttp.go
Normal 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
|
||||
}
|
||||
6
main.go
6
main.go
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user