70 Commits

Author SHA1 Message Date
dependabot[bot]
1b4e6833b0 Bump modernc.org/sqlite from 1.50.1 to 1.53.0
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.50.1 to 1.53.0.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.50.1...v1.53.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-10 14:24:35 +00:00
prosolis
1f211564d9 Review fixes: party close-out, member soft-lock, supply race, elite loot, season crown
Five bugs found reviewing n1-restoration end to end.

beginCombatTurn settles any phase the engine owes before reading whose turn it
is. That settle can end the fight — and the old code then answered "you're not
in a fight" and returned. The terminal status was already persisted, so nothing
ever paid the party out: no XP, no loot, no death recorded, no run teardown. The
reaper cannot recover it either, because listExpiredCombatSessions filters on
status='active'. Close the fight out there, the way the !fight start path and
the reaper already do.

A party member was permanently soft-locked when their leader extracted and never
resumed. seatedExpeditionFor (the guard) spans 'extracting'; expeditionForMember
(what !expedition leave resolved through) saw only 'active'. So the member was
refused any new adventure by the guard and told "No active expedition" by the
command the guard points them at, with nothing sweeping stale rows and only the
leader able to clear one. Resolve the exit through the same lookup as the gate.

updateSupplies overwrites supplies_json wholesale, and expeditionCmdAccept folded
a member's packs onto a snapshot read before the coin debit, unlocked. Handlers
run one goroutine per event, so two invitees accepting genuinely interleave and
one member's packs vanish. advUserLock cannot help — it is keyed by sender, so
racing members take different mutexes. Add advExpeditionLock and re-read the pool
under it. Closes accept-vs-accept; the six other updateSupplies callers still
race and are written up separately.

runHarvestInterrupt picked an elite enemy and elite narration off a local `elite`
flag, then passed a hardcoded false as isElite to closeOutZoneWin. dropZoneLoot
gates masterwork on isBoss||isElite, so beating an elite interrupt skipped the
masterwork roll and took standard treasure weight — while the same elite fought
via !zone paid out correctly.

arenaSeasonRollover marked its job complete even when recordArenaSeasonTitle
failed, and JobCompleted short-circuits every later run for that quarter, so a
transient SQLite BUSY lost the crown forever. Defer completion on failure; the
insert is ON CONFLICT DO NOTHING against PRIMARY KEY (season, kind) and a past
season's data is frozen, so the retry is safe.

Also: drop dead partySurvivors, collapse the zoneCombatRoster alias into
fightRoster, route partyCasualtyLine through joinNames, fold four copies of the
expedition column projection into expeditionSelectCols, stop replyDM sending a
blank DM, and correct two doc comments describing a path that no longer exists.

Deliberately not fixed, with reasons, in gogobee_code_review_followups.md — most
notably that both turn-based close-outs skip grantCombatAchievements and
persistDnDPostCombatSubclass, which the auto-resolve paths run.
2026-07-10 07:20:14 -07:00
prosolis
3369d7d8fe gofmt: bring internal/ and cmd/ back to gofmt -l clean
Mechanical `gofmt -w ./internal ./cmd`. Mostly struct-field realignment that
had drifted, plus a few trailing-newline fixes. No behaviour change — gofmt is
semantics-preserving, and build/vet/test are green either side.

Split out from the code-review fixes that follow so those stay reviewable
instead of hiding inside a wall of realignment.
2026-07-10 07:18:07 -07:00
prosolis
08d3053368 N3/P6e: a party that fights every room
Only elite and boss doorways seated a roster. Everything else -- exploration
rooms, patrol encounters, harvest interrupts -- resolved through SimulateCombat
against ctx.Sender, and P6d made the walk commands leader-only. So on a 38-room
T5 expedition a party of three fought together twice and the leader soloed the
other ~35, then died alone while two untouched members stood at full HP.

The plan said the N-body core was already there and only the callers passed one
player. It wasn't: SimulateCombat built a one-seat roster internally. But the
resolution primitives already read st.c -- the cursor's Combatant -- because the
turn engine has called them that way since P3. Only the round loop needed
widening.

combat_engine_party.go carries it: simulateParty, simulatePartyRound,
roundInitiative, enemyTargetSeat. Every roster short-circuit collapses for one
seat, copying P3's solo exemptions, so the RNG draw order is unchanged and
SimulateCombat is now simulateParty([]Combatant{p}, ...).Seats[0].
TestCombatCharacterization is byte-identical; TestSimulateCombat_IsTheOneSeatPartyCase
pins the delegation event-for-event across 40 seeds.

zone_combat_party.go carries the callers' half: runZoneCombatRoster fans out the
character-scoped close-out (HP, XP, achievements, subclass, heal items burned,
Misty's repair) per seat, while loot, threat, kill records and death stay with
whoever knows the room. runZoneCombat remains the explicit solo entry point --
the arena calls it, and an arena bout must never drag in a party.

Death is read per seat off HP, never off the fight's terminal status: a timed-out
party can still have lost somebody, and a solo player at 0 HP has already ended
the fight, so PlayerEndHP <= 0 is exactly the old !TimedOut rule.

Preserved deliberately: a solo player can win at 0 HP (a retaliate aura kills the
swinger on the killing blow, and resolvePlayerAttack returns before enemyDown is
consumed) and is not marked dead. A party marks its downed seats dead on a win,
which is what finishPartyWin always did.

Solo T5 re-sweep is unregressed (fighter 47-73%, cleric 20-33%). Party of 3 now
clears 100% of every T5 cell, which is P8's problem: the enemy takes one turn per
round and swings at one seat, so a party of N deals xN damage and each member
takes ~1/N^2 of the solo incoming. An HP scalar cannot close that -- it restores
the fight's duration, not the enemy's action economy.
2026-07-10 06:32:29 -07:00
prosolis
32e3148755 N3/P7: a party that only fights together twice
Adds -party N / -party-classes to expedition-sim. Followers are seated
through the real !expedition invite / !expedition accept pair, so the
harness measures the tier gate, the busy guard and the supply pooling
rather than a roster hand-built to succeed. A follower who is refused
halts the run: a party reading taken from a walk that was secretly solo
is worse than no reading.

It immediately found what it was built to find. Only elite and boss
doorways seat the roster; exploration rooms, patrols and harvest
interrupts all resolve through SimulateCombat against ctx.Sender, and
P6d made the walk commands leader-only. So on a 38-room T5 expedition
the party fights 2-3 rooms together and the leader solos the rest -
then dies alone, tearing down the run rows only they own.

Measured at L15/16 over dragons_lair + abyss_portal, party of 3, n=15
per cell: zero TPKs and zero member deaths across 240 seats. Every
failure is the leader falling while two untouched members stand at full
HP. Fighter clears 100% (solo: 47-67%), cleric 33-53% (solo: 13-47%).
The band is unreadable until inline combat seats the party, so the C1
contingency - +35% monster HP per member - stays on the shelf; it would
punish the trash the leader already fights alone.

Hence the outcome vocabulary grows a third word. "tpk" used to mean any
run-ending event, which is how a leader dying beside a healthy party
stayed invisible through P5 and P6. Now: tpk (roster dead),
leader_down, fled (run over, leader alive) - read off the death flag,
not off HP, which the close-out leaves anywhere. A one-seat roster
makes leader-dead and all-dead the same predicate, so solo keeps its
labels for a real death.

Two bugs found in review before this landed:

  - An unknown class was not an error anywhere: -class fightr built a
    1-HP character and reported an ordinary outcome for it. Guarded in
    BuildCharacter, not the flag parser, since the leader had the bug
    long before parties did.
  - The roster short-rest healed the dead - handleDnDShortRest does not
    gate on the death flag, so a member killed in a won boss fight got
    rested back above 0 and stopped counting as a casualty.

Golden byte-identical; go test ./... green.
2026-07-10 00:37:59 -07:00
prosolis
a063e0ccd0 N3/P6d: a party where every member can see the dungeon
Every ownership lookup in the adventure module keys on a user id, and a
party member owns neither the expedition row nor the zone run. So each
player-facing read quietly told them they were not playing.

Rewire them. Reads a member should see resolve through activeExpeditionFor
/ activeZoneRunFor. Leader-only actions answer with copy that names the
leader instead of denying the expedition. Three busy-guards had to start
refusing a member outright: !zone enter, !expedition start and !sell all
keyed on the sender's own row, so a seated member could open a private
dungeon, outfit a rival expedition, or run a shop from the boss room.

Four things the rewire itself exposed:

!resources looks like a read but seed-persists harvest nodes, and
saveHarvestNodes rewrites the entire region_state blob — kills, event
gates, temporal stack — last-write-wins. Reaching it as a member would
revert the leader's walk from a stale snapshot. Persist only for the owner;
seedRoomNodes is pure, so a member re-derives the same nodes.

!zone taunt moves the party's shared mood, which is intended and safe:
applyMoodEvent lands an atomic delta. Its neighbour applyMoodDecayIfStale
writes an absolute gm_mood from the caller's snapshot, and every command
takes the *sender's* lock — a member running it against the leader's run
holds the wrong mutex. The owner check now lives on that function.

A seat outlives status='active'. releaseParty deliberately skips the
seven-day 'extracting' limbo, so the roster persists while
activeExpeditionFor goes blind — long enough for a member to open a run
that wins every lookup once the leader !resumes. seatedExpeditionFor spans
both statuses; it is what the busy-guards ask.

!expedition run was still member-blind. It is the same walk as !zone
advance, reached by its other name.

isPartyMember replaces `run != nil && !isLeader`: activeZoneRunFor reports
isLeader=false for a player with no run anywhere, so the bare test sends a
solo player to go ask their leader.

Golden byte-identical; solo T1 expedition clears end-to-end.
2026-07-09 23:56:16 -07:00
prosolis
b333d05443 N3/P6c: a fight the whole party sits down for
`!fight` seats the expedition's roster instead of the one player who typed
it. Seat 0 is the leader, always: the session row is theirs, the lock is
theirs, and `!flee`, the fork, and `!extract` stay their call.

A monster that wins initiative now swings before anyone speaks. The session
layer used to park every new fight on a player_turn, which is true of the
hardcoded solo order and a lie about a party's -- the enemy would forfeit
round 1 and nobody would notice. `startPartyCombatSession` rolls the order
and sets the phase from it; `handleFightCmd` settles the round before it
announces, so the opening block narrates the hit rather than quietly
showing its damage.

Members were invisible to two commands that had no business ignoring them:
`!cast` queued a spell for "next combat" while its caster was standing in
one, and `!rest` healed a seated member to full mid boss fight. Both now
resolve through the party.

Nobody leaves without an answer. A downed member's `!fight` opens the
party's fight and tells them why they are not in it. The leader's `!extract`
reaches everyone it drags out of the dungeon, and everyone rolls for what
moved into their house while they were gone.

Supplies burn at 50% x N x 4/5 -- a party eats more than one and less than
N. The ratio is exact: 0.8 as a float truncates a party of three to 119%,
a permanent tax nobody would have found.

Solo is untouched, byte for byte. One seat means one build, one INSERT, no
participant rows, the same RNG draws in the same order -- the combat
characterization golden does not move, and neither does the balance corpus.
2026-07-09 23:23:17 -07:00
prosolis
1928f75c19 N3/P6b: a party you can actually ask someone to join
!expedition invite / accept / decline / party / leave. The invitee buys
their own loadout and it pools -- a party is a shared burden, not a free
ride.

The plan said invites close "before the first walk". That is not a window,
it is a race: autoRunMinExpeditionAge leaves a fresh expedition alone for
thirty minutes and then the autopilot starts walking it, and the leader's
own !expedition run can beat it there. Thirty minutes is not enough to ask
a friend who is asleep.

So two changes to what the plan specified:

- The window is all of Day 1, not the first step. Supplies burn at the
  night rollover, so a companion who arrives three rooms in pays and
  receives exactly what one who arrived at the gate does.
- An unanswered invite pins the autopilot: loadExpeditionsForAutoRun skips
  any expedition somebody has been asked to join. The leader must not be
  dragged into a boss room while their friend reads the DM. Bounded by
  expeditionInviteTTL (2h) in the query itself, so a forgotten invite
  costs an afternoon, not the expedition.

New table expedition_invite. Absent == nobody was asked, which is true of
every expedition predating N3 -- nothing to backfill, same reading that
let expedition_party and roster_size ship without one.

Details worth keeping:

- Outstanding invites count against expeditionPartyMax. Otherwise a leader
  asks four people and three accept.
- Pooling raises Current *and* Max. supplyDepletion reads the ratio, so
  folding in only Current would read as the party suddenly starving.
- A member's supplies stay in the pool when they !leave. They were spent
  on the expedition, not lent to it; clawing them back would let someone
  starve the party on their way out.
- assertNotAdventuring guards expeditions and rosters but not bare zone
  runs, so accept checks getActiveZoneRun itself -- startExpedition does.
- A party is not a taxi: zoneOpenToLevel gates the invitee on the same
  tier rule !expedition start applies to the leader.
- releaseParty now clears invites too, or someone could accept onto a
  corpse.
- expeditionCmdStatus and the bare `!expedition` switched to
  activeExpeditionFor, and a member typing `!expedition go 2` is told the
  leader picks the path instead of falling through to `start` and being
  told "2" is an unknown zone.

Combat still seats one player -- handleFightCmd is P6c. go test ./...
green, golden byte-identical.
2026-07-09 22:30:11 -07:00
prosolis
0f144fa335 N3/P6a: let a member find the run they're standing in
Every ownership lookup in the adventure module keys on a user id, and a
party member owns no row: not the expedition, not the zone run. P4 gave
them activeExpeditionFor; this gives them activeZoneRunFor, and gives the
DM seams the audience they never had.

- activeZoneRunFor(user) -> (run, isLeader, err). An owner's lookup is
  exactly getActiveZoneRun, side effects and all -- in particular the
  §4.3 idle reap, which force-extracts the wrapping expedition. A member
  must never re-enter it, or glancing at the map would end the leader's
  run. Pinned.

- expeditionAudience / fanOutExpeditionDM. Briefing, recap and digest all
  DM'd id.UserID(e.UserID) alone. They now loop the roster, which
  partyMemberIDs collapses to exactly the owner when there is none -- so
  a solo expedition sends the same bytes to the same user it always has.
  The briefing's body is expedition-scoped but its pet prefix is not:
  each member has their own pet and their own sheet, so the roll rides a
  per-reader decorator (the shape P5 settled on for combat narration).
  The digest's A6 event anchor rolls per member for the same reason.

- releaseParty on every terminal transition. A seated member is barred
  from adventuring elsewhere, so a roster outliving its expedition
  strands the party. Deliberately NOT on 'extracting': that is a 7-day
  resumable limbo and !resume must bring everyone back. The roster clears
  when the window lapses to 'failed', which routes through
  completeExpedition like the rest.

Rosters are still empty in production -- nothing seats a member yet -- so
every loop here has exactly one element and the whole change is a no-op
until P6b. Golden byte-identical, go test ./... green.
2026-07-09 22:18:40 -07:00
prosolis
e8d06195ac N3/P5: a fight that knows whose turn it is
A solo fight is a conversation: the player types, the engine answers, and
nothing happens in between. A party fight is a queue, and three things follow.

Turn ownership. Only the seat on the clock may act, so beginCombatTurn resolves
the sender's seat and refuses the rest before anyone spends a slot or burns an
item. A fight lock, because three members typing !attack at once took three
different user locks and the check would have passed for all three: it takes the
fight's lock (keyed on seat 0) then the member's own, always in that order, and
a solo fight -- whose owner is the sender, and sync.Mutex is not reentrant --
takes exactly the one lock it always took. And a turn deadline, because one
member who wanders off must not freeze the other two for the hour it takes the
session reaper to wake up.

The deadline is three minutes, not the plan's sixty seconds. The sweep rides the
existing one-minute ticker, so any deadline really fires in [d, d+1m); and
expeditions here run for days, so the asymmetry favours patience over robbing
someone of their boss turn while they read the room on their phone. A lapse
latches that seat onto the auto-picker for the rest of the fight, so an absent
member costs the party one wait rather than one per round. Typing anything hands
the wheel back. Solo is never swept.

Three seat-0 leaks fixed on the way past, all of which would have surfaced as
the leader quietly doing everyone's business:

  - mid-fight buffs folded into the session's embedded ActorStatuses, so a
    member casting Shield on themselves would have armoured the leader;
  - pickAutoCombatAction read sess.PlayerHP and Statuses.ConcentrationDmg, so
    playing an away member's turn would have healed the wrong person and
    re-armed the wrong aura;
  - runCombatRound rested on any player_turn, and a downed seat still holds one
    -- the round would have come to rest on a corpse and waited for a dead
    member to type !attack. settleCombatSession drains it. beginCombatTurn
    settles before reading the clock, which also fixes a latent solo bug: a
    fight interrupted mid enemy_turn resumed parked there and silently ate the
    player's next !attack.

The narration turned out to be written in the second person -- "You score 9
damage", "A hit gets through your guard" -- so swapping a name per seat would
have told three people they each landed the same blow. A round is rendered once
per reader instead: your own events go through the untouched flavor pool, your
allies' through a terse third-person summary. CombatEvent carries the seat to
make that possible, stamped once per phase step rather than at the twenty-odd
append sites in the primitives, which emit against the cursor and know nothing
of seats.

Closing out fans along the seam the data model already cut. Threat, the
zone-kill record, the boss-defeat drop and the run teardown all resolve through
getActiveExpedition or getActiveZoneRun, and a member owns neither row -- so
they fire once, for the owner. Fanning them out would have tripled the threat a
single kill costs. HP, XP, loot and death are the character's, and every seat
gets their own. A member can be dead in a fight the party won, so death is read
per seat off HP, not off the session's status.

The reaper stays attack-only. Finishing an abandoned fight should not quietly
burn the player's spell slots and potions; the deadline latch does use the
picker, because that member is mid-fight with a party waiting on them.

startPartyCombatSession has no production caller yet -- handleFightCmd still
opens a solo session. P6 seats the party.

TestCombatCharacterization is byte-identical: solo balance did not move.
2026-07-09 22:07:20 -07:00
prosolis
d7d0230223 N3/P4: give every seat a row of its own
The turn engine seats a party since P3, but only seat 0 survived a
suspend: seats 1+ reopened from their Mods on every step, rearming their
once-per-fight one-shots -- a party Halfling rerolled a nat 1 every round.

Split CombatStatuses into the fight-scoped half (the enemy's stance, the
round cursor) and the per-character half, ActorStatuses. The embed is
anonymous and untagged, so statuses_json stays the same flat object it
always was and every in-flight prod row decodes unchanged.

Seat 0 keeps living on combat_session. Seats 1+ get combat_participant
rows. That asymmetry is the point: a solo fight -- which is every fight
that has ever run, and the whole balance corpus -- writes exactly the
bytes it wrote before, and no participant rows at all. roster_size
guards the read, so the solo loader never issues the second query.

Parties commit their seats in the same transaction as the session row;
solo keeps its single unwrapped UPDATE.

expedition_party is the co-op roster. No party_id on dnd_expedition:
expedition_id already identifies the party, and a second key would be a
second answer to "who is in this party". Absent means solo, in both new
tables, so neither needs a bootstrap backfill.

The combat characterization golden is byte-identical.

Also seeds and re-powers the two statistical subclass tests. They drew
from the package-global RNG, so their verdict depended on how much
randomness every test declared before them happened to consume -- which
is why they flaked on a clean tree. Sweeping 40 seeds: Precision Attack
had a mean margin of +127 wins against a +50 threshold but a worst case
of -42, and Assassinate averaged +12.8 with two seeds outright negative.
Both effects are real; the trial counts were too low to see them. Seeded,
and raised to 24000/6000 trials, where all 40 seeds clear.
2026-07-09 21:23:35 -07:00
prosolis
ec614e84f1 N3/P3: initiative, and a turn engine that seats a party
The turn engine ran a fixed player -> enemy -> round_end phase machine over
one player and one monster. It now runs a round as a sequence of seats.

turnOrder derives that sequence per round. A solo roster short-circuits to
the historical [player, enemy] and rolls nothing -- the duel has never had
initiative, and handing the monster a coin flip on who swings first would be
a live balance change. A party rolls it with the auto-resolve engine's own
formula (speed + d10 + InitiativeBias).

Every seat in a round shares a (round, phase) pair, so the acting seat is
mixed into the RNG *seed* rather than the stream. Seat 0 and the enemy
sentinel mix to nothing, which is what keeps a solo fight drawing exactly
the pre-roster stream across a suspend/resume.

The round cursor persists as Statuses.TurnIdx, omitempty so no solo row
carries it. A fight that was in flight when the field landed decodes it as
0; turnIdxForPhase reconciles that against Phase, which is the older and
load-bearing field. Without it, a suspended enemy_turn would resume, step,
and land back on enemy_turn forever.

The enemy now picks a target uniformly among the standing roster (solo draws
nothing), a downed seat forfeits its turn silently, and the fight is lost
only when anyAlive() goes false -- not when the acting seat drops. That last
one fixes a latent solo bug on the way past: resolvePlayerSwings returns
false when a retaliate aura kills the swinger between extra attacks, and the
old code walked that corpse into the enemy's turn.

commit() reads seat 0 explicitly instead of the cursor, which the enemy turn
parks on its target and round_end walks across the roster.

TestCombatCharacterization is byte-identical: solo balance did not move.
2026-07-09 20:43:37 -07:00
prosolis
41f98b721a N3/P2: give the combat engine an N-player roster
Splits combatState into a fight-scoped half and a per-character half.
Everything that belongs to one PC -- HP, ward/spore/reflect charges,
heal charges, poison ticks, the death save, Lucky/Rage, the
first-attack one-shots, the arcane ward, concentration, and the
debuffs an enemy stacks onto a specific character -- moves to a new
`actor`. What belongs to the fight stays: the enemy pool, the enemy's
stance (evade/block/advantage/retaliate/regen/survive), the round
counter, the event log, and the RNG stream.

combatState embeds *actor, so the promoted fields keep their names and
all ~230 existing reads (st.playerHP, st.wardCharges, ...) compile
untouched. The embedded pointer is a cursor: seat(i) points it at a
roster member. Solo seats one actor and never moves the cursor, so the
draw order off the single RNG stream is unchanged.

That is the whole point. TestCombatCharacterization -- 57 scenarios x
5 seeds, 7468 pinned golden lines -- is byte-identical before and
after. Solo combat provably did not move, so the d8prereq balance
corpus survives the parties work and only party bands need new
baselines in P7.

Hold-person is fight-scoped (holding the enemy holds it for everyone)
while stat_drain/debuff/max_hp_drain are per-character, which is why
they landed on opposite sides of the split.

No multi-actor *semantics* here: nothing yet decides who the enemy
swings at or how initiative interleaves N players. That is P3. This
commit only lands the data model, and the roster tests cover what the
solo golden structurally cannot see -- cursor isolation, shared-state
visibility across seats, and the pointer embed (a value embed would
silently copy on seat() and fail the round-trip assertion).
2026-07-09 20:29:05 -07:00
prosolis
fc0dff710e N3/P1: widen the combat characterization golden
The roster refactor (N3 parties) has to prove it doesn't move solo
combat, or the d8prereq balance corpus dies with it. The existing
golden pinned 22 scenarios; it missed everything the refactor is most
likely to disturb:

  - ExtraAttacks (the lever J1 moved) and the rest of the 2026-05-16
    class-identity mods: sneak attack, hunter's mark, divine strike,
    thorn lash, crit threshold, first-attack bonus, assassinate,
    berserker rage, spirit weapon, arcane ward, heal charges
  - the race passives (lucky reroll, orc rage, poison/fire resist)
  - all 13 Phase 13 bestiary slice 3+4 effects, none of which had a
    pin: evade, block, advantage, retaliate, regenerate, survive_at_1,
    stat_drain, debuff, max_hp_drain, spell_resist, reveal_action,
    fear_immune, ally_buff
  - phase-scoped ability gating and the environment hazard path

22 -> 57 scenarios, 2047 -> 7468 golden lines. The pre-existing golden
is a byte-exact prefix of the new one, so no existing pin moved.

Enemies are sized per scenario (tanky/hardHit) so per-hit and
per-swing riders accumulate instead of dying in the opening round, and
abilities proc at 1.0 so the pin captures the effect, not the roll.
Verified every new scenario emits its distinctive event.
2026-07-09 20:21:17 -07:00
prosolis
ae5762fc91 R2: !revisit <N> -- walk back one edge, for +1 threat
Retires forward-only navigation. `!revisit <N>` (alias `!zone revisit`)
walks one graph edge back to a room in VisitedNodes, chosen by the number
the player reads off !map's Path strip. Edges are directed, but a corridor
you walked down is a corridor you can walk back up, so adjacency checks
both directions. !map now prints the legal targets.

Cost is +1 threat, not the discount the plan specced. There was nothing to
discount: movement charges neither threat nor supplies, and a cleared room
fires no combat, so backtracking was free. +1 mirrors harvest noise -- less
than a fight (+5) or an elite (+8). Standalone runs have no threat clock and
pay nothing. A siege guard refuses the move at threat >= 99: siege is
one-way sticky, and a player must not be able to strand their own expedition
on a move they made to go pick up a herb.

The plan claimed terminal CombatSession rows already prevent re-triggering a
cleared room. They gate only Elite and Boss, at the doorway. An Exploration
room re-entered resolveCombatRoom unconditionally and a Trap room re-armed --
so revisit would have been an infinite farm: step back, advance, repeat.
resolveRoom now early-returns on an already-cleared room. No-op forward-only,
since advance clears a room only after resolving it.

Autopilot is ticker-driven with no on/off switch, so it would have walked the
player straight back out of the room they revisited. grantAutorunGrace stamps
last_autorun_at to buy one cooldown window. That is a stopgap; R5 still owes
the real policy.

Fork re-pick stays deferred to R3: revisit refuses while a fork is pending,
because advance and `!zone go` resolve node_choices without checking which
node the player is standing on.
2026-07-09 19:47:16 -07:00
prosolis
31e3d69d8d R1: split "where am I" from "how far have I walked"
Backtracking (revisit R2) breaks the assumption every zone-run caller
quietly relied on: that CurrentRoom == len(VisitedNodes)-1. Position and
progress have been the same number only because navigation was
forward-only.

Split them. CurrentRoom is now the first-entry index of CurrentNode in
VisitedNodes -- the room number the player was shown on the way in, and
the salt that enemy/trap/harvest/encounter keys hash. A revisited room
therefore resolves to the same room. RoomsTraversed is a new monotonic
step counter, persisted, backfilled from visited_nodes for in-flight rows.

The audit found only two progress-shaped reads. narrationCadence moves to
RoomsTraversed so a backtracking player draws fresh flavor rather than
replaying the entry-room lines. The other was a latent bug: zoneCmdGo
labelled the room it moved into as CurrentRoom+1, which is only correct at
the frontier; advanceZoneRunNode now returns the true path index.

VisitedNodes becomes an ordered set and RoomsCleared becomes idempotent --
both no-ops while navigation is forward-only, both load-bearing after R2.

No player-visible behavior change. Nothing to route off RoomsTraversed for
threat/SU: verified at HEAD that movement charges neither. Threat comes
from combat, supplies burn per day. The revisit plan's cost model claimed
otherwise and has been corrected -- R2's "discount" is really a net-new
cost decision.
2026-07-09 19:36:24 -07:00
prosolis
adcc62112b N2/B1+B4+C4: tempering, the expedition achievement wing, arena seasons
B1 — tempering. `!adventure temper` pushes an owned magic item one rung up
the rarity ladder at the blacksmith, capped at Legendary. Rarity lives on
the registry definition, so an upgraded instance records its progress as a
`temper` step count on its own row (adventure_inventory, magic_item_equipped)
and effective rarity is derived on read. The stored base rarity is never
written back, so an item cannot double-bump across a load/save round-trip.
Effects flow through the existing rarity scalars — no new combat math, and
the Legendary cap means this accelerates reaching the current ceiling rather
than raising it.

Costs mirror the crafting ladder (10k/25k/60k/150k, Foraging 15/20/25/30).
Only the Legendary rung consumes a T5 material; gating the lower rungs on
one would make tempering unreachable until a player already clears T5.

Two plan anchors were wrong: thyraks_core and portal_fragment are not
loot-note strings needing promotion — they are UniqueAlways slate entries
that already materialize as inventory rows on every T5 clear.

B4 — expedition achievement wing: first-clear and all-zones-cleared per tier
(10), a quiet-clear for keeping peak threat under 50, and temper_legendary.
The quiet-clear requires max_threat_seen to be *present*, not merely low:
recordMaxThreat samples only on day rollover and never stores a zero, so an
absent key means no threat history, not low threat. The plan's no-death-T4
achievement is not shipped — no per-expedition death counter exists — and
pet-saved-my-life already ships as combat_pet_save.

C4 — arena seasons. Standings are derived from arena_history.created_at per
calendar quarter rather than wiping arena_stats: same visible reset, but
lifetime totals survive for `!arena stats` and no destructive job can fire
twice. Crowns archive to arena_season_titles rather than player_meta.title,
which already carries the Survivalist milestone. Rollover rides the existing
midnight ticker and self-dedups on the season key, so a bot that was down on
the boundary catches up on its next wake.
2026-07-09 19:22:10 -07:00
prosolis
0c603cfece appservice: recover undecryptable events with an m.room_key_request
An inbound event whose megolm session hadn't arrived yet was logged and
dropped. The keys are usually merely in flight — a to-device m.room_key racing
the room event — so the message was lost to a few hundred milliseconds.

On NoSessionFound, wait 3s for the session to land; if it doesn't, send an
m.room_key_request and wait 22s more, then decrypt and re-dispatch. A 55s
budget bounds the whole recovery so a permanently-unreadable event can't pin a
goroutine. Runs detached from the transaction context, which is cancelled the
moment we ACK — long before keys could arrive.

This duplicates cryptohelper.HandleEncrypted's own 3s/22s ladder on purpose:
that path is gated on a /sync token in the context, which appservice
transactions never carry, so wiring ch.ASEventProcessor would still drop these.

Note this does not touch the separate stale-megolm case, where a quiet room
stays unreadable until the sender's session rotates — no key request helps
there, and it self-heals.
2026-07-09 18:53:35 -07:00
prosolis
1adcd05dc7 cross-signing: persist the bot's identity instead of reminting it every start
Both session paths called GenerateAndUploadCrossSigningKeys unconditionally on
every start. That published a fresh cross-signing identity each time the bot
restarted, so every user's client saw the bot's verification break and had to
re-verify it. The freshly-minted recovery key was only ever logged, never kept,
so the identity couldn't be recovered either.

bootstrapCrossSigning now mints once, persists the recovery key to
DATA_DIR/cross_signing.json (0600), and on later starts reuses the stored
identity untouched. Two escape hatches, both normally unset:
CROSS_SIGNING_REGENERATE=1 forces exactly one remint (every user must then
re-verify), and CROSS_SIGNING_RECOVERY_KEY imports an identity created before
the bot persisted its own key.

Shared by the appservice and masdevice paths, which had drifted into two copies
of the same block.
2026-07-09 18:53:23 -07:00
prosolis
ba7b20dfe5 url previews: stop dropping thumbnails on body cap and HEAD-403 CDNs
Two independent causes, both silent:

LimitedBody returned an error once the cap was hit, so scrapeOG failed the
whole goquery parse on any page over 2 MiB — even though og: tags live in
<head>, near the top. Hitting the cap is a truncation, not a failure: return
io.EOF and let the parser decide whether it found what it needed. Sized
against the pages actually posted here (n=14): og:title landed within the
first 7 KiB on twelve, worst case ~600 KiB.

validateImageURL HEAD-probed the image and bailed on non-200. Some publisher
CDNs (dims.apnews.com among them) answer HEAD with 403 while serving the same
URL over GET, so their thumbnails were always dropped. Probe with HEAD first,
fall back to a ranged GET asking for the first KiB. A 206 declares the full
size in Content-Range's "/119070" suffix rather than Content-Length, so the
tracking-pixel filter reads size from there.
2026-07-09 18:52:32 -07:00
prosolis
b5493a0e79 N1/A4+A6: wire the stubbed milestone rewards, re-anchor mid-day events
A4 — the three "deferred to hookup" milestone grants now pay out:

  Long Game (T5 clear)   guaranteed Legendary via pickMagicItemForRarity
                         -> dropMagicItemLoot, rendered in a new
                         milestoneAward.Extra block.
  Survivalist (clean T3+) writes AdventureCharacter.Title and announces to
                         the games room. No schema bump — player_meta.title
                         already exists and saveAdvCharacter persists it.
  Two Weeks (day 15)     restocks 3 days of rations (clamped to Supplies.Max)
                         and grants 3 zone-tier consumables.

Two Weeks was specced as +5 max HP "via the expedition row". Dropped: combat
MaxHP comes from stats.HPBonus, built in combat_stats.go from gear/arena/
housing with no expedition in scope. Threading one through would leak an
expedition-only buff into the sim's balance corpus. A supply cache
self-expires with the run and needs no combat math.

A6 — mid-day events rolled 0.5%/player/day from a deferred ticker slot: one
sighting per ~200 days. The roll and its roll-minute scheduler are gone.
Events now fire where the player is demonstrably present and reading a DM:
the end-of-day digest (8%), a sale at Thom's (5%), and an arena cashout (5%),
capped at one event per player per UTC day. A player who hits all three lands
at ~1.19/week. tryTriggerEvent returns bool so a bail (dead / mid-fight /
event already active) hands the day's slot back rather than burning it.

The frequency test drives its own seeded PCG over the chance constants and
the daily cap, so it measures the policy and can't flake on global RNG order.
2026-07-09 18:49:19 -07:00
prosolis
c9df282fde N1/A5: ticket sales fund the community pot
`!lottery pot` has always claimed "pot is funded by ... ticket sales", and
the draw debits prizes from that pot -- but handleLotteryBuy only debited
the player, so ticket euros were burned and the lottery ran as a pure drain
on rake income from elsewhere.

Credit the pot after lotteryInsertTickets succeeds, so the refund path can't
strand money in it.

Watch pot-drain-vs-rake per project_lottery_economics: if weekly pot growth
jumps, tune the prize tiers rather than the ticket price.
2026-07-09 18:32:23 -07:00
prosolis
e8f4863ae0 N1/A1-A3: rewire the drop systems Phase R orphaned
Treasure, masterwork, and consumable drops each had zero call sites: they
only ever fired from the legacy daily activity loop, which adventure.go now
intercepts with a deprecation DM. Hook all three to the zone-combat seam.

A1 - treasure: rollAdvTreasureDropDetailed takes a weight, applied to the
base rate. Boss x4, elite x2, standard x1, plus one x1 roll on zone clear.
Near-miss DMs now fire only for weighted moments; at x1 on autopilot they'd
land on ~3% of every kill.

A2 - masterwork: the catalog is keyed to mining/fishing/foraging, so the
dungeon lookup returned nil and the hook would have been a silent no-op.
masterworkDefForZone rolls across all three slot lines at the zone's tier.
Flavor now follows loc.Activity rather than the item's catalog line, with a
new dungeon pool - a crypt boss must not narrate a pickaxe striking ore.

A3 - consumables + ingredients: the audit found more than the four named
ingredients were stranded. generateAdvLoot is reachable only from
resolveDungeonAction, which has no callers, so all four legacy loot tables
were dead and every one of the 12 recipes was uncraftable. rollZoneIngredient
draws from those tables directly at the zone's tier, reviving them wholesale
rather than re-keying 24 ingredients into the per-zone slates.
2026-07-09 18:29:31 -07:00
prosolis
a4f162d2ee appservice: heartbeat presence every 20s to beat Synapse's 30s offline timeout (was flapping at 60s) 2026-07-04 10:42:55 -07:00
prosolis
0f82a088f9 appservice: start presence heartbeat before plugin init so bot shows online from boot 2026-07-04 10:36:44 -07:00
prosolis
d3f42009f7 appservice: heartbeat presence online so bot shows green while running 2026-07-04 09:54:46 -07:00
prosolis
6387380d0a Add appservice auth mode behind AUTH_MODE; land device grant
Two things, entangled in the working tree because 20d1f92 was mislabeled
(its message claimed "device grant" but it only deleted registration.yaml.example
and left the failed appservice+/sync hybrid in client.go):

1. Land the MAS OAuth 2.0 device grant that was running in prod but never
   committed: masauth.go + client.go rewrite. Bot stays a normal user so /sync
   works; refresh token persisted in data/mas_auth.json.

2. Add "appservice" as an alternate transport behind AUTH_MODE (default
   "masdevice" = unchanged device-grant path, instant rollback):
   - internal/bot/session.go: Session abstraction unifying both transports
     (OnEventType/Run/Stop); NewSession dispatches on AuthMode.
   - internal/bot/appservice.go: as_token auth (MAS-durable, no login/MFA/expiry),
     cryptohelper MSC4190 device creation, crypto machine fed from Synapse
     transaction pushes (to-device/device-lists/OTK) instead of /sync, inbound
     decrypt+redispatch, and lazy per-room StateStore backfill (encryption +
     members) so replies in encrypted rooms encrypt to the right recipients.
   - main.go: NewSession/sess.OnEventType/sess.Run in place of the /sync loop.
   - .env.example: AUTH_MODE, AS_REGISTRATION, AS_LISTEN_HOST/PORT, HOMESERVER_DOMAIN.

The appservice path fixes the earlier hybrid's fatal flaw (Synapse forbids AS
users from /sync) by using the transaction/push model. Bot-side only; Synapse
registration + experimental_features (msc2409/msc3202/msc4190) and E2EE cutover
are the next steps. Build + vet green (CGO=1 -tags goolm).
2026-07-03 17:11:58 -07:00
prosolis
20d1f92f97 Replace appservice auth with MAS OAuth 2.0 device grant
The appservice approach was wrong for a /sync bot: Synapse forbids
appservice-namespace users from /sync (sync.py raises NotImplementedError,
'we no longer support AS users using /sync'), so the bot received no events.

Authenticate instead via the MAS OAuth 2.0 device grant (RFC 8628):
- internal/bot/masauth.go: discover MAS endpoints from well-known+OIDC,
  dynamic client registration, device-code flow (prints approval URL once),
  persist rotating refresh token in data/mas_auth.json, refresh access token.
- internal/bot/client.go: obtain token via device grant, keep bot a NORMAL
  user (so /sync works), background refresher updates the live client, E2EE
  via cryptohelper on a fresh device.
- main.go: drop AS_TOKEN; remove registration.yaml.example; docs updated.

First run needs a one-time browser approval as the bot user; thereafter the
bot refreshes silently.
2026-07-03 15:40:26 -07:00
prosolis
48330be3d5 Migrate Matrix auth to appservice (MAS-durable)
Replace password login + password-UIA cross-signing with appservice
as_token auth and MSC4190 device creation, so the bot survives the
Matrix Authentication Service (MAS) migration that removes m.login.password
and UIA.

- internal/bot/client.go: NewClient uses AS_TOKEN, SetAppServiceUserID,
  whoami validation, cryptohelper MSC4190 device create; drop device.json
  (crypto store persists device id); cross-signing best-effort/soft-fail.
- main.go: Config.Password -> ASToken (reads AS_TOKEN).
- internal/util/auth.go: deleted (password login dead in a MAS world).
- Bump mautrix-go v0.28.0 -> v0.28.1.
- registration.yaml.example + README/.env.example: appservice setup docs.
2026-07-03 15:12:47 -07:00
prosolis
c07d228be6 Merge email-nag into main 2026-06-28 17:40:16 -07:00
prosolis
f93fddd1d6 Add email-nag plugin: collect+verify missing Authentik emails over Matrix DM
One-shot MAS-migration helper. DMs a fixed target set of users with no
email on file in Authentik, collects an address, verifies inbox ownership
with an emailed 6-digit code (via Resend), and only writes it back to
Authentik after confirmation — so a mistyped/hostile address never becomes
a live recovery address. FSM state persists in email_nag_prompts so
restarts never re-nag anyone done or mid-flow.

- Per-user rolling-1h cap on verification emails (EMAIL_NAG_MAX_SENDS_PER_HOUR)
  to prevent send-spam abuse.
- Misconfig disables only this plugin instead of aborting bot startup.
2026-06-28 17:39:29 -07:00
prosolis
7c19788b52 Merge long-expeditions-d1 into main
Brings the J3 / long-expedition track up to main, including link-thumbnail
og:image previews + WOTD default-off.
2026-06-24 23:17:56 -07:00
prosolis
8122973b74 Link thumbnails: post og:image previews + WOTD default-off
URL link previews now post the page's og:image as an inline m.image
thumbnail above the title/description reply. All outbound fetches in the
preview path (page scrape, image HEAD probe, image download) route through
a new SSRF/DoS-hardened safehttp client so a posted link can't steer
fetches at loopback/RFC1918/cloud-metadata IPs or OOM the parser.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Diagnostic context: project_d8d_diagnostic. Cleric flat manor likely
the concentration AOE re-tick engine gap (filed for D8-e+).
2026-05-28 09:35:04 -07:00
prosolis
d7ced471a1 J3 D8-c: concentration ×3 multiplier in spellExpectedDamage
`spellExpectedDamage` (`dnd_class_balance.go`) now multiplies expected
damage by 3 when `sp.Concentration && sp.Effect ∈ {EffectDamageSave,
EffectDamageAuto}`. Attack-roll concentration excluded by design —
hex-style cases ride per-hit mods, not the score. Closes the picker
gap that walked past heat_metal / spike_growth / flaming_sphere /
cloud_of_daggers in favour of one-shot blasts of similar slot level.

Measured on n=5000 d8c corpus vs d8prereq baseline (sim_results,
gitignored): per-class means all within ±2.4pp (1σ ≈ 2.2pp); macro
leaderboard unchanged. Real picker swap lands at T3 manor_blackspire
— bard +11pp, mage +10pp, sorcerer +9pp, druid +5pp. T4/T5 caster
wall unchanged → confirms HP+AC is the binding constraint past T3
(D8-d-fix territory), not picker quality.

Side discovery: cleric flat / warlock −6pp manor — spirit_guardians-
style AOE-save concentration spells appear to resolve once in
SimulateCombat / turn-engine instead of re-ticking per round. The
multiplier is correct in expectation; the engine under-delivers.
Filed as separate follow-up; D8-c stays.

Plan §8 D8-c marked SHIPPED.
2026-05-28 08:14:13 -07:00
prosolis
63ad423b79 J3 D8-review: surface sim subprocess errors + SW upcast + doorway msg
- expedition-sim matrix worker now captures child stderr and dumps
  runErr/stderr/stdout-snippet on failure so halted rows have a cause.
- simPickSpiritualWeapon walks slots 2..5 and upcasts when L2 is spent
  instead of silently skipping the spell on high-level clerics.
- advanceOnceWithOpts !inlineBossCombat branch now emits the same
  "Room X/Y — Boss/Elite. Type !fight to engage." line as foreground.
2026-05-28 00:53:25 -07:00
prosolis
da94d51857 J3 D8-d: T4 caster wall diagnostic — HP+AC, not transit/heals
Plan update only. Diagnostic in sim_results/d8d_findings.md
(gitignored). n=3-5 per caster L10 underdark:

- Multi-region transit eliminated: all casters TPK in r1
  before any cross-region transit; Combats=0 (no elite/boss
  reached).
- Heal-stock not the lever: bumped simConsumableBundle T4
  2 -> 5 Spirit Tonics, +3-5 rooms median, 0/5 clears.
  Reverted.
- Confirmed lever: computeAC class floors (casters 11-14
  vs martial 16-17) + d6/d8 HP scaling.

Recommended next session: lift floors in computeAC
(dnd.go:195) — cleric/druid 3->5, bard/warlock 1->3,
mage/sorcerer 0->2. Validate vs d8prereq_corpus.
2026-05-28 00:41:28 -07:00
prosolis
151d6abc01 J3 D8-prereq: plan update — corpus diff + D8-c/d/e queued
Marks D8-prereq shipped with the 5000-run d8prereq_corpus measurements
(cleric +25.8, sorcerer +22.6, warlock +20.2, mage +17.8, druid +14.4,
bard +12.0 vs d7d). Caster/martial gap closed from ~40pp to ~22pp; T2/T3
zones are where the picker pays off.

Queues three follow-ups in §8: D8-c concentration-damage modeling, D8-d
T4 caster wall diagnostic (every caster still 0% at underdark — picker
no longer the suspect), D8-e martial T4/T5 regression triage (-6 to -18
pp from routing boss/elite through the turn-engine instead of inline
sim; likely inline was over-rosy, not turn-engine wrong).

Findings detail: sim_results/d8prereq_findings.md (local, sim_results/
is gitignored). Re-baseline corpus: sim_results/d8prereq_corpus.jsonl.
2026-05-28 00:18:29 -07:00
prosolis
631764bbbd J3 D8-prereq: split compact flag so sim drives the picker
Adds inlineBossCombat alongside compact in runAutopilotWalk and
advanceOnceWithOpts. Production background autorun keeps both true
(inline auto-resolve), foreground stays both false (manual !fight), the
sim now uses compact=true + inlineBossCombat=false so the boss/elite
doorway returns stopBoss/stopElite after the safety gate — autoResolveCombat
+ simPickCombatAction / simPickSpell drive the fight via the turn-based
engine. The picker (and D8-b upcasting) has been dead since D3's
compact-inline boss rooms; this re-wires it.

n=50/cell L10 smoke vs d7d (zones T1-T3):
  bard    forest_shadows  61 → 100   (+39)
  bard    manor_blackspire 10 →  34   (+24)
  cleric  forest_shadows  15 →  96   (+81)
  cleric  manor_blackspire 0 →  54   (+54)
  fighter T1-T3                100   (unchanged)

Also parallelizes matrix mode via subprocess workers (each child has its
own SQLite — db package globals preclude in-process parallelism). New
-jobs flag, defaults to runtime.NumCPU(). 8 workers gave ~7x speedup on
the smoke matrix.
2026-05-27 23:16:33 -07:00
prosolis
ad2a8258ba J3 D8-b: sim picker upcasting + discovery picker is dead since D3
simPickSpell now enumerates one candidate per available slot >= native
for every prepared damage spell, scored via spellExpectedDamage with the
existing "+1 die per slot" scaling. Cantrips contribute a single slot-0
candidate. Tie-break: highest slot first, then highest expDmg. Picker
returns "<id> --upcast N" when the winning slot exceeds native so
parseCombatCast upcasts in the engine. Build + plugin tests green.

Then: measured zero impact. d8b_corpus.jsonl (same 10x5x100 matrix as
d7d) lands every class within +/-1.5pp of d7d baseline.

Root cause discovered post-corpus: simPickSpell is dead code in the
current sim path. Commit 68ed8e7 ("Long expeditions D3: compact
autopilot auto-resolves boss rooms") added a !compact gate at
dnd_expedition_cmd.go:810 so compact autopilot inline-resolves boss/
elite rooms via resolveCombatRoom -> runZoneCombat -> SimulateCombat
instead of returning stopBoss/stopElite for autoResolveCombat to handle.
The sim uses compact=true (expedition_sim.go:433), so autoResolveCombat
- the only caller of simPickCombatAction/simPickSpell - never fires.
Verified empirically: a stderr-traced simPickSpell produced zero hits
across full bard/cleric L10 runs.

This rewrites the picker-era retrospective. J2's baseline_j2a_v2_all10
(bard 40%, cleric 39%) was measured before D3 with the picker live.
d7d's L10 baseline (bard 34%, cleric 21%) is post-D3 with the picker
disconnected - that 6-18pp drop is the post-D3 caster regression, not
"long-expedition mechanics didn't help casters" as d7d framed it.
D8-a's "+2.2pp cleric" was also noise (1sigma ~ 1.8pp on n=500).

D8-b code kept in tree (correct, currently inert, turns on as soon as
the wire reconnects). Plan rewritten in section 8: D8-b and D8-c are
deferred behind D8-prereq, which splits compact so the sim opts out of
compact-inline boss/elite combat without affecting prod rendering.

Files:
- internal/plugin/expedition_sim.go: simPickSpell upcast enumeration
- gogobee_long_expedition_plan.md: D8-b implemented-but-inert,
  D8-prereq added as next step
- sim_results/d8b_corpus.jsonl: 5000-row corpus retained for the
  picker-dead baseline
2026-05-27 22:27:04 -07:00
prosolis
2fdb280477 J3 D8-a: caster picker data fixes + Spiritual Weapon pick
Bard L1 += thunderwave, L2 += heat_metal; cleric L1 += inflict_wounds;
shatter overlay Classes broadened (defensive — mergeClassList already
unioned). New simPickSpiritualWeapon runs before simPickSpell so a
cleric with an L2 slot opens fights with the BuffSelf-tagged spell that
the regular picker (damage-effects only) was skipping; existing
spiritWeaponStrike per-round mace path lights up.

Measured L10 n=100/zone: cleric 21.0 → 23.2% (+2.2pp), bard within
noise. T3+ wall unchanged — picker upcasting + concentration-damage
modeling (D8-b/c) are the bigger levers, queued in plan §8.
2026-05-27 22:00:02 -07:00
prosolis
4576c75722 Long expeditions D7-d: corpus re-run + sim heavy-preset fix
Fix sim regression introduced by D5-b: bare `expedition start <zone>`
returns the loadout prompt DM without outfitting, so SimRunner.RunExpedition
was halting before persisting any expedition. Pass `heavy` from the
harness — tier-max packs, no prod paths touched.

D7-d corpus (sim_results/, gitignored): n=100 × 10 classes × 5 zones ×
L10. Leaderboard mirrors J2b — martials 78–82%, casters 21–42%, cluster
gap unchanged by long-expedition mechanics. Cleric worst at 21% (L10).
Bard/cleric trailers not relieved by autopilot camp pacing; remains
J3-territory. T3/T4 cleared runs hit their §2 target durations.

D5-d retune decision: no change. phase5BDailyBurnRatePct=50 + per-tier
DailyBurn stay as-is — heavy-preset cleared runs end with 78–98% SU
surplus; even TPK runs leave packs mostly full. Supply economy is not
a binding constraint at heavy preset.

Closes the long-expedition track.
2026-05-27 21:18:48 -07:00
prosolis
3b29d10461 Long expeditions D7-c: -days flag + per-day snapshots in SimResult
cmd/expedition-sim -days N caps runs by synthetic day rollovers
(Outcome="day_capped"). SimResult.DaySnapshots traces HP/SU/threat/rooms
at start, every Night-camp rollover, and end-of-run — unblocks empirical
D5-d retune of phase5BDailyBurnRatePct against per-day SU draws.
2026-05-27 20:47:28 -07:00
prosolis
29cad7972a Long expeditions D7-b: drive autopilot camp from SimRunner
maybeAutoCamp / pitchAutopilotCamp / pitchBossSafetyCamp now take a
now time.Time so the sim can inject a synthetic clock; tryAutoRun
still passes time.Now().UTC(). SimRunner.RunExpedition advances simNow
by autoRunCooldown per walk and runs the production camp scheduler
after each soft stop (and pitchBossSafetyCamp on stopBossSafety),
dwelling minAutoCampDwell + breakAutoCampIfDue so the next walk can
proceed. Effect: HP-low mid-day rests, base-camp waypoints, Night-camp
rollovers, and boss-safety holds all fire under the sim; D7-a's
tickEventAnchoredRollover shortcut is retained on TickDay for tests
and the pre-cutoff legacy path.
2026-05-27 20:40:04 -07:00
prosolis
a2992ea06c Long expeditions D7-a: teach SimRunner.TickDay event-anchored rollover
deliverBriefingEventAnchored reads time.Now().UTC() for its safety-net
check, so synthetic TickDay calls never advanced CurrentDay on D2-b
expeditions — DaysAtEnd / SUEnd stayed at start values. Short-circuit
in TickDay: when isEventAnchored, fire nightRolloverBurn → optional
applyCampRest (Standard, Rough fallback, skipped on low-SU) →
nightRolloverDrift(briefAt). Mirrors pitchAutopilotCamp Night=true.
Production paths untouched.

Unblocks D5-d retune of phase5BDailyBurnRatePct and the class corpus
re-run.
2026-05-27 20:15:54 -07:00
prosolis
6c4b14e113 Long expeditions D6: player-facing surface cleanup
Rewrite !expedition help around the autopilot loop, frame !camp/!fight
as overrides, and add Day X / ~Y expected + rooms/total + last-3-events
to !expedition status.
2026-05-27 20:01:50 -07:00
prosolis
9be85ba954 Long expeditions D5-c: wire Ranger forage SU
§4.2's "Ranger, 1d4 SU/day" perk had been dead since Phase 12 E1b —
SupplyForageMaxSU was defined but unreferenced, ForagedToday was only
ever reset to false. New applyRangerForage helper grants 1d4 SU once
per day (headroom-capped, Ranger-only), fires at the top of
nightRolloverBurn, and surfaces as a "forage" line in the end-of-day
digest. No DC roll — accessibility over crunch, and the D5-a caps
already give all loadouts comfortable headroom.
2026-05-27 19:48:44 -07:00
prosolis
26cda148fb Long expeditions D5-b: loadout preset prompt at launch
`!expedition start <zone>` (and `!resume`) with no pack arg now DMs a
Lean / Balanced / Heavy menu sized per zone tier. Player replies with
the preset name (short forms l/b/h also work). Raw `Ns Md` syntax stays
as the advanced override.

Heavy always equals supplyPackCaps (the D5-a harsh×3 ceiling); Lean
covers intended days at raw burn; Balanced sits between.
2026-05-27 19:41:49 -07:00
prosolis
040cfba514 Long expeditions D5-a: per-tier supply pack caps
Today's global SupplyPackStandardMax=3 / SupplyPackDeluxeMax=1 were
a 2-day shape; with D1's longer room budgets and D2-b's event-anchored
night burns, a T4/T5 player can't legally buy enough supplies for the
intended duration. supplyPackCaps(tier) now returns (std,dlx) per
tier — T1/T2: (2,1); T3: (3,1) unchanged; T4: (5,1); T5: (7,2) —
sized to clear DailyBurn(raw) × intendedDays × 1.3 even with the
harsh×3 multiplier layered on. Validate takes a tier; both call sites
(!expedition start, !resume) pass the resolved zone's tier. Holiday
+1 standard pack still bypasses the cap on purpose. DailyBurn /
phase5BDailyBurnRatePct unchanged; that's a D7 lever once the sim
can measure event-anchored rollovers.
2026-05-27 19:33:57 -07:00
prosolis
aaec0ba225 Long expeditions D4-b: activity-anchored morning briefing
Event-anchored expeditions no longer pin their re-engagement DM to 06:00
UTC. The ticker skips idle players (last_activity older than today's
threshold, inside the 28h safety net); maybeDeliverDeferredBriefing fires
the owed briefing on the next inbound message in any room. The OnMessage
hook also stamps last_activity so chat presence (not just bot commands)
counts toward "the player is here."
2026-05-27 19:21:49 -07:00
prosolis
5a6e395805 Long expeditions D4-a: autopilot DM bundling + EoD digest
Suppress per-tick auto-walk DMs in compact mode. tryAutoRun now only
surfaces for fork / death / run-complete / boss-safety camp / Night-camp
pitch. A Night-camp pitch flushes the day as an EoD digest (counts of
walk/harvest/interrupt plus threat/milestone/narrative bullets, built
from dnd_expedition_log) followed by the camp block. Each successful
background walk writes a `walk` log entry so the digest can count rooms
without persisting raw stream narration. maybeAutoCamp and
pitchBossSafetyCamp now return the autoCampDecision so callers can
branch on dec.Night.
2026-05-27 19:07:37 -07:00
prosolis
68ed8e7c60 Long expeditions D3: compact autopilot auto-resolves boss rooms
Drops the boss carve-out in the compact (background autorun) path so
boss rooms resolve through the same forward-sim engine elites already
use. A `bossSafetyGate` (HP < 80% / supplies < daily burn /
exhaustion >= 3) guards the engage; when it trips, the walk returns
`stopBossSafety` and the autorun ticker force-pitches a rest camp via
`pitchBossSafetyCamp` (bypasses the normal scheduler's HP threshold
and its RoomBoss room-type block; keeps event-anchored night handling).

`resolveCombatRoom` now selects monster + label + loot drop by
`run.CurrentRoomType()` so the same callsite handles boss kills
(zone.Boss bestiary, "Boss — name down", boss-loot drop, elite-tier
threat bump). The walk loop only breaks at elite/boss doorways when
`!compact`; compact lets the next iteration auto-resolve.

Foreground `!fight` and `!expedition run` are unchanged. Sim path is
unaffected — stopBossSafety falls into the default soft-stop branch.
2026-05-27 18:56:52 -07:00
prosolis
c729433353 Long expeditions D2-b: event-anchored day rollover
Splits the legacy briefing body into nightRolloverBurn + nightRolloverDrift,
plus a processNightCamp convenience. For expeditions started after the new
eventAnchoredCutoff, the 06:00 UTC briefing stops mutating: it posts a
re-engagement DM, and only force-fires processNightCamp itself after a 28h
safety-net window. Day++/burn/threat-drift now ride along the autopilot
night-camp pitch (decideAutopilotCamp sets Night=true when ≥16h since the
last rollover) and on the first player !camp since the last rollover. The
legacy UTC-anchored flow still works via the same staged helpers, with
processOvernightCamp interleaved to preserve the rest-before-drift ordering.

Tests pin eventAnchoredCutoff to year 9999 in TestMain so the existing
legacy assertions still run; new tests cover the night decision, the
night-camp pitch advancing the day, the event-anchored skip, and the
safety-net force-rollover.
2026-05-27 18:42:57 -07:00
prosolis
7115c536ef Long expeditions D2-a: autopilot camp scheduler
New expedition_autocamp.go: pure decideAutopilotCamp + pitchAutopilotCamp
+ dwell-window lifecycle. Wired into tryAutoRun so the background ticker
pitches a rest camp on low HP and a base-camp waypoint on region-boss
clear; auto-pitched camps last minAutoCampDwell (4h) before the next tick
breaks them and walks. CampState.AutoPitched separates auto- vs player-
pitched camp lifetimes so a player !camp stays sticky.

Day-rollover semantics unchanged — still UTC-anchored; D2-b moves
day++/burn/threat-drift onto the camp-pitch event.
2026-05-27 18:23:26 -07:00
prosolis
42fb805ee0 Long expeditions D1-e: T5 zones to new 36-44 room band
Closes D1. Both T5 zones follow the D1-a/b/c/d pattern: extend the
graph so the longest entry→boss walk lands in band, keep the zone's
authored topology intact. Also backfills the missing per-node
RegionID authoring against dnd_expedition_region.go — the D1-d
deferral note flagged this as a prerequisite for T5.

  dragons_lair (T5): 12 → 47 nodes, longest 38 (band 36-44)
  abyss_portal (T5): 13 → 51 nodes, longest 39 (band 36-44)

Topology preserved per zone:
- Dragon's Lair: 4-region authoring (every node carries a valid
  RegionID); binary-converging fork1 (ash_bridge TRAP / treasure_vault
  Perception 15) still converges at the R3 wyrmlings_nest elite;
  capstone fork2 still 3-way (LockNone / CHA 16 / Perception 17
  SECRET LootBias 2.5) with each spur preserving its own named
  capstone node. New R4 infernax_doors MERGE consolidates the long
  final hall instead of triplicating it.
- Abyss Portal: 4-region authoring; three sequential forks preserved
  (binary / binary / ternary) — fork1 Perception 16, fork2 CON 16
  (full STR/DEX/CON/INT/WIS/CHA roster still covered), fork3 capstone
  3-way with reality_seam SECRET LootBias 3.0. New R4 belaxath_doors
  MERGE consolidates the final tear-approach.

MinRooms/MaxRooms re-pitched 9-10 → 36-44 so the dice fallback also
lands in band for any future graphless variant.
2026-05-27 18:13:33 -07:00
prosolis
d99f975074 Long expeditions D1-d: T4 zones to new 28-34 room band
Both T4 zones follow the D1-a/b/c pattern: extend the graph so the
longest entry→boss walk lands in band, keep the zone's authored
topology intact.

  underdark         (T4): 10 → 46 nodes, longest 30 (band 28-34)
  feywild_crossing  (T4):  9 → 53 nodes, longest 30 (band 28-34)

Topology preserved per zone:
- Underdark: 4-region authoring intact (every node carries a valid
  RegionID); 3-way fork1 (drow R2 / illithid R3 / deep_chasm R1)
  with LockNone / LockPerception / LockStatCheck CON; deep_chasm
  intentionally stays R1; all three arms converge at the R4
  throne_approach merge. Adds the missing Trap anchor (Collapsed
  Arch) in the R1 preamble.
- Feywild Crossing: woven forks intact — hag_circle still has two
  incoming edges (one per first-stage path), time_eddy stays
  grove-exclusive, illusion_garden stays marsh-exclusive, fork1
  remains CHA-vs-Perception with no LockNone option. Adds the
  missing Trap anchor (Cursed Thicket) and a fae_court MERGE so
  the three second-stage endings converge into one final boss
  approach instead of triplicating it.

MinRooms/MaxRooms re-pitched 8-10 → 28-34 so the dice fallback also
lands in band for any future graphless variant.

T5 zones (dragons_lair, abyss_portal) deferred to D1-e — those
need a RegionID backfill on top of the length extension since their
regionsByZone entries are not currently honored by their graphs.
2026-05-27 18:02:05 -07:00
prosolis
bbc25fe958 Long expeditions D1-c: T3 zones to new 22-26 room band
Both T3 zones follow the D1-a/b pattern: extend the graph so the longest
entry→boss walk lands in band, keep the zone's authored topology intact.

  manor_blackspire (T3): 11 → 35 nodes, longest 23 (band 22-26)
  underforge       (T3): 10 → 31 nodes, longest 23 (band 22-26)

Topology preserved per zone:
- Manor: two-stacked-3-way forks identity + full lock-kind coverage
  (LockNone, LockPerception, LockStatCheck, LockLevelMin). Adds the
  missing Trap anchor (Cursed Threshold). All six spokes are 3 mid-nodes
  so route choice is loot/encounter character, not shortcut economics.
- Underforge: one-way-descent gauntlet identity preserved — 17-node
  linear preamble through the existing Cooling River (TRAP) and Magma
  Chamber (ELITE), single 3-way antechamber fork before boss. All three
  antechamber spokes are 4 mid-nodes.

MinRooms/MaxRooms re-pitched 7-9 → 22-26 so the dice fallback also lands
in band for any future graphless variant.
2026-05-27 17:46:38 -07:00
prosolis
89edc76829 Long expeditions D1-b: crypt_valdris + T2 zones to new room bands
Three more zones follow the goblin_warrens pattern from D1-a: add the
missing Trap anchor and deepen the graph so the longest entry→boss walk
lands in its tier's target band.

  crypt_valdris  (T1):  8 → 17 nodes, longest 13 (band 12–14)
  forest_shadows (T2):  9 → 19 nodes, longest 16 (band 16–20)
  sunken_temple  (T2): 10 → 26 nodes, longest 16 (band 16–20)

Topology preserved per zone:
- Crypt diamond + Perception-DC-15 secret off side_chapel.
- Forest asymmetric main fork (long branch carries the elite) +
  WIS-DC-14 secret pre-boss.
- Sunken Temple sequential forks with no mid-path merge (four leaves
  all terminate at the single boss room).

MinRooms/MaxRooms re-pitched to the §2 bands so the dice fallback also
lands in band for any future graphless zone.
2026-05-27 17:32:29 -07:00
prosolis
4999368031 Long expeditions D1-a: graph-driven TotalRooms; goblin_warrens 7→16
Length is now sourced from the zone graph's longest entry→boss path,
not the dice roll, so "Room X/Y" matches what the player walks. The
dice fallback stays for graphless zones.

Goblin Warrens grows to the T1 12–14 band (13-node traversal): adds the
missing Trap anchor and deepens both fork branches + the post-merge
approach. Pattern reference for the remaining zones in D1-b…d.
2026-05-27 17:24:36 -07:00
prosolis
92b99a0399 Camp: heal on pitch; ambient cooldown 3h → 6h
!camp now applies long-rest effects (HP, spell slots, exhaustion,
threat -5, Underforge heat) immediately instead of waiting for the
06:00 UTC briefing. New CampState.RestApplied flag stops
processOvernightCamp from re-applying at briefing — it just strikes
the camp.

Also dials ambient cooldown 3h → 6h so a workday gives ~1–2 hits
instead of ~3.
2026-05-27 16:57:57 -07:00
prosolis
6e4928ca17 Merge pull request #11 from prosolis/forex-crypto-coingecko
Forex: convert to/from crypto via CoinGecko (keyless)
2026-05-21 22:21:52 -07:00
241 changed files with 27990 additions and 3688 deletions

View File

@@ -1,12 +1,50 @@
# ---- Matrix Connection ----
# GogoBee authenticates via the MAS (Matrix Authentication Service) OAuth 2.0
# device grant. No password/token goes here: on first run the bot prints a URL
# + code to approve ONCE in a browser (logged in as BOT_USER_ID), then stores a
# refresh token in DATA_DIR/mas_auth.json and refreshes silently thereafter.
# To force re-authorization, delete data/mas_auth.json.
HOMESERVER_URL=https://matrix.example.com
BOT_USER_ID=@gogobee:example.com
BOT_PASSWORD=your_password_here
BOT_DISPLAY_NAME=GogoBee
# ---- Auth mode ----
# masdevice (default) — MAS OAuth 2.0 device grant over /sync (above).
# appservice — Matrix appservice: the registration's as_token is the
# credential (no login/MFA/expiry, MAS-durable). The bot
# runs an HTTP listener and receives events over Synapse's
# transaction push instead of /sync (Synapse forbids AS
# users from /sync). E2EE rides MSC3202/MSC2409 + MSC4190.
AUTH_MODE=masdevice
# The following are required only when AUTH_MODE=appservice:
AS_REGISTRATION= # path to the appservice registration YAML (as_token/hs_token/namespaces)
AS_LISTEN_HOST= # bind address for the transaction listener (e.g. 0.0.0.0 or a tailnet IP)
AS_LISTEN_PORT= # bind port; Synapse's registration url must reach host:port
HOMESERVER_DOMAIN= # server_name, e.g. example.com (derives the bot MXID from sender_localpart)
# ---- Cross-signing (optional; both auth modes) ----
# Controls only whether the bot shows as VERIFIED in clients — E2EE works without it.
# Normally you set NEITHER of these. On its first start the bot mints a cross-signing
# identity and persists the recovery key itself to DATA_DIR/cross_signing.json (0600);
# every later start reuses that identity untouched, so users verify the bot once, ever.
#
# Set to 1 for exactly ONE start to throw away the published identity and mint a fresh
# one (the bot stores the new key itself). Every user must then verify the bot again.
# Needed only if the identity was lost, or to adopt one the bot can't re-sign.
CROSS_SIGNING_REGENERATE=
#
# Imports an existing recovery key into DATA_DIR/cross_signing.json on next start.
# Only useful to adopt an identity created before the bot persisted its own key.
CROSS_SIGNING_RECOVERY_KEY=
# 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
@@ -123,3 +161,17 @@ MINIFLUX_MAX_PER_POLL=5 # max entries per feed per poll (default 5)
RATELIMIT_WEATHER=5
RATELIMIT_TRANSLATE=20
RATELIMIT_CONCERTS=10
# ---- Email Nag (one-shot migration helper: collect+verify missing Authentik emails over Matrix DM) ----
FEATURE_EMAIL_NAG= # set to "true" to enable
EMAIL_NAG_AUTHENTIK_URL= # e.g. https://authentik.parodia.dev
EMAIL_NAG_AUTHENTIK_TOKEN= # Authentik service-account token, scoped to user write
EMAIL_NAG_RESEND_KEY= # Resend API key (sends the verification code)
EMAIL_NAG_MAIL_FROM= # e.g. Parodia <noreply@mail.parodia.dev>
EMAIL_NAG_HOME_DOMAIN= # Matrix server name, e.g. parodia.dev (builds @user:domain)
EMAIL_NAG_TARGETS= # comma-separated Authentik usernames to nag (== Matrix localparts)
EMAIL_NAG_CODE_TTL=30m # verification code lifetime (default 30m)
EMAIL_NAG_MAX_ATTEMPTS=5 # wrong-code attempts before restart (default 5)
EMAIL_NAG_MAX_SENDS_PER_HOUR=5 # cap on verification emails per user per rolling hour (anti-spam, default 5)
EMAIL_NAG_SWEEP_DELAY=2s # delay between DMs during the startup sweep (default 2s)
EMAIL_NAG_REPROMPT_COOLDOWN= # re-nag non-responders whose last prompt is older than this (e.g. 72h); empty/0 = nag once

View File

@@ -91,7 +91,14 @@ Everything is configured through environment variables or a `.env` file.
|----------|-------------|
| `HOMESERVER_URL` | Matrix homeserver URL, e.g. `https://matrix.org` |
| `BOT_USER_ID` | Bot's Matrix user ID, e.g. `@gogobee:matrix.org` |
| `BOT_PASSWORD` | Bot's Matrix password |
GogoBee authenticates via the **Matrix Authentication Service (MAS) OAuth 2.0
device grant** — no password or token in the environment. On first run it prints
a verification URL and user code; approve it once in a browser while logged in
as `BOT_USER_ID`. The bot then stores a refresh token in `DATA_DIR/mas_auth.json`
and refreshes its access token silently for the life of the deployment. Delete
`data/mas_auth.json` to force re-authorization. Requires a homeserver with MAS
enabled (advertised via the `org.matrix.msc2965.authentication` well-known).
### Core (optional)

View File

@@ -3,22 +3,33 @@
// outcomes mirror what live players hit.
//
// Single run:
// expedition-sim [-class fighter] [-level 5] [-zone goblin_warrens]
// [-bank 1000] [-cap 50] [-log] [-data DIR]
//
// expedition-sim [-class fighter] [-level 5] [-zone goblin_warrens]
// [-bank 1000] [-cap 50] [-log] [-data DIR]
//
// Party run (N3/P7 — the leader is -class; followers clone it unless named):
//
// expedition-sim -party 3 -level 15 -zone dragons_lair
// expedition-sim -party 2 -class cleric -party-classes fighter -level 16 ...
//
// Matrix mode (cartesian sweep over classes × levels × zones × N runs,
// one JSON object per stdout line, log suppressed by default):
// expedition-sim -matrix -classes fighter,mage -levels 5,10 \
// -zones goblin_warrens,wolf_den -runs 20
//
// expedition-sim -matrix -classes fighter,mage -levels 5,10 \
// -zones goblin_warrens,wolf_den -runs 20
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
"gogobee/internal/plugin"
@@ -32,6 +43,7 @@ func main() {
zone = flag.String("zone", "goblin_warrens", "zone id (single-run mode)")
bank = flag.Float64("bank", 1000, "starting coin balance — must cover outfitting")
cap = flag.Int("cap", 50, "max autopilot bursts per expedition (each = up to autopilotRoomCap rooms)")
days = flag.Int("days", 0, "stop after N synthetic day rollovers (0 = unbounded; the -cap safety net still applies)")
dataDir = flag.String("data", "", "data dir for the temp sqlite db (default: OS tempdir; ignored in matrix mode)")
userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)")
logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)")
@@ -45,12 +57,21 @@ func main() {
trace = flag.Bool("trace", false, "include raw per-round CombatEvent stream on the LAST combat of each expedition (boss room) — for J2 diagnostic sweeps")
petLevel = flag.Int("pet-level", 0, "attach a base housing pet at this level (1-10) to every sim character; 0 = no pet (default, matches prod char-creation)")
party = flag.Int("party", 1, "seat a party of N (1 = solo, the historical path). Followers are invited on Day 1 and buy their own supplies")
partyClasses = flag.String("party-classes", "", "comma-separated classes for the N-1 followers (default: clones of -class)")
jobs = flag.Int("jobs", 0, "matrix mode — concurrent worker count (each worker is a subprocess so it gets its own sqlite). 0 = runtime.NumCPU()")
)
flag.Parse()
if *petLevel < 0 || *petLevel > 10 {
fail("pet-level must be 0-10, got", *petLevel)
}
if *party < 1 || *party > plugin.ExpeditionPartyMax {
fail("party must be 1-", plugin.ExpeditionPartyMax, ", got", *party)
}
followers := followerClasses(*class, *party, *partyClasses)
plugin.SetSimIncludeTrace(*trace)
plugin.SetSimPetLevel(*petLevel)
@@ -65,14 +86,40 @@ func main() {
includeLog = *logFlag
}
})
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, includeLog)
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses)
return
}
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *logFlag)
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag, followers)
}
func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap int, includeLog bool) {
// followerClasses expands -party / -party-classes into the class of each
// follower seat. An explicit list must name every seat: a party of 3 whose
// second follower silently defaulted to the leader's class would quietly
// answer a different question than the one asked.
func followerClasses(leaderClass string, party int, spec string) []string {
n := party - 1
if n == 0 {
if strings.TrimSpace(spec) != "" {
fail("-party-classes given but -party is 1")
}
return nil
}
if strings.TrimSpace(spec) == "" {
out := make([]string, n)
for i := range out {
out[i] = leaderClass
}
return out
}
out := splitNonEmpty(spec)
if len(out) != n {
fail(fmt.Sprintf("-party %d needs %d follower classes, got %d", party, n, len(out)))
}
return out
}
func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap, days int, includeLog bool, followers []string) {
dir := dataDir
if dir == "" {
var err error
@@ -83,7 +130,7 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa
defer os.RemoveAll(dir)
}
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap)
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days, followers)
if err != nil {
if res != nil {
if !includeLog {
@@ -99,47 +146,133 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa
emitIndented(res)
}
func runMatrix(classes, levels, zones string, runs int, bank float64, cap int, includeLog bool) {
// matrixJob is one (class, level, zone, replicate-index) cell of the
// matrix sweep. Each job is run by a worker as a single-run subprocess so
// it gets its own SQLite handle — the plugin package's db.* globals
// preclude in-process parallelism.
type matrixJob struct {
class string
level int
zone string
rep int
}
func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel, party int, partyClasses string) {
cs := splitNonEmpty(classes)
ls := parseLevels(levels)
zs := splitNonEmpty(zones)
if len(cs) == 0 || len(ls) == 0 || len(zs) == 0 || runs <= 0 {
fail("matrix mode requires non-empty -classes, -levels, -zones and runs > 0")
}
enc := json.NewEncoder(os.Stdout)
if jobs <= 0 {
jobs = runtime.NumCPU()
}
exe, err := os.Executable()
if err != nil {
fail("os.Executable:", err)
}
work := make([]matrixJob, 0, len(cs)*len(ls)*len(zs)*runs)
for _, c := range cs {
for _, lv := range ls {
for _, z := range zs {
for r := 0; r < runs; r++ {
dir, err := os.MkdirTemp("", "expedition-sim-")
if err != nil {
fail("mkdir temp:", err)
}
uid := id.UserID(fmt.Sprintf("@sim:%s-l%d-%s-%d", c, lv, z, r))
res, runErr := runOne(dir, uid, plugin.DnDClass(c), lv, plugin.ZoneID(z), bank, cap)
if res != nil && !includeLog {
res.Log = nil
}
if runErr != nil && res == nil {
// Synthesize a row so the corpus has one line per
// cell regardless of init failures.
res = &plugin.SimResult{
UserID: string(uid),
Class: c,
Level: lv,
Zone: z,
Outcome: "halted",
}
}
_ = enc.Encode(res)
_ = os.RemoveAll(dir)
work = append(work, matrixJob{class: c, level: lv, zone: z, rep: r})
}
}
}
}
workCh := make(chan matrixJob)
resCh := make(chan *plugin.SimResult, len(work))
var wg sync.WaitGroup
for i := 0; i < jobs; i++ {
wg.Add(1)
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses)
}
go func() {
for _, j := range work {
workCh <- j
}
close(workCh)
}()
go func() {
wg.Wait()
close(resCh)
}()
enc := json.NewEncoder(os.Stdout)
for r := range resCh {
_ = enc.Encode(r)
}
}
func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap int) (*plugin.SimResult, error) {
func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel, party int, partyClasses string) {
defer wg.Done()
for j := range in {
uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep)
dir, err := os.MkdirTemp("", "expedition-sim-")
if err != nil {
out <- &plugin.SimResult{UserID: uid, Class: j.class, Level: j.level, Zone: j.zone, Outcome: "halted"}
continue
}
args := []string{
"-class", j.class,
"-level", strconv.Itoa(j.level),
"-zone", j.zone,
"-bank", strconv.FormatFloat(bank, 'f', -1, 64),
"-cap", strconv.Itoa(cap),
"-days", strconv.Itoa(days),
"-data", dir,
"-user", uid,
fmt.Sprintf("-log=%t", includeLog),
fmt.Sprintf("-pet-level=%d", petLevel),
fmt.Sprintf("-party=%d", party),
}
// Left empty, each cell's followers clone that cell's own -class.
if partyClasses != "" {
args = append(args, "-party-classes", partyClasses)
}
if trace {
args = append(args, "-trace")
}
cmd := exec.Command(exe, args...)
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
stdout, runErr := cmd.Output()
var res plugin.SimResult
jerr := json.Unmarshal(stdout, &res)
if jerr != nil {
res = plugin.SimResult{UserID: uid, Class: j.class, Level: j.level, Zone: j.zone, Outcome: "halted"}
}
if runErr != nil && res.Outcome == "" {
res.Outcome = "halted"
}
// Surface subprocess failures: parse error with non-empty stdout
// (corrupted JSON) and non-zero exits both get a stderr dump so the
// user sees the underlying cause instead of just a halted row.
if jerr != nil || runErr != nil {
fmt.Fprintf(os.Stderr, "sim cell %s halted: runErr=%v jerr=%v\n", uid, runErr, jerr)
if stderrBuf.Len() > 0 {
fmt.Fprintf(os.Stderr, " child stderr:\n%s\n", stderrBuf.String())
}
if jerr != nil && len(stdout) > 0 {
snip := stdout
if len(snip) > 200 {
snip = snip[:200]
}
fmt.Fprintf(os.Stderr, " child stdout (first 200B): %q\n", snip)
}
}
if !includeLog {
res.Log = nil
}
out <- &res
_ = os.RemoveAll(dir)
}
}
func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap, days int, followers []string) (*plugin.SimResult, error) {
runner, err := plugin.NewSimRunner(dataDir)
if err != nil {
return nil, fmt.Errorf("init runner: %w", err)
@@ -150,7 +283,32 @@ func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zon
return nil, fmt.Errorf("build character: %w", err)
}
runner.Euro.Credit(uid, bank, "expedition-sim bankroll")
return runner.RunExpedition(uid, zone, cap)
// Followers share the leader's level — a party is not a carry (the tier
// gate refuses an under-levelled invitee anyway) — and each buys their own
// loadout, so each needs their own bankroll.
var members []id.UserID
for i, fc := range followers {
muid := followerUserID(uid, i)
if _, err := runner.BuildCharacter(muid, plugin.DnDClass(fc), level); err != nil {
return nil, fmt.Errorf("build follower %d (%s): %w", i+1, fc, err)
}
runner.Euro.Credit(muid, bank, "expedition-sim bankroll")
members = append(members, muid)
}
return runner.RunPartyExpedition(uid, members, zone, cap, days)
}
// followerUserID derives a follower's Matrix id from the leader's. It must keep
// the ":" — ResolveUser treats a colon as "this is already a full mxid" and
// short-circuits the room-membership lookup the sim has no client for.
func followerUserID(leader id.UserID, i int) id.UserID {
s := string(leader)
local, server, ok := strings.Cut(strings.TrimPrefix(s, "@"), ":")
if !ok {
local, server = strings.TrimPrefix(s, "@"), "sim"
}
return id.UserID(fmt.Sprintf("@%s-m%d:%s", local, i+1, server))
}
func emitIndented(res *plugin.SimResult) {

View File

@@ -129,7 +129,7 @@ var (
reSpaces = regexp.MustCompile(`\s+`)
reOrphanMustMake = regexp.MustCompile(`(?i)\s+must\s+(?:make|succeed on|attempt)([.,;])`)
reOrphanTrailingAnd = regexp.MustCompile(`(?i),?\s+and\.`)
descScrub = []*regexp.Regexp{
descScrub = []*regexp.Regexp{
// Range / distance jargon.
regexp.MustCompile(`(?i)\s*\bwithin range\b`),
regexp.MustCompile(`(?i)\s*\bwithin \d+ feet(?: of [a-z ]+?)?`),

View File

@@ -93,11 +93,11 @@ func TestCleanDescRepairsOrphans(t *testing.T) {
// TestStripNameParenthetical — bestiary R21 / magic-item alias names.
func TestStripNameParenthetical(t *testing.T) {
cases := map[string]string{
"Giant Rat (Diseased)": "Giant Rat",
"Deep Gnome (Svirfneblin)": "Deep Gnome",
"Giant Rat (Diseased)": "Giant Rat",
"Deep Gnome (Svirfneblin)": "Deep Gnome",
"Stone of Good Luck (Luckstone)": "Stone of Good Luck",
"Ordinary Name": "Ordinary Name",
"": "",
"Ordinary Name": "Ordinary Name",
"": "",
}
for in, want := range cases {
if got := stripNameParenthetical(in); got != want {

View File

@@ -88,18 +88,18 @@ func must(err error) {
// open5eSpell is the subset of the Open5e v1 spell schema we consume. Unknown
// fields are ignored; the vendored JSON keeps the full payload.
type open5eSpell struct {
Slug string `json:"slug"`
Name string `json:"name"`
Desc string `json:"desc"`
HigherLevel string `json:"higher_level"`
Range string `json:"range"`
Material string `json:"material"`
CanBeCastAsRitual bool `json:"can_be_cast_as_ritual"`
Duration string `json:"duration"`
RequiresConcentration bool `json:"requires_concentration"`
CastingTime string `json:"casting_time"`
LevelInt int `json:"level_int"`
School string `json:"school"`
Slug string `json:"slug"`
Name string `json:"name"`
Desc string `json:"desc"`
HigherLevel string `json:"higher_level"`
Range string `json:"range"`
Material string `json:"material"`
CanBeCastAsRitual bool `json:"can_be_cast_as_ritual"`
Duration string `json:"duration"`
RequiresConcentration bool `json:"requires_concentration"`
CastingTime string `json:"casting_time"`
LevelInt int `json:"level_int"`
School string `json:"school"`
// SpellLists is the structured class list, but the SRD dump leaves it
// incomplete (no paladin entries at all). DndClass is the free-text
// "Druid, Wizard" field and is the more complete source — mapClasses

24
go.mod
View File

@@ -11,37 +11,39 @@ require (
github.com/joho/godotenv v1.5.1
github.com/olebedev/when v1.1.0
github.com/robfig/cron/v3 v3.0.1
github.com/rs/zerolog v1.35.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/image v0.40.0
maunium.net/go/mautrix v0.28.0
modernc.org/sqlite v1.50.1
maunium.net/go/mautrix v0.28.1
modernc.org/sqlite v1.53.0
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/AlekSi/pointer v1.0.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/coder/websocket v1.8.15 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.44 // indirect
github.com/mattn/go-sqlite3 v1.14.45 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/zerolog v1.35.1 // indirect
github.com/tidwall/gjson v1.19.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
go.mau.fi/util v0.9.9 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
modernc.org/libc v1.72.3 // indirect
go.mau.fi/util v0.9.10 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

67
go.sum
View File

@@ -10,6 +10,8 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/chehsunliu/poker v0.1.0 h1:OeB4O+QROhA/DiXUhBBlkgbzCx0ZVWMpWgKNu+PX9vI=
github.com/chehsunliu/poker v0.1.0/go.mod h1:V6K4yyDbafp0k6lUnYbwoTS/KsHSB1EWiJdEk54uB1w=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -36,8 +38,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/notnil/joker v0.0.0-20180219043703-3f2f69a75914 h1:xXPuFr3PVM4p6Vw3j0CP29oWYRVKO3cPZjR6D7BxggQ=
@@ -73,18 +75,18 @@ github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhso
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE=
go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY=
go.mau.fi/util v0.9.10 h1:wzvz5iDHyqDXB8vgisD4d3SzucLXNM3iNY+1O1RoHtg=
go.mau.fi/util v0.9.10/go.mod h1:YQOxySn+ZE3qSYqNxvyX7Yi3suA8YK17PS6QqBREW7A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -92,8 +94,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -103,8 +105,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -112,8 +114,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -126,8 +128,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -146,37 +148,38 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ=
maunium.net/go/mautrix v0.28.0/go.mod h1:/a9A7LGaqb9B3nho4tLd28n0EPcCdwpm2dxkxkLLgh0=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
maunium.net/go/mautrix v0.28.1 h1:Hic3oDMPbLbQu1fhboTRAKZcORMjzzkjxsa+SGk60b0=
maunium.net/go/mautrix v0.28.1/go.mod h1:mWXQNmOlrq4VTDU9f1HO03BSIswdUIyyY4wUKHqwzzY=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -185,8 +188,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -0,0 +1,280 @@
# Long Expeditions — multi-session plan
> Goal: dungeon expeditions become real multi-day journeys (T1 ≈ 2 days → T5 ≈ 7 days). Player picks **camp supplies** at launch, then watches it play out. Camp pitch/break, elite engagement, and **boss engagement** all become autonomous. Foreground `!camp` / `!fight` survive as overrides, not the expected path.
## Status @ 2026-05-28
**Core track (D1D7) — DONE.** All phases shipped; v1 of the long-expedition mechanics is live on `long-expeditions-d1`.
**§6 decisions (2026-05-28) re-opened two threads** that ride after D8:
- **D9** — T1T3 room-count bump toward the 35× target band (D1 landed T1T3 at ~2×). Sim-first: read T1/T2 day-counts from a fresh corpus *after* D8 lands.
- **D10** — T4/T5 anchor-room variety (2nd elite + 2nd trap mid-zone). Bundle with D9 to avoid touching graphs twice.
**Active work — §8 (J3 caster sim-picker):** D8-a / D8-b / D8-prereq / D8-d-diagnostic shipped; **D8-c (concentration), D8-d-fix (AC floor lift), D8-e (martial regression triage)** queued. D8 is sequentially the gate before D9/D10 — room counts only make sense once the sim accurately scores the classes that will walk through them.
**Sequence:** D8-c → D8-d-fix → D8-e → D9 → D10. Re-baseline once at the end.
## 1. Why the current shape is "too short"
| Tier | Zones | Rooms | Real-time exit |
|------|-------|------|----------------|
| T1 | Goblin Warrens, Crypt Valdris | 67 | usually <1 calendar day |
| T2 | Forest Shadows, Sunken Temple | 78 | usually 1 day |
| T3 | Manor Blackspire, Underforge | 79 | 12 days |
| T4 | Underdark, Feywild Crossing | 810 (×regions) | 23 days |
| T5 | Dragon's Lair, Abyss Portal | 910 (×regions) | 24 days |
(Room counts from `internal/plugin/dnd_zone.go:70-134`; tier zones from memory `project_expedition_difficulty`.)
Because the autopilot walks ~3 rooms / 2h (`expedition_autorun.go:43,53`) and boss/elite stops are manual, a player who pays attention clears T1T3 in an evening. Briefing/recap UTC ticks fire but rarely matter — the expedition is over before "day 2" ever lands.
## 2. Target shape
| Tier | Target duration | New room budget (single-region) | Notes |
|------|-----------------|---------------------------------|-------|
| T1 | 2 days | 1214 | one autopilot-camp |
| T2 | 3 days | 1620 | two camps |
| T3 | 4 days | 2226 | three camps; threat starts to bite |
| T4 | 56 days | 2834 (split across 34 regions) | base camp emerges |
| T5 | 7 days | 3644 (split across 34 regions) | full siege/temporal pressure |
(Numbers are first-draft anchors; class-balance sim drives the actual dial in D7.)
Duration is measured in **expedition-days advanced by autopilot camp**, not real-time UTC days. Each autopilot night-camp = day++. Real-time UTC still drives the morning briefing DM cadence, but day-count and supply burn become event-driven (camp = sleep = day++ = burn).
The autopilot decides:
- when to camp (and which camp type)
- whether to engage elites and bosses, or to camp first and retry
- whether to extract early on starvation/HP collapse
The player decides:
- supply pack purchases at launch (only meaningful pre-expedition choice)
- whether to override (`!camp <kind>`, `!fight`, `!expedition extract`)
## 3. Phasing
Built across multiple sessions; each phase ships independently with tests and a small DM-side feedback loop.
### D1 — Length + room-count rework
**Files:** `dnd_zone.go` (per-zone Min/MaxRooms), `dnd_expedition_region.go` (multi-region region sizes), `dnd_zone_run.go:generateRoomSequence` (still terminates Entry → … → Elite → Boss, but with more Explorations).
**Work:**
- Re-pitch Min/MaxRooms per zone to the table in §2.
- Multi-region zones: extend per-region room budgets so the total stays in band when summed.
- Keep the fixed Entry / Trap / Elite / Boss anchors; only Exploration count scales.
- Consider 2 traps + 2 elites for T4T5 to break up the long middle (deferred decision — see §6).
- Update existing zone graphs in `zone_graph_*.go` for the new lengths. *Auditing whether each zone has a hand-tuned graph or relies on the linear compiler is part of D1 scoping.*
**Exit criteria:** `expedition-sim` shows mean-room-cleared per zone in the new band; no test fixtures hard-code old counts.
### D2 — Autopilot camp scheduler
**D2-a (shipped 2026-05-27):** new `expedition_autocamp.go` with pure `decideAutopilotCamp` + `pitchAutopilotCamp` + dwell-window lifecycle (`shouldSkipAutoRunForCamp`, `breakAutoCampIfDue`). Wired into `tryAutoRun`: skip while inside `minAutoCampDwell` (4h), break + walk past it, post-walk scheduler pitches HP-low rest camps and region-boss base-camp waypoints. CampState gained `AutoPitched` so auto- vs player-pitched lifetimes diverge. Day rollover is still UTC-anchored; D2-b moves it event-anchored.
**D2-b (shipped 2026-05-27):** event-anchored day rollover. `dnd_expedition_cycle.go` adds `eventAnchoredCutoff` + `isEventAnchored`, splits the briefing body into `nightRolloverBurn` + `nightRolloverDrift` (+ a convenience `processNightCamp` that runs both back-to-back). For expeditions started ≥ cutoff, `deliverBriefing` skips mutators and posts a re-engagement DM; if `last_briefing_at` is older than `nightSafetyNet` (28h) it force-fires `processNightCamp` so a stalled autopilot doesn't freeze the expedition. The autopilot scheduler grew a `Night` flag — `decideAutopilotCamp` sets it when `time.Since(LastBriefingAt) ≥ nightCampWindow` (16h); `pitchAutopilotCamp` then runs the burn → camp → rest → drift sequence in one pitch. Manual `!camp <type>` runs the same flow when it's the first camp since the last rollover. Legacy UTC-anchored expeditions (start_date < cutoff) keep the original mutator flow via the same staged helpers, preserving rest-before-drift ordering through `processOvernightCamp`. Tests fence cutoff to year 9999 in `TestMain` so existing legacy assertions stand; new tests exercise the night decision, night-pitch rollover, event-anchored skip, and safety-net force.
**Files:** new `expedition_autocamp.go`; hooks in `expedition_autorun.go:tryAutoRun`; reuse `dnd_expedition_camp.go:campPitch` / `applyCampRest`.
**Heuristics (in priority order):**
1. **Day-budget pacing** — pitch when rooms-walked-this-day ≥ tier-specific day-room target. Standard if room cleared, rough if not.
2. **Resource gates** — pitch if HP < 35% of max, or supplies projected to cover <1 more day's burn (`exp.Supplies.Current < exp.Supplies.DailyBurn * harshMod`).
3. **Post-elite breath** — auto-pitch a standard camp in the cleared elite room.
4. **Region boss cleared in multi-region zone** — auto-pitch base camp at first eligible site.
5. **Don't pitch when** mid-fight, in a trap/boss room, in a fork-pending state, or already camped.
Auto-break already happens on move (`autoBreakCampOnMove` in `dnd_expedition_camp.go:401`). Autopilot just needs to *not* break a freshly-pitched camp by immediately walking on — gate by `time.Since(camp.EstablishedAt) > minCampDwell` (15min real-time? or simply: don't auto-walk while camped).
**Day-rollover semantics change (decided event-anchored):** today, day++ happens at the 06:00 UTC briefing (`dnd_expedition_cycle.go:236`). After D2, the autopilot **night-camp pitch** is the event that fires day++, supply burn, overnight rest, and threat drift — in one atomic rollover the camp scheduler triggers. The 06:00 UTC briefing tick survives only as a re-engagement DM anchor (and a safety net: if for some reason no camp pitched in N hours, force one). Concretely:
- Move the body of `deliverBriefing` (`dnd_expedition_cycle.go:186-`) into a `processNightCamp(exp)` helper called from the autopilot camp scheduler when it pitches a *night* camp (rough/standard/fortified — not mid-day breath stops).
- Distinguish "night camp" (advances day) from "rest stop" (doesn't). Heuristic: a camp pitched at the end of the day-budget room count is night; one pitched purely for HP/SU recovery mid-day is a rest stop. Both apply `applyCampRest`; only night also runs supply burn + day++ + threat drift.
- The existing UTC briefing ticker becomes: if `last_briefing_at` is older than ~20h *and* the user is active, post a "Day N — here's where you stand" re-engagement DM. It calls **no** mutators.
- Manual `!camp <type>` from the player still does what it does today (apply rest immediately) but **also** counts as a night camp if invoked with no other camp today — keep parity with the autopilot behavior so the override doesn't accidentally stall day advancement.
**Migration:** in-flight expeditions at deploy time keep their existing 06:00 UTC day++ until they end (fence by `start_date`). New expeditions use the event-anchored model.
**Exit criteria:** sim shows autopilot pitches the right kind of camp at the right room with no player input; supply economy still survives a 7-day T5.
### D3 — Autonomous elite + boss engagement
**Files:** `dnd_zone_cmd.go:497-535` (the prev==RoomElite / RoomBoss branch).
**Shipped 2026-05-27.** `dnd_zone_cmd.go` adds `stopBossSafety` + `bossSafetyGate(uid, exp)` (HP < 80%, supplies < daily burn, exhaustion ≥ 3 — the "active low status" interpretation). The elite/boss doorway branch drops the `prev == RoomBoss || !compact` carve-out: in compact mode bosses fall through to the same `resolveCombatRoom` path elites have used, after the gate clears. `resolveCombatRoom` selects monster + label + loot-drop by `run.CurrentRoomType()` so the same call site handles boss kills (zone.Boss bestiary, "👑 Boss — name down", boss-loot drop, elite-tier threat bump). Loss on auto-resolved boss falls through to the existing player-death narration / forceExtractExpeditionForRunLoss path — no special treatment.
The walk loop (`dnd_expedition_cmd.go:717`) only breaks at the elite/boss doorway when `!compact`; compact lets the next iteration auto-resolve. `stopBossSafety` is excluded from the rooms-walked tally and its `autopilotFooter` returns "" — `res.final` already carries the held-back line.
When the gate trips, `tryAutoRun` calls `pitchBossSafetyCamp(exp)` (new in `expedition_autocamp.go`): force-pitches Standard (or Rough fallback) regardless of `decideAutopilotCamp`'s HP threshold or its RoomBoss room-type block, with the same `Night` event-anchored rollover handling. Dwell expires → next tick retries the boss. Foreground `!fight` and foreground `!expedition run` are unchanged: both still stop at the boss doorway for the player who wants to watch. Sim path is unaffected — `stopBossSafety` falls into `expedition_sim.go`'s default branch (tick-a-day, retry next walk).
**Open question:** do we want a "set autopilot off" toggle for players who actively want to play the boss themselves? My take: default-on, single-flag per expedition (`autopilot_engage`) settable at start or via `!expedition autopilot off`. Defer until D3 lands and we see how it reads.
**Exit criteria:** sim runs a full T5 expedition launch → boss kill with zero player commands besides `!expedition start`.
### D4 — DM volume + day surfaces
The big risk: a 7-day T5 with autopilot walking, camping, fighting elites, and killing bosses will spam DMs unless we bundle. The user has already pushed back on recap chatter (`feedback_skip_recaps`).
**D4-a (shipped 2026-05-27):** autopilot DM bundling. `expedition_autorun.go:tryAutoRun` now drops per-tick auto-walk DMs in compact mode. New surface rules:
- Fork / death / run-complete still DM the walk stream — interactive + climax beats.
- A Night=true camp pitch flushes the day as an end-of-day digest (`renderEndOfDayDigest` in new `expedition_autorun_digest.go`) followed by the camp block.
- Boss-safety camp pitch gets a short "holding before the boss" header + camp block (walk stream dropped).
- Non-night auto-camp (mid-day rest / base-camp waypoint) surfaces the camp block by itself.
- Everything else — uneventful walks, preflight pauses, harvest interrupts — goes silent.
Each successful background walk now logs a `walk` entry (`appendExpeditionLog(... "walk" ...)`) so the digest can count rooms walked from structured logs (the raw `r.stream` narration is no longer persisted across ticks). Digest groups counts of walk/harvest/interrupt and inlines threat/milestone/narrative lines for the prior day (`dayExpeditionLog` helper). `maybeAutoCamp` + `pitchBossSafetyCamp` now return the `autoCampDecision` so `tryAutoRun` can branch on `dec.Night`.
**D4-b (shipped 2026-05-27):** morning re-engagement DM anchored to user activity, not 06:00 UTC. `fireExpeditionBriefings` now skips event-anchored expeditions whose `last_activity` is older than today's 06:00 UTC threshold (and we're still inside `nightSafetyNet` — stalled-autopilot force-fires still win). New `maybeDeliverDeferredBriefing` runs at the top of `AdventurePlugin.OnMessage` on every inbound message in any room: it stamps `last_activity` (so the next ticker pass sees the player as present, even from non-bot chatter) and posts the deferred briefing if one is owed (CAS-gated by today's 06:00 threshold; pre-06:00 lazy fires are suppressed to avoid double-emit with the ticker sweep that follows). Day-1 inbounds don't trigger a briefing before the first night camp has happened.
**Remaining work:**
- TwinBee voice + no-recap copy pass on the digest once the shape settles.
**Exit criteria:** a 7-day T5 produces ≤ 10 DMs across the whole expedition (1 launch + 6 end-of-day + 1 boss + 1 run-complete + 1 emergency).
### D5 — Supplies economics retune
**D5-a (shipped 2026-05-27):** per-tier pack caps. `dnd_expedition_supplies.go` retires the global `SupplyPackStandardMax`/`SupplyPackDeluxeMax` constants in favor of `supplyPackCaps(tier) (std, dlx int)` — T1/T2: (2,1); T3: (3,1) unchanged; T4: (5,1); T5: (7,2). `SupplyPurchase.Validate` is now `Validate(tier ZoneTier)`; both call sites (`!expedition start`, `!resume`) pass the resolved zone's tier. The cap clears `DailyBurn(raw) × intendedDays × 1.3` for every tier under the §2 target durations even with harsh×3 layered on top, so a player who wants to buy for the long shape can. DailyBurn / harsh-multiplier / `phase5BDailyBurnRatePct` are unchanged here — they're a D7 lever, alongside the sim work that should unblock empirical baselining. The help text now describes the cap as "scales by zone tier"; the holiday +1 standard pack still bypasses the cap on purpose (it's a freebie on top).
> **Note:** the original "Empirical (sim-driven)" path was blocked: under D2-b event-anchored expeditions, `SimRunner.TickDay` calls `deliverBriefing` → `deliverBriefingEventAnchored`, which reads `time.Now().UTC()` for its safety-net check, so synthetic ticks never advance `CurrentDay` and `DaysAtEnd` stays at 0. Re-baselining off real day-counts requires teaching the sim to drive the event-anchored rollover (D7).
**D5-b (shipped 2026-05-27):** "Pick your loadout" preset prompt at launch. `!expedition start <zone>` with no pack arg now DMs a Lean / Balanced / Heavy menu (sized per zone tier via new `loadoutPurchase(tier, SupplyLoadout)` in `dnd_expedition_supplies.go`); player replies with `!expedition start <zone> lean|balanced|heavy` (short forms `l`/`b`/`h` also work). `!resume` got the same treatment. Raw `Ns Md` syntax remains the advanced override — `resolveLoadoutOrParse` in `dnd_expedition_cmd.go` tries a single-token preset first, falls back to `parseSupplyArgs`. Heavy always equals `supplyPackCaps` (the D5-a harsh×3 ceiling); Lean covers intended days at raw burn; Balanced sits between. `parseSupplyArgs("")` still returns 1 standard for any tier — it's no longer reachable from `!expedition start` (the empty path is intercepted to prompt), but the `1s` default is preserved so future callers don't get surprise zero-pack purchases. Help text updated; two existing scenario tests now pass `lean` explicitly, three new tests cover preset resolution + the prompt-vs-start guard.
**D5-c (shipped 2026-05-27):** Ranger forage wired against the new caps. Audit found `SupplyForageMaxSU = 4` defined in `dnd_expedition_supplies.go` since Phase 12 E1b but never referenced — `ForagedToday` was only ever reset to `false`, never set to `true`, so the §4.2 "Ranger, WIS DC 12, 1d4 SU/day" perk had never actually granted supplies. New helper `applyRangerForage(e, c, rng)` rolls 1d4 SU once per day for Ranger characters, headroom-capped against `Supplies.Max` so a Heavy loadout doesn't overflow its purchased ceiling; non-Rangers and post-roll repeats are no-ops. Fires at the top of `nightRolloverBurn` (before the daily burn) so the +SU lands on today's bag and a "forage" log entry flows into the end-of-day digest via a new `renderEndOfDayDigest` case. No DC roll — accessibility over crunch (`feedback_accessibility_over_dnd_crunch`); the DC-12 gate would've added a fail case with no upside given the new headroom. Average +2.5 SU/day off a Ranger is a quiet Lean-loadout cushion (~1 extra day of T5 late-stage burn over a 7-day run), not a Heavy multiplier. Tests cover ranger-grants, non-ranger no-op, repeat-day no-op, headroom-cap, full-bag-still-stamps-flag, nil-safety.
**Remaining work (D5-d+):**
- DailyBurn / `phase5BDailyBurnRatePct` revisit once D7 sim measures real elapsed-day counts.
**Exit criteria:** balanced loadout completes intended-duration expedition in ≥ 85% of sim runs without starvation extraction.
### D6 — Player-facing surface cleanup
**D6 (shipped 2026-05-27):** player-facing copy now matches the autopilot-first reality.
- `expeditionHelpText` rewritten around the loop "pick a zone → pick a loadout → `!expedition run` watches it play out"; commands grouped into **Run an expedition** / **Mid-expedition** / **Overrides** with a one-line shape paragraph at the top.
- `!camp` and `!fight` were already absent from the primary `!expedition` help; both are now explicitly listed under **Overrides** and framed as "autopilot covers these — only reach for them if you want manual control." `campHelpText` itself opens with the same override framing so a player who types `!camp` lands on the right mental model.
- `expeditionCmdStatusImpl` (default `!expedition status`) now leads with **Day X / ~Y expected** (driven by new `expeditionTargetDays(tier ZoneTier)` in `dnd_expedition_supplies.go` — T1=2 → T5=7, the §2 anchor table), adds a **Rooms: N / Total in this region** line under the region marker (sourced from `getActiveZoneRun(...).CurrentRoom/TotalRooms`), and appends a **Recent:** block with the last 3 log entries (summary fallback to flavor). Supplies and threat lines unchanged; the `--debug` view is untouched.
- No new public command surface, no schema change, no test churn (no existing test asserted on the rewritten strings); full `go test ./...` green.
### D7 — Sim + class re-baseline
**D7-a (shipped 2026-05-27):** `SimRunner.TickDay` now drives event-anchored expeditions. `expedition_sim.go` short-circuits when `isEventAnchored(exp)` is true: instead of calling `deliverBriefing` (whose `deliverBriefingEventAnchored` branch reads `time.Now().UTC()` and would never trip the safety-net under synthetic ticks), it runs `nightRolloverBurn` → optional `applyCampRest(Standard)` if affordable (Rough fallback, skipped on low-SU) → `nightRolloverDrift(briefAt)`. Mirrors `pitchAutopilotCamp` with `Night=true`. Production paths untouched. New `expedition_sim_test.go` covers (i) 3 ticks advancing day 1→4 with supply burn + stamped `LastBriefingAt`, and (ii) the low-SU branch where the rest is skipped but burn/drift still fire. Unblocks D5-d empirical re-baseline of `phase5BDailyBurnRatePct` and the class corpus re-run.
**D7-b (shipped 2026-05-27):** `SimRunner.RunExpedition` now drives the production camp scheduler under a synthetic clock (`simWalkInterval = autoRunCooldown`, 2h per walk). After every soft-stop walk it calls `maybeAutoCamp(exp, simNow)`; `stopBossSafety` routes to `pitchBossSafetyCamp(exp, simNow)`. The helpers (`applyAutoCamp` / `applyAutoCampBossSafety`) advance `simNow` past `minAutoCampDwell` (4h) and break the auto-pitched camp via `breakAutoCampIfDue` so the next walk proceeds. `maybeAutoCamp`, `pitchAutopilotCamp`, and `pitchBossSafetyCamp` gained a `now time.Time` parameter (the only prod caller, `tryAutoRun`, still passes `time.Now().UTC()`). Effect: HP-low mid-day rests, multi-region base-camp waypoints, Night-camp rollovers, and boss-safety holds all fire under the sim — the D7-a `tickEventAnchoredRollover` shortcut is no longer used by RunExpedition (kept on TickDay for tests + the pre-cutoff legacy path). Coverage in `expedition_sim_test.go`: Night-camp day advance, HP-low mid-day rest, boss-safety force-pitch, no-op when neither trigger fires.
**D7-c (shipped 2026-05-27):** `cmd/expedition-sim` gained a `-days N` flag — when set, `RunExpedition` exits with `Outcome="day_capped"` after the Nth synthetic day rollover; 0 (default) is unbounded and only the existing `-cap` walk safety net applies. `SimResult` gained `DaySnapshots []SimDaySnapshot` ({Day, HPCurrent, HPMax, Supplies, Threat, Rooms}), captured at expedition start (Day 0), after every `applyAutoCamp`/`applyAutoCampBossSafety` Night-camp rollover, and one final end-of-run entry (reads `getExpedition(mostRecentExpeditionID(...))` when the row already extracted so closed runs still close their trajectory). `RunExpedition` signature picked up the `maxDays int` parameter; sole caller is the CLI. Coverage: `TestSimRunner_CaptureDaySnapshot_PopulatesFields` exercises the helper in isolation (HP from the live character row, SU/threat/day from the live expedition, Rooms from `res.Rooms`). Unblocks empirical D5-d retune of `phase5BDailyBurnRatePct` against per-day SU draws.
**D7-d (shipped 2026-05-27):** class corpus re-run + D5-d retune decision. First fixed a sim regression `expedition_sim.go` introduced by D5-b — bare `expedition start <zone>` now returns the loadout prompt instead of outfitting, so the sim was halting before persisting any expedition; harness now passes the `heavy` preset. Corpus: `sim_results/d7d_corpus.jsonl` (n=100 × 10 classes × 5 zones × L10 = 5000 runs, `heavy` preset, `-cap 300`). Analysis: `sim_results/d7d_analyze.py`. Writeup: `sim_results/d7d_findings.md`. **Key reads:** (i) leaderboard mirrors [[project_j2_sim_artifact]] — martials 7882%, casters 2142%, ~40pp cluster gap unchanged by long-expedition mechanics; (ii) bard/cleric trailers **not** relieved by autopilot camp pacing — cleric actually regressed at L10 (21% vs J2b L12 39%) on spell-pool richness, J3-territory; (iii) T3 hits target 4-day duration (median 3), T4 hits 56 days (median 5), T5 hits 5 of 7 (only 21 clears); (iv) **D5-d retune: no change.** `phase5BDailyBurnRatePct=50` + per-tier `DailyBurn` stay as-is — heavy-preset cleared runs end with 7898% SU remaining; supply economy is not a binding constraint and players lose to HP zero, not starvation. New memory entry: [[project_d7d_baseline]].
**Remaining work (D7-e+):** none in the core D7 scope. Two follow-up phases (D9 room-count bump, D10 T4/T5 anchor variety) were opened by the 2026-05-28 §6 resolution and are sequenced after D8. See §9 below.
## 8. J3 caster-picker rework (next session)
**Why split out:** D7-d corpus confirmed the martial/caster gap is class-roster + sim-picker driven, not autopilot/burn/pacing driven. Long-expedition mechanics are settled; the trailer fix lives in the sim picker, not the expedition shell.
**D8-a (shipped 2026-05-27, this session):** quick data-layer cleanup. Bard L1 prepared list gained `thunderwave`; L2 gained `heat_metal`. Cleric L1 gained `inflict_wounds`. `shatter` overlay Classes broadened to mage/bard/sorcerer (overlay was already unioning via `mergeClassList`, so this is defensive). New `simPickSpiritualWeapon` runs before `simPickSpell` in `simPickCombatAction`: cleric with an L2 slot + spiritual_weapon prepared + `sess.Statuses.BuffSpiritProc == 0` opens the fight with it, picking up the existing `spiritWeaponStrike` per-round bonus-action attack. **Measured impact (L10, n=100/zone, heavy preset):** cleric 21.0 → 23.2% (+2.2pp), bard 34.2 → 32.8% (within noise). T3+ wall unchanged — the data fixes are correct but the picker math is what's binding. Files touched: `internal/plugin/dnd_spells_data.go:192`, `internal/plugin/dnd_spells.go:822,881,884`, `internal/plugin/expedition_sim.go:simPickCombatAction + new simPickSpiritualWeapon`. All green.
**D8-b (implemented 2026-05-27, **inert** — see D8-prereq below): sim picker upcasting.** `simPickSpell` (`internal/plugin/expedition_sim.go:933`) now enumerates one candidate per available slot ≥ native for every prepared damage spell, scored via `spellExpectedDamage(sp, slotLevel, c.Level)`. Cantrips contribute a single slot-0 candidate. Tie-break: highest slot first, then highest expDmg. When the winning slot exceeds the spell's native level, the picker returns `"<id> --upcast N"` so `parseCombatCast` upcasts in the engine. Build + tests green.
**Measured impact: nothing.** d8b corpus (`sim_results/d8b_corpus.jsonl`, same matrix as d7d) lands every class within ±1.5pp of the d7d baseline — bard 1.0, cleric 0.4, mage 1.2, sorcerer +1.4, warlock 0.8, druid +0.6, martials ±0.2. Pure noise band. Expected bard +510pp / mage+sorcerer+warlock +28pp never materialized.
**Root cause (discovered post-corpus, this session):** `simPickSpell` is **dead code** in the current sim path. Commit `68ed8e7` ("Long expeditions D3: compact autopilot auto-resolves boss rooms") added a `!compact` gate at `dnd_expedition_cmd.go:810` so compact autopilot inline-resolves boss/elite rooms via `resolveCombatRoom``runZoneCombat``SimulateCombat` instead of returning `stopBoss/stopElite` for `autoResolveCombat` to handle. The sim uses `compact=true` (`expedition_sim.go:433`), so `autoResolveCombat` — the only caller of `simPickCombatAction`/`simPickSpell` — never fires. Verified empirically: a stderr-traced `simPickSpell` produced zero hits across full bard/cleric L10 runs.
This rewrites the picker-era retrospective. **J2's `baseline_j2a_v2_all10.jsonl` (bard 40%, cleric 39%) was measured before D3, with the picker live.** d7d's L10 baseline (bard 34%, cleric 21%) was measured *after* D3, with the picker disconnected — that ~618pp drop is the post-D3 caster regression, not "long-expedition mechanics didn't help casters" as the d7d writeup framed it. D8-a's "+2.2pp cleric" was also noise (1σ ≈ 1.8pp on n=500).
**D8-prereq (SHIPPED 2026-05-28, commit `631764b`):** split the `compact` flag in `runAutopilotWalk` / `advanceOnceWithOpts` into `compact` + `inlineBossCombat`. Production background autorun (`expedition_autorun.go:173`) passes `(true, true)` — unchanged inline auto-resolve. Sim (`expedition_sim.go:432`) passes `(true, false)` so boss/elite doorways return `stopBoss`/`stopElite` after the safety gate; `autoResolveCombat``simPickCombatAction``simPickSpell` / `simPickSpiritualWeapon` now drive boss/elite fights through the turn-based `!fight` engine. D8-b upcasting went live with the rewire. Foreground (`!expedition run`) flow unchanged (compact=false short-circuits).
Also parallelized matrix mode via subprocess workers (`cmd/expedition-sim/main.go`, `-jobs` flag; defaults to NumCPU). Each worker is its own process so the plugin's global SQLite handle isn't a contention point.
**Measured impact (5000-run L10 corpus, `sim_results/d8prereq_corpus.jsonl` — full diff in `sim_results/d8prereq_findings.md`):**
| Class | d7d | d8prereq | Δ |
|----------|-----:|---------:|-------:|
| cleric | 21.0 | 46.8 | +25.8 |
| sorcerer | 29.4 | 52.0 | +22.6 |
| warlock | 35.4 | 55.6 | +20.2 |
| mage | 36.4 | 54.2 | +17.8 |
| druid | 42.2 | 56.6 | +14.4 |
| bard | 34.2 | 46.2 | +12.0 |
| (martials) | ~80 | ~78 | -2 to -4 (noise + T4/T5 turn-engine regression) |
Caster/martial gap closed from ~40pp to ~22pp. T2/T3 zones are now the picker payoff (forest_shadows 75 → 99, manor_blackspire 45 → 75). **T4 caster wall persists** (every caster 0% at underdark); **T5 is universally 0%** including martials. Both require separate diagnostics — picker isn't the binding constraint past T3.
**D8-c (SHIPPED 2026-05-28):** concentration-damage multiplier. `spellExpectedDamage` (`internal/plugin/dnd_class_balance.go:280`) multiplies expected damage by 3 when `sp.Concentration && sp.Effect ∈ {EffectDamageSave, EffectDamageAuto}`. `EffectDamageAttack` excluded by design — single-target attack-roll spells aren't typically concentration; hex-style cases get their lift from mods. Test: `TestSpellExpectedDamage_ConcentrationMultiplier`.
**Measured impact (d8c_corpus.jsonl vs d8prereq, full diff in `sim_results/d8c_findings.md`):** every class within ±2.4pp on the leaderboard (1σ ≈ 2.2pp on n=500) — *macro-leaderboard unchanged*. Picker swap landed where expected at **T3 manor_blackspire**: bard +11pp (32→43), mage +10pp (71→81), sorcerer +9pp (60→69), druid +5pp manor + +1pp underdark. T4/T5 caster wall unchanged (still 0% for every caster except druid 0→1 underdark) — confirms [[project_d8d_diagnostic]]: past T3 the binding constraint is HP+AC, not picker quality.
**Side discovery (engine modelling gap):** cleric stayed flat across all zones despite having spirit_guardians (L3, concentration, save) prepared — and warlock dropped 6pp at manor. Likely the sim's `EffectDamageSave` path resolves the AOE save *once* rather than re-ticking per round while concentration holds, so the multiplier overrates spells the engine then under-delivers on. **Filed as a separate follow-up** (concentration re-tick gap in `SimulateCombat`/turn-engine); does *not* unwind D8-c — bard/mage/sorcerer/druid picker swaps are clean and within plan. Cross-link [[project_concentration_retick_gap]] when written.
**D8-d (DIAGNOSTIC SHIPPED 2026-05-28):** T4 caster wall diagnosed; no code change landed. Writeup: `sim_results/d8d_findings.md`. **Hypothesis disambiguation across cleric/bard/mage/sorcerer/warlock/druid L10 underdark (n=35 each):** (a) multi-region transit damage — REJECTED. All casters TPK in r1 (`underdark_surface_tunnels`) before any cross-region transit; `Combats=0` (the elite/boss session table is empty); deaths are entirely inline `SimulateCombat` mob rooms. (b) T4 attack-bonus vs caster AC — confirmed contributor. `computeAC` floor gives casters AC 1114 vs martial 1617; T4 standard roster (Atk +5 to +7) hits ~6065%. (c) HP scaling — confirmed contributor. Caster HPMax 93110 vs martial 141 (~30% gap). (d) Heal-stock — minor lever, NOT the lever. Direct experiment: bumped `simConsumableBundle` T4 from 2 → 5 Spirit Tonics, re-ran n=5 per class. Median rooms-before-TPK lifted +35 but 0/5 clears across every caster. Reverted. **Recommended tuning lever (next session, NOT shipped here):** lift `computeAC` class floors (cleric/druid 3 → 5, bard/warlock 1 → 3, mage/sorcerer 0 → 2) — single function, easy to revert, doesn't move the martial leaders. Validate against `d8prereq_corpus.jsonl`. Hit-dice rescale is the second option but is bigger surface area.
**D8-e (TODO next session): martial T4/T5 regression triage.** Paladin -18pp T4 / fighter -6pp T4 -5pp T5 / ranger -13pp T5 — the cost of moving boss/elite fights from inline `SimulateCombat` to the turn-based `!fight` engine. Two possibilities: (1) turn-engine plays martials slightly worse than inline-sim (action economy / multiattack handling differs — see [[project_sim_multiattack_gap]]); (2) inline-sim was over-rosy and the new numbers are the real ones. Read: trace one paladin underdark run pre/post and diff damage exchanges. If turn-engine is correct, prod martial numbers were inflated and we should not "fix" the regression.
**Sequence:** D8-c → D8-d → D8-e. D8-c is the smallest lever and confirms the picker model. D8-d is the highest-value (caster T4 is the next gap to close). D8-e is informational — settles whether to trust the new numbers.
**Open questions for D8:**
1. ~~D8-b upcast aggressively or conservatively?~~ **Resolved:** aggressive — the T3 lift (mage +61, warlock +65, druid +68) confirms the model.
2. D8-c concentration-rounds factor: 3 fixed, or scale with tier (longer fights at T4/T5)? Fixed is simpler; tier-scaled is more correct.
3. ~~Cleric T3+ cliff~~**partial resolution:** picker rewire took cleric T3 0% → 39%. Cleric T4 is still 0% (and so is every other caster); now subsumed by D8-d.
## 4. Cross-cutting risks
- **Existing in-flight expeditions** at deploy time. Either fence by `start_date` (old expeditions finish under old rules) or write a one-shot migration that retunes their room counts. Lean: fence — these are typically <2 days.
- **The 24h zone-run inactivity timeout** (`dnd_zone_run.go:254`) — must be raised, or the autopilot must self-poke `last_action_at` per tick. With multi-day expeditions, a quiet stretch overnight is now expected.
- **Idle reaper** (commit `0f72484`, see `expedition_autorun.go` references) — already holds streak on autopilot; verify it tolerates 5-day expeditions.
- **Babysit perk** — `BabysitSafeRest` already upgrades Standard → Fortified at rest (`dnd_expedition_camp.go:241-245`); confirm interaction with autopilot picking Standard (no change needed; the upgrade fires inside `applyCampRest`).
- **Multi-region travel still unwired in autopilot** (memory `project_multiregion_travel_unwired`). For T4/T5 to actually take 57 days, autopilot **must** auto-advance regions on region-clear. D2/D3 covers this implicitly but call it out — likely a small dedicated commit.
- **Pet arrival** (memory `project_pet_event_reachability`) — emergence seam is at run-complete; longer expedition just delays the roll, no code change needed.
- **Twinbee voice + secret NPC buffs** (memories `feedback_twinbee_voice`, `feedback_npc_buffs_are_secret`) — none of the new copy can break these.
## 5. Schema impact
Likely none. Existing columns cover it:
- `current_day` → autopilot increments via `advanceExpeditionDay`.
- `camp_json`, `supplies_json`, `region_state`, `threat_*` already in place.
- One **possible** new column: `expected_days` int (set at launch from zone tier + loadout) so status/end-of-day DMs can render "Day 3/5". Cheaper to derive than store — defer.
## 6. Open questions for next session
1. ~~Day-advance semantics~~**decided 2026-05-27: event-anchored.** day++ fires on autopilot camp-pitch (autopilot night-camp = sleep = day burn + briefing). UTC clock becomes a re-engagement-DM anchor only, not a state mutator. See §3-D2 for implementation impact.
2. ~~Boss autopilot default~~**decided 2026-05-28: default ON.** D3 already ships compact-mode boss auto-resolve gated by `bossSafetyGate` (HP/SU/exhaustion). No opt-out flag for now; revisit if players want manual final-blow framing.
3. ~~Per-tier room counts: sim-first vs ship-and-refine~~**decided 2026-05-28: sim-first refinement in D7-d.** D1 shipped a guess; we're much closer now. Measured vs prior: T1 ~2x (7→16), T2 ~1.8x (9→19), T3 ~2x (11→35), T4 ~3x (10→46), T5 ~3x (13→47). Target band is 35x; T1T3 likely want another bump but defer until the D7-d sim corpus reads day-counts. Don't churn graphs blindly.
4. ~~Extra anchor rooms (T4/T5)~~**decided 2026-05-28: yes, add anchor-room variety in T4/T5.** Second elite + second trap mid-zone (not just rely on multi-region/patrols/threat). Slot into D7-d alongside the room-count retune so we don't re-touch the graphs twice.
5. ~~Mobile/AFK / DM cadence~~**decided in D4-a (already shipped):** event-anchored to in-game day. Night-camp pitch flushes the day as a digest; fork/death/run-complete still DM the walk stream; mid-day rests/waypoints surface only the camp block; per-tick auto-walks silent. No real-time pacing knob.
## 7. Sequence
**Original D1D7 plan (all shipped):** D1 → D2 → D3 → D5 → D4 → D6 → D7.
**Current sequence (post-2026-05-28):** D8-c → D8-d-fix → D8-e → D9 → D10 → final re-baseline.
| Phase | What | Blocks |
|------|------|--------|
| D8-c | Concentration-damage multiplier in `spellExpectedDamage` | D8-d-fix read |
| D8-d-fix | Lift `computeAC` caster floors (cleric/druid 3→5, bard/warlock 1→3, mage/sorcerer 0→2) | D8-e read |
| D8-e | Diff turn-engine vs inline-sim for paladin/fighter/ranger on T4/T5; decide whether martial regression is real or a "fix" target | D9 (need stable class baseline) |
| D9 | T1T3 room-count bump toward 35× band, sim-validated against fresh corpus | D10 |
| D10 | T4/T5 anchor-room variety (2nd elite + 2nd trap mid-zone) | final re-baseline |
| — | One final L10 + L12 corpus pass to confirm tier day-counts + class spread | — |
## 9. Post-§6 follow-ups (D9 / D10)
### D9 — T1T3 room-count bump
**Why now:** §6 fork 3 resolved to "sim-first refinement"; D7-d corpus shows T3 hitting only median 3 days (target 4) and T1/T2 weren't even measured because the corpus only matrixed T3+. T1T3 graphs landed at ~2× prior length in D1; target band is 35×.
**Plan:**
- Pull T1/T2 day-counts from a post-D8 corpus run (matrix needs to include `goblin_warrens`, `crypt_valdris`, `forest_shadows`, `sunken_temple` at L4/L6).
- Lift graph sizes only if median days < tier target (T1=2, T2=3, T3=4). Don't churn graphs that already hit target.
- Reuse the D1-a..e fork/anchor/merge patterns (`internal/plugin/zone_graph_*.go`); preserve existing topology — only add Exploration nodes + the second-anchor slot D10 will fill.
- One-shot bootstrap: existing in-flight expeditions fence by `start_date` per §4 (consistent with D1 deploy).
**Risk:** if D8-d-fix lifts caster T3 clears materially, day-counts move too; measure after D8 stabilizes, not before.
### D10 — T4/T5 anchor-room variety
**Why now:** §6 fork 4 resolved "yes". T4/T5 zones run 3050 rooms with only one Trap + one Elite anchor; the long middle reads same-y. Bundle with D9 so we touch each `zone_graph_*.go` once.
**Plan:**
- Add a second Trap + second Elite anchor mid-zone for all T4/T5 zones (`underdark`, `feywild_crossing`, `dragons_lair`, `abyss_portal`).
- Place the second Elite at the region-boundary spurs (good narrative justification — region-guardian rather than zone-guardian) so multi-region travel finally gets a teeth-y interrupt.
- Place the second Trap on the long Exploration runs of fork branches, not on the merge node (keeps fork-choice loot/risk asymmetric).
- Preserve longest-path length within current band — added anchors replace Exploration nodes, not append.
**Validation:** same final corpus pass as D9. Look for boss-clear rate dipping by <5pp (anchor difficulty is intended, not punishing) and median day-count stable.
### Cross-links picked up here
- [[project_multiregion_travel_unwired]] — boss-clear → next-region auto-advance is wired (verified 2026-05-27); D10's region-boundary elites give it something to interrupt.
- [[project_j3_caster_picker]] / [[project_d8d_diagnostic]] — D8 gates the whole sequence.
- [[project_d8prereq_baseline]] — the corpus to diff against, not d7d.

310
internal/bot/appservice.go Normal file
View File

@@ -0,0 +1,310 @@
package bot
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/crypto/cryptohelper"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// presenceHeartbeatInterval is how often the appservice re-asserts its online
// presence. In masdevice mode the /sync long-poll refreshed presence implicitly;
// appservice mode has no /sync, so we push presence explicitly. Synapse ties
// "online" to recent activity and force-offlines a non-syncing user ~30s after its
// last update (SYNC_ONLINE_TIMEOUT). A slower tick makes the bot FLAP (green right
// after each PUT, then offline until the next) — empirically observed at 60s. So we
// tick well under 30s to hold it continuously green. A hard crash stops the
// heartbeat and Synapse offlines the bot within ~30s on its own.
const presenceHeartbeatInterval = 20 * time.Second
// Waits applied when an inbound event arrives before its megolm session does.
// The short wait covers the common race (keys moments behind the event); only
// after it lapses do we spend an m.room_key_request and wait the long one. The
// budget bounds the whole recovery so a permanently-unreadable event can't pin a
// goroutine forever. Mirrors cryptohelper's own 3s/22s ladder.
const (
initialSessionWait = 3 * time.Second
extendedSessionWait = 22 * time.Second
sessionRecoveryBudget = initialSessionWait + extendedSessionWait + 30*time.Second
)
// cryptoToDeviceTypes are the to-device event types the crypto machine must see
// to establish Olm/Megolm sessions and share/receive room keys. In /sync mode
// the cryptohelper gets these automatically; under the appservice transaction
// model we route each one to mach.HandleToDeviceEvent ourselves.
var cryptoToDeviceTypes = []event.Type{
event.ToDeviceEncrypted,
event.ToDeviceRoomKey,
event.ToDeviceForwardedRoomKey,
event.ToDeviceRoomKeyRequest,
event.ToDeviceRoomKeyWithheld,
event.ToDeviceOrgMatrixRoomKeyWithheld,
event.ToDeviceSecretRequest,
event.ToDeviceSecretSend,
event.ToDeviceDummy,
}
// newAppserviceSession builds the appservice-mode Session: as_token auth, a
// cryptohelper that mints the bot's device via MSC4190, and an EventProcessor
// fed by Synapse's transaction pushes (in place of /sync). The bot is an
// appservice user — Synapse forbids AS users from /sync, so all events, plus the
// E2EE extensions (to-device / device lists / OTK counts), arrive over the
// transaction API instead.
func newAppserviceSession(cfg Config) (*Session, error) {
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
if cfg.UserID == "" {
return nil, fmt.Errorf("BOT_USER_ID is required")
}
if cfg.RegistrationPath == "" {
return nil, fmt.Errorf("AS_REGISTRATION is required in appservice mode")
}
if cfg.HomeserverDomain == "" {
return nil, fmt.Errorf("HOMESERVER_DOMAIN is required in appservice mode (server_name, e.g. parodia.dev)")
}
if !((&appservice.HostConfig{Hostname: cfg.ListenHost, Port: cfg.ListenPort}).IsConfigured()) {
return nil, fmt.Errorf("AS_LISTEN_HOST/AS_LISTEN_PORT must be set in appservice mode")
}
ctx := context.Background()
reg, err := appservice.LoadRegistration(cfg.RegistrationPath)
if err != nil {
return nil, fmt.Errorf("load appservice registration %q: %w", cfg.RegistrationPath, err)
}
// Gate for to-device delivery: handleTransaction only pumps to-device events
// when this is set (appservice/http.go). Force it on regardless of the yaml so
// E2EE key exchange can't silently break on a missing field.
reg.EphemeralEvents = true
as, err := appservice.CreateFull(appservice.CreateOpts{
Registration: reg,
HomeserverDomain: cfg.HomeserverDomain,
HomeserverURL: cfg.Homeserver,
HostConfig: appservice.HostConfig{Hostname: cfg.ListenHost, Port: cfg.ListenPort},
})
if err != nil {
return nil, fmt.Errorf("create appservice: %w", err)
}
// Surface the HTTP listener + transaction logs (default is a silent Nop).
as.Log = zerolog.New(os.Stderr).With().Timestamp().Str("component", "appservice").Logger().
Level(zerolog.InfoLevel)
userID := id.UserID(cfg.UserID)
if as.BotMXID() != userID {
return nil, fmt.Errorf("registration sender_localpart resolves to %s but BOT_USER_ID is %s", as.BotMXID(), userID)
}
client := as.BotClient() // as_token auth + SetAppServiceUserID (?user_id=) assertion
// Assert the device via ?org.matrix.msc3202.device_id= on E2EE requests, and
// satisfy cryptohelper.Init's syncer check: with this set, Init permits a nil
// Syncer (we drive the crypto machine from transactions, not /sync). The param
// is only emitted once DeviceID is non-empty (url.go), so setting it now is a
// no-op for the whoami/device-create calls below; CreateDeviceMSC4190 re-sets it.
client.SetAppServiceDeviceID = true
// Validate the token + identity before we start listening.
whoami, err := client.Whoami(ctx)
if err != nil {
return nil, fmt.Errorf("appservice token validation failed (whoami): %w", err)
}
if whoami.UserID != userID {
return nil, fmt.Errorf("appservice identity mismatch: token resolves to %s but BOT_USER_ID is %s", whoami.UserID, userID)
}
slog.Info("appservice token valid", "user_id", whoami.UserID)
// ---- E2EE via cryptohelper (MSC4190 device creation, no /login) ----
cryptoDBPath := filepath.Join(cfg.DataDir, "crypto.db")
ch, err := cryptohelper.NewCryptoHelper(client, []byte("gogobee_pickle_key"), cryptoDBPath)
if err != nil {
return nil, fmt.Errorf("init crypto helper: %w", err)
}
// MSC4190: the appservice creates/refreshes its own device via PUT /devices
// instead of the UIA-gated login. The crypto store persists the device ID, so
// restarts reuse it. LoginAs carries only the display name (never calls /login
// in MSC4190 mode). client.Syncer is nil here, so Init does NOT wire /sync
// handlers — we drive the crypto machine from transactions below instead.
ch.MSC4190 = true
ch.LoginAs = &mautrix.ReqLogin{InitialDeviceDisplayName: cfg.DisplayName}
if err := ch.Init(ctx); err != nil {
return nil, fmt.Errorf("crypto helper init (MSC4190 device create): %w", err)
}
client.Crypto = ch
// ---- Event processor: replicate the cryptohelper's /sync wiring against
// the appservice transaction channels ----
ep := appservice.NewEventProcessor(as)
mach := ch.Machine()
// Best-effort cross-signing bootstrap so the bot's device shows as verified to
// users who trust its master key. Best-effort: under MAS the key upload may be
// refused, which we log and ignore — E2EE still functions without it.
bootstrapCrossSigning(ctx, mach, cfg.DataDir)
// Crypto plumbing that /sync would otherwise carry:
ep.OnOTK(mach.HandleOTKCounts)
ep.OnDeviceList(mach.HandleDeviceLists)
for _, t := range cryptoToDeviceTypes {
ep.On(t, mach.HandleToDeviceEvent)
}
// Keep the client's StateStore current so outgoing sends know which rooms are
// encrypted and who to share keys with (no /sync backfill here), and let the
// crypto machine track membership for key sharing. PrependHandler so state is
// updated before app handlers (auto-join/moderation) run for the same event.
ep.PrependHandler(event.StateEncryption, func(ctx context.Context, evt *event.Event) {
mautrix.UpdateStateStore(ctx, as.StateStore, evt)
})
ep.PrependHandler(event.StateMember, func(ctx context.Context, evt *event.Event) {
mautrix.UpdateStateStore(ctx, as.StateStore, evt)
mach.HandleMemberEvent(ctx, evt)
})
// Without /sync there is no state backfill, so the client's StateStore starts
// empty. Before we hand off a decrypted event (whose reply may need to be
// encrypted), resolve the room once: mark it encrypted and populate its member
// list. Otherwise outbound sends go plaintext (IsEncrypted=false) and, worse,
// the group session is shared to nobody (GetRoomJoinedOrInvitedMembers empty)
// so recipients can't decrypt the bot's replies.
var resolved sync.Map // roomID -> struct{}, resolved once
resolveRoom := func(ctx context.Context, roomID id.RoomID) {
if _, done := resolved.LoadOrStore(roomID, struct{}{}); done {
return
}
var enc event.EncryptionEventContent
if err := client.StateEvent(ctx, roomID, event.StateEncryption, "", &enc); err == nil && enc.Algorithm != "" {
_ = as.StateStore.SetEncryptionEvent(ctx, roomID, &enc)
}
if members, err := client.Members(ctx, roomID); err == nil {
_ = as.StateStore.ReplaceCachedMembers(ctx, roomID, members.Chunk)
} else {
slog.Warn("appservice: failed to fetch room members; will retry", "room", roomID, "err", err)
resolved.Delete(roomID) // allow a later event to retry
}
}
// recoverSession handles an event whose megolm session we don't have yet. The
// keys are often merely in flight (a to-device m.room_key racing the room
// event), so wait briefly; if they never land, ask the sender to re-share and
// wait longer. Only once that fails is the event genuinely unreadable.
//
// This duplicates cryptohelper.HandleEncrypted's wait/request ladder on
// purpose: that path is gated on a /sync token being present in the context
// (mautrix.SyncTokenContextKey), which appservice transactions never carry, so
// wiring ch.ASEventProcessor would still leave us dropping these events.
//
// Runs detached from the transaction context, which is cancelled as soon as we
// ACK the transaction — long before the keys could arrive.
recoverSession := func(evt *event.Event) {
ctx, cancel := context.WithTimeout(context.Background(), sessionRecoveryBudget)
defer cancel()
content := evt.Content.AsEncrypted()
got := ch.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, initialSessionWait)
if !got {
ch.RequestSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, evt.Sender, content.DeviceID)
got = ch.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, extendedSessionWait)
}
if !got {
slog.Warn("appservice: gave up decrypting event, no room key",
"room", evt.RoomID, "event", evt.ID, "session", content.SessionID)
return
}
decrypted, err := ch.Decrypt(ctx, evt)
if err != nil {
slog.Warn("appservice: failed to decrypt event after key request",
"room", evt.RoomID, "event", evt.ID, "err", err)
return
}
slog.Debug("appservice: recovered event after key request", "room", evt.RoomID, "event", evt.ID)
ep.Dispatch(ctx, decrypted)
}
// Decrypt inbound encrypted room events and re-dispatch the plaintext so the
// normal message/reaction handlers fire (mirrors cryptohelper.HandleEncrypted).
ep.On(event.EventEncrypted, func(ctx context.Context, evt *event.Event) {
resolveRoom(ctx, evt.RoomID)
decrypted, err := ch.Decrypt(ctx, evt)
if err != nil {
if errors.Is(err, cryptohelper.NoSessionFound) {
go recoverSession(evt)
return
}
slog.Warn("appservice: failed to decrypt event", "room", evt.RoomID, "event", evt.ID, "err", err)
return
}
ep.Dispatch(ctx, decrypted)
})
return &Session{
Client: client,
mode: "appservice",
as: as,
ep: ep,
}, nil
}
// runAppservice starts the transaction dispatchers and the HTTP listener, then
// blocks until ctx is cancelled.
func (s *Session) runAppservice(ctx context.Context) error {
s.ep.Start(ctx)
s.as.Ready = true
errCh := make(chan struct{})
go func() {
s.as.Start() // blocks in ListenAndServe until Stop()
close(errCh)
}()
slog.Info("appservice listener started", "address", s.as.Host.Address())
select {
case <-ctx.Done():
return nil
case <-errCh:
return fmt.Errorf("appservice HTTP listener exited unexpectedly")
}
}
// runPresenceHeartbeat keeps the bot's Matrix presence "online" while the
// appservice runs. Without /sync, nothing else refreshes presence, so it would
// otherwise freeze at its last value. On graceful shutdown it best-effort sets
// presence "offline"; on a hard crash the heartbeat simply stops and Synapse
// decays the stale "online" state on its own.
func (s *Session) runPresenceHeartbeat(ctx context.Context) {
setPresence := func(ctx context.Context, presence event.Presence) {
if err := s.Client.SetPresence(ctx, mautrix.ReqPresence{Presence: presence}); err != nil {
slog.Warn("appservice: set presence failed", "presence", presence, "err", err)
}
}
setPresence(ctx, event.PresenceOnline)
ticker := time.NewTicker(presenceHeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
// Detach from the cancelled ctx so the final PUT still goes out.
offCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
setPresence(offCtx, event.PresenceOffline)
cancel()
return
case <-ticker.C:
setPresence(ctx, event.PresenceOnline)
}
}
}

View File

@@ -2,203 +2,174 @@ package bot
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"gogobee/internal/util"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/cryptohelper"
"maunium.net/go/mautrix/id"
)
// DeviceInfo holds persisted device credentials.
type DeviceInfo struct {
AccessToken string `json:"access_token"`
DeviceID string `json:"device_id"`
UserID string `json:"user_id"`
}
// Config holds the bot's startup configuration.
type Config struct {
Homeserver string
UserID string
Password string
UserID string // Bot's full Matrix user ID, e.g. @twinbee:parodia.dev
DataDir string
DisplayName string
// AuthMode selects how the bot connects to Matrix:
// "masdevice" (default) — MAS OAuth 2.0 device grant + /sync (masauth.go).
// "appservice" — Matrix appservice: as_token auth + Synapse→bot
// transaction push (appservice.go). MAS-durable:
// no login, no MFA, no token expiry ever.
// The device-grant path is retained as an instant rollback: flip AUTH_MODE
// back to masdevice and restart, no rebuild.
AuthMode string
// ---- appservice mode only ----
// RegistrationPath points at the appservice registration YAML (id, as_token,
// hs_token, sender_localpart, namespaces). Synapse and the bot share it.
RegistrationPath string
// ListenHost/ListenPort is where the bot's HTTP listener binds to receive
// Synapse's transaction pushes (Synapse reaches it via the registration url).
ListenHost string
ListenPort uint16
// HomeserverDomain is the server_name (e.g. parodia.dev), needed to derive
// the bot's MXID from sender_localpart.
HomeserverDomain string
}
// NewClient creates and configures a mautrix client with E2EE support.
// The cryptohelper handles:
// - Persistent crypto store in SQLite (device keys, sessions, cross-signing keys)
// - Automatic cross-signing bootstrap (self-signs the device on first run)
// - Automatic device trust via cross-signing (no manual verification needed)
// - Megolm session sharing and key exchange
// - Olm session management for device-to-device encryption
// NewClient creates and configures a mautrix client authenticated against
// Matrix Authentication Service (MAS) via the OAuth 2.0 device grant, with
// E2EE via the cryptohelper.
//
// This solves the TS version's device verification issues because:
// 1. Crypto state persists across restarts (not in-memory like fake-indexeddb)
// 2. Cross-signing makes other devices trust this bot automatically
// 3. The bot trusts all users' devices by default (appropriate for a bot)
// 4. No manual emoji/SAS verification needed
// Auth flow (see masauth.go for detail):
// - Discover MAS OAuth endpoints from the homeserver's well-known + OIDC docs.
// - If we have a persisted refresh token, refresh it for a fresh access token.
// - Otherwise run the device-authorization grant: print a URL + code for a
// human to approve ONCE in a browser (as the bot's user), then store the
// resulting access + refresh tokens.
// - A background goroutine refreshes the access token before it expires and
// updates the live client, so the bot runs indefinitely without re-auth.
//
// The bot stays a normal Matrix user (not an appservice), so /sync works — an
// appservice user is forbidden from /sync by Synapse. E2EE is unchanged: the
// cryptohelper persists device keys, olm/megolm sessions and cross-signing in
// its own SQLite store, and the bot trusts all users' devices by default.
func NewClient(cfg Config) (*mautrix.Client, error) {
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
if cfg.UserID == "" {
return nil, fmt.Errorf("BOT_USER_ID is required")
}
devicePath := filepath.Join(cfg.DataDir, "device.json")
ctx := context.Background()
// Try to load existing device credentials
device, err := loadDevice(devicePath)
// ---- MAS OAuth device-grant authentication ----
auth := newMASAuth(cfg.DataDir)
auth.load()
if err := auth.discover(cfg.Homeserver); err != nil {
return nil, fmt.Errorf("MAS discovery: %w", err)
}
if auth.refreshToken != "" {
// Returning start: refresh the stored token.
if err := auth.refresh(); err != nil {
return nil, fmt.Errorf("MAS token refresh failed (delete %s to re-authorize): %w",
filepath.Join(cfg.DataDir, "mas_auth.json"), err)
}
slog.Info("MAS auth: refreshed access token from stored session", "device_id", auth.deviceID)
} else {
// First run: register a client and run the interactive device grant.
if err := auth.ensureClient(cfg.DisplayName); err != nil {
return nil, fmt.Errorf("MAS client registration: %w", err)
}
if err := auth.deviceFlow(ctx); err != nil {
return nil, fmt.Errorf("MAS device authorization: %w", err)
}
}
userID := id.UserID(cfg.UserID)
client, err := mautrix.NewClient(cfg.Homeserver, userID, auth.token())
if err != nil {
slog.Info("no existing device found, will login fresh")
return nil, fmt.Errorf("create client: %w", err)
}
client.DeviceID = id.DeviceID(auth.deviceID)
var client *mautrix.Client
if device != nil {
// Validate existing token
valid, _ := util.IsTokenValid(cfg.Homeserver, device.AccessToken)
if valid {
slog.Info("existing device credentials valid", "device_id", device.DeviceID)
userID := id.UserID(device.UserID)
client, err = mautrix.NewClient(cfg.Homeserver, userID, device.AccessToken)
if err != nil {
return nil, fmt.Errorf("create client with existing token: %w", err)
}
client.DeviceID = id.DeviceID(device.DeviceID)
} else {
slog.Warn("existing device credentials invalid, logging in again")
device = nil
}
// Validate the token + identity before proceeding.
whoami, err := client.Whoami(ctx)
if err != nil {
return nil, fmt.Errorf("token validation failed (whoami): %w", err)
}
if device == nil {
// Fresh login
loginResp, err := util.LoginWithPassword(cfg.Homeserver, cfg.UserID, cfg.Password, cfg.DisplayName)
if err != nil {
return nil, fmt.Errorf("login: %w", err)
}
userID := id.UserID(loginResp.UserID)
client, err = mautrix.NewClient(cfg.Homeserver, userID, loginResp.AccessToken)
if err != nil {
return nil, fmt.Errorf("create client: %w", err)
}
client.DeviceID = id.DeviceID(loginResp.DeviceID)
// Save device info
device = &DeviceInfo{
AccessToken: loginResp.AccessToken,
DeviceID: loginResp.DeviceID,
UserID: loginResp.UserID,
}
if err := saveDevice(devicePath, device); err != nil {
slog.Warn("failed to save device info", "err", err)
}
slog.Info("logged in successfully",
"user_id", loginResp.UserID,
"device_id", loginResp.DeviceID,
)
if whoami.UserID != userID {
return nil, fmt.Errorf("identity mismatch: token resolves to %s but BOT_USER_ID is %s", whoami.UserID, userID)
}
slog.Info("MAS auth: token valid", "user_id", whoami.UserID, "device_id", client.DeviceID)
// Set up E2EE via cryptohelper — stores crypto state in its own SQLite DB,
// separate from the main app database. Unlike the TS version which used an
// in-memory fake-indexeddb store that was lost on restart (causing constant
// re-verification), mautrix-go's cryptohelper persists everything in SQLite:
// device keys, olm/megolm sessions, cross-signing keys, and device trust state.
//
// We pass just the raw file path — the cryptohelper wraps it in a file: URI
// with _txlock=immediate internally (see cryptohelper.go line 82).
// ---- E2EE via cryptohelper ----
// The crypto store persists device keys, olm/megolm sessions, cross-signing
// and device trust in its own SQLite DB. The stored device ID must match the
// one bound to our OAuth token (device: scope); a fresh mas_auth.json is
// paired with a fresh crypto.db.
cryptoDBPath := filepath.Join(cfg.DataDir, "crypto.db")
ch, err := cryptohelper.NewCryptoHelper(client, []byte("gogobee_pickle_key"), cryptoDBPath)
if err != nil {
return nil, fmt.Errorf("init crypto helper: %w", err)
}
// LoginAs enables the cryptohelper to re-login if the token expires,
// and to bootstrap cross-signing on first run. Cross-signing means:
// - The bot's master key signs its own device key
// - Other users/devices that have verified the bot's master key
// will automatically trust this device
// - No interactive emoji/SAS verification needed
ch.LoginAs = &mautrix.ReqLogin{
Type: mautrix.AuthTypePassword,
Identifier: mautrix.UserIdentifier{
Type: mautrix.IdentifierTypeUser,
User: cfg.UserID,
},
Password: cfg.Password,
InitialDeviceDisplayName: cfg.DisplayName,
}
if err := ch.Init(context.Background()); err != nil {
// No LoginAs: we already have a token + device ID; the cryptohelper just
// attaches E2EE to the existing session.
if err := ch.Init(ctx); err != nil {
return nil, fmt.Errorf("crypto helper init: %w", err)
}
// Attach crypto helper to client
client.Crypto = ch
// Bootstrap cross-signing: generate keys, sign own device, sign master key.
// This makes the bot's device show as "verified" to other users.
// Best-effort cross-signing bootstrap (makes the bot's device show as
// verified to users who trust its master key). Not required for E2EE to
// function; under MAS the key upload may be refused, which we ignore.
mach := ch.Machine()
recoveryKey, _, err := mach.GenerateAndUploadCrossSigningKeys(context.Background(), func(ui *mautrix.RespUserInteractive) interface{} {
return map[string]interface{}{
"type": mautrix.AuthTypePassword,
"identifier": map[string]interface{}{
"type": mautrix.IdentifierTypeUser,
"user": cfg.UserID,
},
"password": cfg.Password,
"session": ui.Session,
}
}, "")
if err != nil {
slog.Warn("cross-signing: key upload failed (may already exist)", "err", err)
} else {
slog.Info("cross-signing: keys uploaded", "recovery_key", recoveryKey)
}
bootstrapCrossSigning(ctx, mach, cfg.DataDir)
if err := mach.SignOwnDevice(context.Background(), mach.OwnIdentity()); err != nil {
slog.Warn("cross-signing: sign own device failed", "err", err)
} else {
slog.Info("cross-signing: own device signed")
}
if err := mach.SignOwnMasterKey(context.Background()); err != nil {
slog.Warn("cross-signing: sign master key failed", "err", err)
} else {
slog.Info("cross-signing: master key signed")
}
// ---- Background token refresher ----
// Refresh ~60s before expiry and push the new token into the live client so
// in-flight /sync and API calls keep authenticating.
go refreshLoop(context.Background(), auth, client)
slog.Info("E2EE initialized",
"user_id", client.UserID,
"device_id", client.DeviceID,
"crypto_store", "sqlite-persistent",
"auth", "mas-oauth-device-grant",
)
return client, nil
}
func loadDevice(path string) (*DeviceInfo, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
// refreshLoop keeps the client's access token fresh for the life of the process.
func refreshLoop(ctx context.Context, auth *masAuth, client *mautrix.Client) {
for {
wait := time.Until(auth.expiry()) - 60*time.Second
if wait < 10*time.Second {
wait = 10 * time.Second
}
select {
case <-ctx.Done():
return
case <-time.After(wait):
}
if err := auth.refresh(); err != nil {
slog.Error("MAS token refresh failed; retrying in 30s", "err", err)
select {
case <-ctx.Done():
return
case <-time.After(30 * time.Second):
}
continue
}
client.AccessToken = auth.token()
slog.Debug("MAS token refreshed", "expires_at", auth.expiry().Format(time.RFC3339))
}
var info DeviceInfo
if err := json.Unmarshal(data, &info); err != nil {
return nil, err
}
return &info, nil
}
func saveDevice(path string, info *DeviceInfo) error {
data, err := json.MarshalIndent(info, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}

View File

@@ -0,0 +1,137 @@
package bot
import (
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto"
)
// crossSigningFile is the on-disk store for the bot's cross-signing recovery key
// (data/cross_signing.json), alongside mas_auth.json and crypto.db. The key
// decrypts the private cross-signing keys the bot parks in SSSS, so it is the
// only thing that lets a rebuilt crypto.db re-sign the bot's device instead of
// minting a whole new identity and forcing everyone to re-verify.
//
// It is deliberately NOT logged: the screen wrapper pipes the bot's output
// through tee, which truncates the log on every restart, so a key that only ever
// exists in a log line is a key you lose on the next boot.
const crossSigningFile = "cross_signing.json"
type crossSigningStore struct {
RecoveryKey string `json:"recovery_key"`
}
func crossSigningPath(dataDir string) string {
return filepath.Join(dataDir, crossSigningFile)
}
// loadRecoveryKey returns the stored key, or "" when there is none.
func loadRecoveryKey(dataDir string) string {
data, err := os.ReadFile(crossSigningPath(dataDir))
if err != nil {
return "" // fresh install
}
var s crossSigningStore
if err := json.Unmarshal(data, &s); err != nil {
slog.Warn("cross-signing: corrupt recovery-key store, ignoring", "path", crossSigningPath(dataDir), "err", err)
return ""
}
return s.RecoveryKey
}
func saveRecoveryKey(dataDir, key string) {
data, err := json.MarshalIndent(crossSigningStore{RecoveryKey: key}, "", " ")
if err != nil {
slog.Error("cross-signing: marshal recovery key failed", "err", err)
return
}
path := crossSigningPath(dataDir)
if err := os.WriteFile(path, data, 0o600); err != nil {
slog.Error("cross-signing: could not persist recovery key; a crypto.db rebuild will need a re-verify", "path", path, "err", err)
return
}
slog.Info("cross-signing: recovery key persisted", "path", path)
}
// uiaSession answers a user-interactive-auth challenge with just the session ID.
// Under MAS the key upload is either allowed outright or refused; there is no
// password to offer.
func uiaSession(ui *mautrix.RespUserInteractive) interface{} {
return map[string]interface{}{"session": ui.Session}
}
// bootstrapCrossSigning establishes the bot's cross-signing identity, which is
// what lets clients show it as verified rather than as an unknown device. E2EE
// works without it; only the verification badge depends on it.
//
// It must never mint a second identity by accident. mautrix's
// GenerateAndUploadCrossSigningKeys is unconditional: every call generates a fresh
// master/self/user-signing trio and overwrites the published one. Calling it on
// each start reminted the bot's identity every boot, which is why clients kept
// asking users to re-verify. So generate only when there is no identity to keep,
// or when the operator explicitly asks for a reset.
//
// The private keys live server-side in SSSS, never in crypto.db, and mach.Load
// does not restore them. A bot that keeps its crypto.db stays signed from its
// first signing and needs nothing here. A bot whose crypto.db was rebuilt has a
// brand-new device that only the recovery key can re-sign.
//
// Set CROSS_SIGNING_REGENERATE=1 to deliberately reset the identity (costs one
// re-verify per user, and stores the fresh key). CROSS_SIGNING_RECOVERY_KEY
// imports an existing key into the store, for adopting an identity created before
// the bot persisted its own.
func bootstrapCrossSigning(ctx context.Context, mach *crypto.OlmMachine, dataDir string) {
stored := loadRecoveryKey(dataDir)
if envKey := os.Getenv("CROSS_SIGNING_RECOVERY_KEY"); envKey != "" && envKey != stored {
saveRecoveryKey(dataDir, envKey)
stored = envKey
}
hasKeys, isVerified, err := mach.GetOwnVerificationStatus(ctx)
if err != nil {
slog.Warn("cross-signing: could not determine verification status, leaving identity alone", "err", err)
return
}
regenerate := os.Getenv("CROSS_SIGNING_REGENERATE") != ""
switch {
case regenerate || !hasKeys:
if regenerate && hasKeys {
slog.Warn("cross-signing: CROSS_SIGNING_REGENERATE set — replacing the published identity; every user must verify the bot once more. Unset it after this start.")
}
key, _, err := mach.GenerateAndUploadCrossSigningKeys(ctx, uiaSession, "")
if err != nil {
slog.Warn("cross-signing: key upload failed, bot will show unverified", "err", err)
return
}
saveRecoveryKey(dataDir, key)
if err := mach.SignOwnDevice(ctx, mach.OwnIdentity()); err != nil {
slog.Warn("cross-signing: sign own device failed", "err", err)
}
if err := mach.SignOwnMasterKey(ctx); err != nil {
slog.Warn("cross-signing: sign master key failed", "err", err)
}
slog.Info("cross-signing: identity created and device signed")
case isVerified:
slog.Info("cross-signing: identity already published and this device is signed")
case stored != "":
// Pulls the private keys back out of SSSS, then signs this device and the
// master key with them.
if err := mach.VerifyWithRecoveryKey(ctx, stored); err != nil {
slog.Warn("cross-signing: recovery-key restore failed, bot will show unverified", "err", err)
return
}
slog.Info("cross-signing: device re-signed from the stored recovery key")
default:
slog.Warn("cross-signing: this device is unsigned and no recovery key is stored, so the bot will show unverified (E2EE still works). Set CROSS_SIGNING_REGENERATE=1 once to mint a fresh identity.",
"store", crossSigningPath(dataDir))
}
}

View File

@@ -0,0 +1,56 @@
package bot
import (
"os"
"path/filepath"
"testing"
)
func TestRecoveryKeyRoundTrip(t *testing.T) {
dir := t.TempDir()
if got := loadRecoveryKey(dir); got != "" {
t.Fatalf("fresh install returned %q, want empty", got)
}
const key = "EsTd o49d mMLt 3Uf9 Gjn9 x5fv YE9H wF6n aadC q2D8 Fv7j rQ4c"
saveRecoveryKey(dir, key)
if got := loadRecoveryKey(dir); got != key {
t.Fatalf("loadRecoveryKey() = %q, want %q", got, key)
}
}
// The key is crypto material sitting next to crypto.db; it must not be readable
// by other users on the host.
func TestRecoveryKeyFileIsPrivate(t *testing.T) {
dir := t.TempDir()
saveRecoveryKey(dir, "some-key")
info, err := os.Stat(filepath.Join(dir, crossSigningFile))
if err != nil {
t.Fatalf("stat: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("recovery key file mode = %o, want 600", perm)
}
}
// A corrupt store must degrade to "no key" rather than panicking or returning
// garbage that would be fed to VerifyWithRecoveryKey.
func TestRecoveryKeyCorruptStore(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, crossSigningFile), []byte("{not json"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if got := loadRecoveryKey(dir); got != "" {
t.Fatalf("corrupt store returned %q, want empty", got)
}
}
// Overwriting an adopted key must not leave trailing bytes from the longer value.
func TestRecoveryKeyOverwrite(t *testing.T) {
dir := t.TempDir()
saveRecoveryKey(dir, "a-much-longer-original-recovery-key-value")
saveRecoveryKey(dir, "short")
if got := loadRecoveryKey(dir); got != "short" {
t.Fatalf("loadRecoveryKey() = %q, want %q", got, "short")
}
}

View File

@@ -2,6 +2,8 @@ package bot
import (
"log/slog"
"os"
"strings"
"sync"
"gogobee/internal/plugin"
@@ -9,13 +11,24 @@ import (
// Registry manages plugin registration and event dispatch.
type Registry struct {
mu sync.RWMutex
plugins []plugin.Plugin
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 {

375
internal/bot/masauth.go Normal file
View File

@@ -0,0 +1,375 @@
package bot
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// masAuth performs MAS (Matrix Authentication Service) OAuth 2.0 device-grant
// authentication for the bot and keeps a valid access token available.
//
// Why this exists: MAS replaces Matrix's legacy password login with OAuth2. A
// bot can't do the interactive authorization-code flow, so we use the OAuth 2.0
// Device Authorization Grant (RFC 8628): on first run the bot prints a URL +
// user code, a human approves the login as the bot's user ONCE in a browser,
// and MAS returns an access token + refresh token. Thereafter the bot refreshes
// silently forever — no password, no expiry surprises, and the bot stays a
// normal Matrix user so /sync keeps working (unlike an appservice user, which
// Synapse forbids from /sync).
//
// The refresh token is rotated by MAS on every refresh, so we persist the new
// one each time.
type masAuth struct {
http *http.Client
storePath string
scope string
// discovered OAuth endpoints
deviceEndpoint string
tokenEndpoint string
registrationEndpoint string
mu sync.RWMutex
clientID string
deviceID string
accessToken string
refreshToken string
expiresAt time.Time
}
// masStore is the on-disk persisted state (data/mas_auth.json).
type masStore struct {
ClientID string `json:"client_id"`
DeviceID string `json:"device_id"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt time.Time `json:"expires_at"`
}
const masScopeAPI = "urn:matrix:org.matrix.msc2967.client:api:*"
func newMASAuth(dataDir string) *masAuth {
return &masAuth{
http: &http.Client{Timeout: 30 * time.Second},
storePath: filepath.Join(dataDir, "mas_auth.json"),
}
}
func (m *masAuth) load() {
data, err := os.ReadFile(m.storePath)
if err != nil {
return // fresh install
}
var s masStore
if err := json.Unmarshal(data, &s); err != nil {
slog.Warn("mas_auth: corrupt store, ignoring", "err", err)
return
}
m.clientID = s.ClientID
m.deviceID = s.DeviceID
m.accessToken = s.AccessToken
m.refreshToken = s.RefreshToken
m.expiresAt = s.ExpiresAt
}
func (m *masAuth) save() {
m.mu.RLock()
s := masStore{
ClientID: m.clientID,
DeviceID: m.deviceID,
AccessToken: m.accessToken,
RefreshToken: m.refreshToken,
ExpiresAt: m.expiresAt,
}
m.mu.RUnlock()
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
slog.Error("mas_auth: marshal failed", "err", err)
return
}
if err := os.WriteFile(m.storePath, data, 0o600); err != nil {
slog.Error("mas_auth: write failed", "err", err)
}
}
func (m *masAuth) token() string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.accessToken
}
// discover resolves the MAS OAuth endpoints from the homeserver's well-known
// document and the issuer's OIDC discovery document.
func (m *masAuth) discover(homeserver string) error {
var wk struct {
Auth struct {
Issuer string `json:"issuer"`
} `json:"org.matrix.msc2965.authentication"`
}
if err := m.getJSON(strings.TrimRight(homeserver, "/")+"/.well-known/matrix/client", &wk); err != nil {
return fmt.Errorf("fetch well-known: %w", err)
}
if wk.Auth.Issuer == "" {
return fmt.Errorf("homeserver does not advertise a MAS issuer (msc2965) — is MAS enabled?")
}
var oidc struct {
DeviceEndpoint string `json:"device_authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
RegistrationEndpoint string `json:"registration_endpoint"`
}
if err := m.getJSON(strings.TrimRight(wk.Auth.Issuer, "/")+"/.well-known/openid-configuration", &oidc); err != nil {
return fmt.Errorf("fetch OIDC discovery: %w", err)
}
if oidc.DeviceEndpoint == "" || oidc.TokenEndpoint == "" {
return fmt.Errorf("MAS issuer %q does not advertise a device/token endpoint", wk.Auth.Issuer)
}
m.deviceEndpoint = oidc.DeviceEndpoint
m.tokenEndpoint = oidc.TokenEndpoint
m.registrationEndpoint = oidc.RegistrationEndpoint
slog.Info("mas_auth: discovered endpoints", "issuer", wk.Auth.Issuer)
return nil
}
// ensureClient registers a public OAuth client via dynamic registration if we
// don't already have a client_id persisted.
func (m *masAuth) ensureClient(displayName string) error {
if m.clientID != "" {
return nil
}
if m.registrationEndpoint == "" {
return fmt.Errorf("no registration endpoint discovered")
}
body := map[string]any{
"client_name": displayName + " (bot)",
"application_type": "native",
"token_endpoint_auth_method": "none",
"grant_types": []string{"urn:ietf:params:oauth:grant-type:device_code", "refresh_token"},
"response_types": []string{},
"client_uri": "https://github.com/prosolis/gogobee",
}
raw, _ := json.Marshal(body)
resp, err := m.http.Post(m.registrationEndpoint, "application/json", strings.NewReader(string(raw)))
if err != nil {
return fmt.Errorf("client registration request: %w", err)
}
defer resp.Body.Close()
rb, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 && resp.StatusCode != 201 {
return fmt.Errorf("client registration failed (HTTP %d): %s", resp.StatusCode, string(rb))
}
var out struct {
ClientID string `json:"client_id"`
}
if err := json.Unmarshal(rb, &out); err != nil || out.ClientID == "" {
return fmt.Errorf("client registration: no client_id in response: %s", string(rb))
}
m.mu.Lock()
m.clientID = out.ClientID
m.mu.Unlock()
m.save()
slog.Info("mas_auth: registered OAuth client", "client_id", out.ClientID)
return nil
}
// deviceFlow runs the interactive device-authorization grant. It blocks until
// the user approves the login (or the code expires).
func (m *masAuth) deviceFlow(ctx context.Context) error {
if m.deviceID == "" {
m.deviceID = randomDeviceID()
}
scope := masScopeAPI + " urn:matrix:org.matrix.msc2967.client:device:" + m.deviceID
form := url.Values{"client_id": {m.clientID}, "scope": {scope}}
var da struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
if err := m.postForm(m.deviceEndpoint, form, &da); err != nil {
return fmt.Errorf("device authorization request: %w", err)
}
// Surface the approval prompt prominently — the operator must act on it.
uri := da.VerificationURI
if da.VerificationURIComplete != "" {
uri = da.VerificationURIComplete
}
banner := fmt.Sprintf(`
================= TwinBee needs you to authorize its login =================
Open this URL in a browser (logged in as the bot's Matrix user):
%s
and enter code: %s
(expires in %d minutes)
============================================================================
`, uri, da.UserCode, da.ExpiresIn/60)
fmt.Print(banner)
slog.Warn("mas_auth: device authorization required", "verification_uri", da.VerificationURI, "user_code", da.UserCode, "expires_in_s", da.ExpiresIn)
interval := da.Interval
if interval <= 0 {
interval = 5
}
deadline := time.Now().Add(time.Duration(da.ExpiresIn) * time.Second)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Duration(interval) * time.Second):
}
pending, err := m.pollToken(url.Values{
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
"device_code": {da.DeviceCode},
"client_id": {m.clientID},
})
if err != nil {
return err
}
if !pending {
slog.Info("mas_auth: device authorization approved", "device_id", m.deviceID)
return nil
}
}
return fmt.Errorf("device authorization timed out (not approved within %d minutes)", da.ExpiresIn/60)
}
// pollToken hits the token endpoint. Returns pending=true if the user hasn't
// approved yet (keep polling); on success it stores the tokens.
func (m *masAuth) pollToken(form url.Values) (pending bool, err error) {
resp, err := m.http.PostForm(m.tokenEndpoint, form)
if err != nil {
return false, fmt.Errorf("token request: %w", err)
}
defer resp.Body.Close()
rb, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 200 {
return false, m.storeTokenResponse(rb)
}
var oerr struct {
Error string `json:"error"`
}
_ = json.Unmarshal(rb, &oerr)
switch oerr.Error {
case "authorization_pending":
return true, nil
case "slow_down":
return true, nil
default:
return false, fmt.Errorf("token endpoint error (HTTP %d): %s", resp.StatusCode, string(rb))
}
}
// refresh exchanges the stored refresh token for a fresh access token. MAS
// rotates the refresh token, so we persist the new one.
func (m *masAuth) refresh() error {
m.mu.RLock()
rt, cid := m.refreshToken, m.clientID
m.mu.RUnlock()
if rt == "" {
return fmt.Errorf("no refresh token")
}
resp, err := m.http.PostForm(m.tokenEndpoint, url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {rt},
"client_id": {cid},
})
if err != nil {
return fmt.Errorf("refresh request: %w", err)
}
defer resp.Body.Close()
rb, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return fmt.Errorf("refresh failed (HTTP %d): %s", resp.StatusCode, string(rb))
}
return m.storeTokenResponse(rb)
}
func (m *masAuth) storeTokenResponse(rb []byte) error {
var tr struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(rb, &tr); err != nil {
return fmt.Errorf("parse token response: %w", err)
}
if tr.AccessToken == "" {
return fmt.Errorf("token response missing access_token: %s", string(rb))
}
m.mu.Lock()
m.accessToken = tr.AccessToken
if tr.RefreshToken != "" {
m.refreshToken = tr.RefreshToken
}
ttl := tr.ExpiresIn
if ttl <= 0 {
ttl = 300
}
m.expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
m.mu.Unlock()
m.save()
return nil
}
func (m *masAuth) expiry() time.Time {
m.mu.RLock()
defer m.mu.RUnlock()
return m.expiresAt
}
// getJSON GETs a URL and decodes JSON.
func (m *masAuth) getJSON(u string, out any) error {
resp, err := m.http.Get(u)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("GET %s: HTTP %d: %s", u, resp.StatusCode, string(b))
}
return json.NewDecoder(resp.Body).Decode(out)
}
// postForm POSTs a form and decodes a JSON success body (200).
func (m *masAuth) postForm(u string, form url.Values, out any) error {
resp, err := m.http.PostForm(u, form)
if err != nil {
return err
}
defer resp.Body.Close()
rb, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return fmt.Errorf("POST %s: HTTP %d: %s", u, resp.StatusCode, string(rb))
}
return json.Unmarshal(rb, out)
}
// randomDeviceID mirrors mautrix's device-id style: 10 uppercase letters.
func randomDeviceID() string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
b := make([]byte, 10)
if _, err := rand.Read(b); err != nil {
// rand.Read essentially never fails; fall back to a fixed prefix.
return "GOGOBEEBOT"
}
for i := range b {
b[i] = alphabet[int(b[i])%len(alphabet)]
}
return "GB" + string(b[:8])
}

140
internal/bot/session.go Normal file
View File

@@ -0,0 +1,140 @@
package bot
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/event"
)
// Session is the bot's live Matrix connection plus its event source. It hides
// the difference between the two transports the bot can run under:
//
// - masdevice: a normal Matrix user authenticated via the MAS OAuth device
// grant, receiving events over /sync (mautrix DefaultSyncer).
// - appservice: a Synapse appservice authenticated by its as_token, receiving
// events pushed over the appservice transaction API (mautrix EventProcessor).
//
// Callers register the same three handlers (message/member/reaction) via
// OnEventType and call Run regardless of mode; the plugin layer never knows which
// transport is in use.
type Session struct {
Client *mautrix.Client
mode string
// masdevice
syncer *mautrix.DefaultSyncer
// appservice
as *appservice.AppService
ep *appservice.EventProcessor
presenceOnce sync.Once
}
// EventHandler matches both DefaultSyncer.OnEventType and EventProcessor.On.
type EventHandler = func(ctx context.Context, evt *event.Event)
// NewSession builds a Session for the configured auth mode. Default and
// "masdevice" use the MAS device grant; "appservice" uses the transaction model.
func NewSession(cfg Config) (*Session, error) {
switch cfg.AuthMode {
case "appservice":
return newAppserviceSession(cfg)
case "", "masdevice":
return newMASDeviceSession(cfg)
default:
return nil, fmt.Errorf("unknown AUTH_MODE %q (want appservice or masdevice)", cfg.AuthMode)
}
}
// newMASDeviceSession wraps the existing device-grant client (NewClient) and its
// DefaultSyncer. This is the retained rollback path — unchanged behaviour.
func newMASDeviceSession(cfg Config) (*Session, error) {
client, err := NewClient(cfg)
if err != nil {
return nil, err
}
return &Session{
Client: client,
mode: "masdevice",
syncer: client.Syncer.(*mautrix.DefaultSyncer),
}, nil
}
// OnEventType registers a handler for the given event type against whichever
// event source backs this session.
func (s *Session) OnEventType(evtType event.Type, fn EventHandler) {
switch s.mode {
case "appservice":
s.ep.On(evtType, fn)
default:
s.syncer.OnEventType(evtType, fn)
}
}
// StartPresence begins actively maintaining the bot's Matrix presence, tied to
// ctx. In appservice mode it launches the online heartbeat (runPresenceHeartbeat);
// masdevice mode relies on the /sync long-poll's implicit presence refresh, so it
// is a no-op there. Call this early — before the slow plugin init — so the bot
// shows online from boot rather than only once the transaction listener starts
// (~2min later). The presence PUT is outbound-only and needs no event handlers, so
// starting it ahead of the listener is safe. Idempotent: extra calls do nothing.
func (s *Session) StartPresence(ctx context.Context) {
if s.mode != "appservice" {
return
}
s.presenceOnce.Do(func() {
go s.runPresenceHeartbeat(ctx)
})
}
// Run blocks, delivering events to registered handlers, until ctx is cancelled.
func (s *Session) Run(ctx context.Context) error {
switch s.mode {
case "appservice":
return s.runAppservice(ctx)
default:
return s.runSync(ctx)
}
}
// Stop halts the event source. Safe to call once.
func (s *Session) Stop() {
switch s.mode {
case "appservice":
if s.ep != nil {
s.ep.Stop()
}
if s.as != nil {
s.as.Stop()
}
default:
s.Client.StopSync()
}
}
// runSync is the device-grant /sync loop (moved verbatim from main.go): restart
// on transient failure, exit on context cancel.
func (s *Session) runSync(ctx context.Context) error {
for {
err := s.Client.SyncWithContext(ctx)
if ctx.Err() != nil {
return nil // shutdown requested
}
if err != nil {
slog.Error("sync stopped, restarting in 5s", "err", err)
} else {
slog.Warn("sync returned without error, restarting in 5s")
}
select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
return nil
}
}
}

View File

@@ -333,6 +333,30 @@ 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 ''`,
// Tempering (gogobee_engagement_plan.md B1). A magic item's rarity
// lives on its registry definition, so an upgraded instance needs
// somewhere of its own to record how far it has been pushed. temper
// counts rarity steps above the definition's base; effective rarity
// is derived at the effect/render/sell boundary, never written back.
// DEFAULT 0 is the correct value for every pre-existing row, so this
// needs no bootstrap backfill.
`ALTER TABLE adventure_inventory ADD COLUMN temper INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE magic_item_equipped ADD COLUMN temper INTEGER NOT NULL DEFAULT 0`,
// Revisit R1 (gogobee_revisit_plan.md §R1). Until now "how far along
// is this run" and "which room am I standing in" were the same number,
// both read off len(visited_nodes). Backtracking splits them: the
// path index stops being monotonic, so effort gets its own counter.
// DEFAULT 0 is wrong for in-flight rows — bootstrapRoomsTraversed
// backfills them from visited_nodes.
`ALTER TABLE dnd_zone_run ADD COLUMN rooms_traversed INTEGER NOT NULL DEFAULT 0`,
// N3/P4 party combat. Every pre-existing fight is solo, and solo is
// exactly roster_size 1, so the DEFAULT is the correct value for every
// row and no bootstrap backfill is needed. The column is a read guard:
// loadCombatParticipants is skipped entirely when it reads 1.
`ALTER TABLE combat_session ADD COLUMN roster_size INTEGER NOT NULL DEFAULT 1`,
}
for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil {
@@ -1124,6 +1148,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())
);
@@ -1496,6 +1521,21 @@ CREATE TABLE IF NOT EXISTS arena_stats (
updated_at INTEGER NOT NULL
);
-- Arena seasons (gogobee_engagement_plan.md C4). Season standings are DERIVED
-- from arena_history.created_at, so arena_stats stays lifetime and no
-- quarterly wipe ever runs. Only the awarded titles are archived here, one row
-- per (season, kind) — the champion for that quarter, frozen.
CREATE TABLE IF NOT EXISTS arena_season_titles (
season TEXT NOT NULL,
kind TEXT NOT NULL, -- 'earnings' | 'streak'
user_id TEXT NOT NULL,
value INTEGER NOT NULL,
awarded_at INTEGER NOT NULL,
PRIMARY KEY (season, kind)
);
CREATE INDEX IF NOT EXISTS idx_arena_season_titles_user ON arena_season_titles(user_id);
CREATE INDEX IF NOT EXISTS idx_arena_history_created ON arena_history(created_at);
-- Rival System
CREATE TABLE IF NOT EXISTS adventure_rival_records (
user_id TEXT NOT NULL,
@@ -1997,6 +2037,24 @@ CREATE TABLE IF NOT EXISTS space_inviter_prompts (
);
CREATE INDEX IF NOT EXISTS idx_space_inviter_user ON space_inviter_prompts(user_id);
-- ── Email nag — collect+verify missing Authentik emails over Matrix DM ─────
-- One row per target user (MXID). Verified state survives restarts so the
-- startup sweep never re-nags someone already done or mid-flow.
CREATE TABLE IF NOT EXISTS email_nag_prompts (
user_id TEXT PRIMARY KEY, -- full MXID
username TEXT NOT NULL, -- Authentik username == Matrix localpart
dm_room_id TEXT NOT NULL,
stage TEXT NOT NULL, -- awaiting_email | awaiting_code | done
pending_email TEXT,
code TEXT,
code_expires INTEGER,
attempts INTEGER NOT NULL DEFAULT 0,
prompt_sent_at INTEGER NOT NULL,
verified_email TEXT,
verified_at INTEGER,
updated_at INTEGER NOT NULL
);
-- ── Turn-based combat — persistent per-fight session ───────────────────────
-- One row per manual elite/boss fight. Persists across bot restarts and
-- player away-from-keyboard so a fight can resume (or be auto-finished by
@@ -2027,12 +2085,85 @@ CREATE TABLE IF NOT EXISTS combat_session (
status TEXT NOT NULL DEFAULT 'active', -- active|won|lost|fled|expired
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_action_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL
expires_at DATETIME NOT NULL,
-- N3/P4: seated player characters, seat 0 (this row) included. 1 == solo,
-- which is what every pre-N3 row is, so the DEFAULT needs no backfill. Read
-- as a guard: only a roster_size > 1 makes the loader touch
-- combat_participant, keeping the solo fight at one query.
roster_size INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_combat_session_active
ON combat_session(user_id, status);
CREATE INDEX IF NOT EXISTS idx_combat_session_expiry
ON combat_session(status, expires_at);
-- ── N3/P4 — party combat: the seats a session row cannot hold ─────────────
-- One row per party member from seat 1 up. Seat 0 is the session's own
-- user_id/player_hp/statuses_json, so a solo fight writes no rows here and
-- reads none: absent == solo, which is why this table needs no backfill.
--
-- seat: index into the combat roster. 1..N-1; seat 0 is the session.
-- hp / hp_max: this member's live pool. Seat 0's lives on combat_session.
-- statuses_json: serialized ActorStatuses — the per-character half of the
-- effect state (their concentration, their debuffs, their
-- once-per-fight one-shots). The enemy's stance and the round
-- cursor are fight-scoped and stay on combat_session.
CREATE TABLE IF NOT EXISTS combat_participant (
session_id TEXT NOT NULL,
seat INTEGER NOT NULL,
user_id TEXT NOT NULL,
hp INTEGER NOT NULL,
hp_max INTEGER NOT NULL,
statuses_json TEXT NOT NULL DEFAULT '{}',
PRIMARY KEY (session_id, seat)
);
CREATE INDEX IF NOT EXISTS idx_combat_participant_user
ON combat_participant(user_id);
-- ── N3/P4 — expedition parties ────────────────────────────────────────────
-- Membership for a co-op expedition. The leader's dnd_expedition row stays
-- the single source of truth for the shared clock, threat, and supply pool;
-- members reference it through here rather than owning a row of their own.
-- A solo expedition has no rows: absent == solo, so no backfill is needed.
--
-- There is deliberately no party_id on dnd_expedition. The expedition_id is
-- already the party's identity, and a second key would be a second source of
-- truth for "who is in this party" that could disagree with this table.
--
-- role: 'leader' | 'member'. Exactly one leader per expedition, matching
-- dnd_expedition.user_id; enforced in code, not by constraint (the
-- same discipline combat_session uses for one-active-per-user).
CREATE TABLE IF NOT EXISTS expedition_party (
expedition_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (expedition_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_expedition_party_user
ON expedition_party(user_id);
-- ── N3/P6b — pending party invites ────────────────────────────────────────
-- An invite the leader has sent and the invitee has not yet answered. It is
-- deleted on accept, on decline, and when the expedition ends; it expires on
-- read past expeditionInviteTTL, so a forgotten invite cannot pin an
-- expedition's autopilot forever.
--
-- Absent == nobody has been asked, which is true of every expedition that
-- existed before N3, so there is nothing to backfill.
--
-- While any row here names an expedition, the autopilot will not walk it: the
-- leader must not be dragged into a boss room while their friend is still
-- reading the invite DM.
CREATE TABLE IF NOT EXISTS expedition_invite (
expedition_id TEXT NOT NULL,
user_id TEXT NOT NULL,
invited_by TEXT NOT NULL,
invited_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (expedition_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_expedition_invite_user
ON expedition_invite(user_id);
`
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
@@ -2041,15 +2172,15 @@ func SeedSchedulerDefaults(d *sql.DB) error {
name string
cron string
}{
{"prefetch", "5 0 * * *"}, // 00:05 daily
{"maintenance", "0 3 * * *"}, // 03:00 daily
{"wotd", "0 8 * * *"}, // 08:00 daily
{"holidays", "0 7 * * *"}, // 07:00 daily
{"releases", "0 9 * * 1"}, // 09:00 Monday
{"birthday_check", "0 6 * * *"}, // 06:00 daily
{"anime_releases", "0 10 * * *"},// 10:00 daily
{"movie_releases", "0 11 * * *"},// 11:00 daily
{"concert_digest", "0 12 * * 0"},// 12:00 Sunday
{"prefetch", "5 0 * * *"}, // 00:05 daily
{"maintenance", "0 3 * * *"}, // 03:00 daily
{"wotd", "0 8 * * *"}, // 08:00 daily
{"holidays", "0 7 * * *"}, // 07:00 daily
{"releases", "0 9 * * 1"}, // 09:00 Monday
{"birthday_check", "0 6 * * *"}, // 06:00 daily
{"anime_releases", "0 10 * * *"}, // 10:00 daily
{"movie_releases", "0 11 * * *"}, // 11:00 daily
{"concert_digest", "0 12 * * 0"}, // 12:00 Sunday
}
stmt, err := d.Prepare(`INSERT OR IGNORE INTO scheduler_config (job_name, cron_expr) VALUES (?, ?)`)

View File

@@ -2,6 +2,7 @@ package plugin
import (
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"strings"
@@ -1137,9 +1138,160 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
return false
},
},
// ── Expeditions ──
// All passive: they read the expedition rows the extract path already
// writes. Tier comes from the zone registry, not a column, so the
// checks map zone_id → Tier in Go.
{
ID: "expedition_clear_t1", Name: "Off the Porch", Description: "Cleared your first Tier 1 zone. It only gets worse from here.",
Emoji: "🗺️",
Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 1) },
},
{
ID: "expedition_clear_t2", Name: "Reasonably Lost", Description: "Cleared a Tier 2 zone. The map stops being a suggestion.",
Emoji: "🗺️",
Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 2) },
},
{
ID: "expedition_clear_t3", Name: "Deep Enough", Description: "Cleared a Tier 3 zone. Someone should have stopped you.",
Emoji: "🗺️",
Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 3) },
},
{
ID: "expedition_clear_t4", Name: "Past the Warnings", Description: "Cleared a Tier 4 zone. The signs were quite clear.",
Emoji: "🗺️",
Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 4) },
},
{
ID: "expedition_clear_t5", Name: "Where the Map Ends", Description: "Cleared a Tier 5 zone. There was nothing left to be brave about.",
Emoji: "🗺️",
Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 5) },
},
{
ID: "expedition_master_t1", Name: "Warren Cartography", Description: "Cleared every Tier 1 zone. Thorough.",
Emoji: "🧭",
Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 1) },
},
{
ID: "expedition_master_t2", Name: "No Stone Unturned", Description: "Cleared every Tier 2 zone.",
Emoji: "🧭",
Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 2) },
},
{
ID: "expedition_master_t3", Name: "Completionist's Limp", Description: "Cleared every Tier 3 zone. Walk it off.",
Emoji: "🧭",
Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 3) },
},
{
ID: "expedition_master_t4", Name: "Nowhere Left to Go Wrong", Description: "Cleared every Tier 4 zone.",
Emoji: "🧭",
Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 4) },
},
{
ID: "expedition_master_t5", Name: "The Long Way Down", Description: "Cleared every Tier 5 zone. Both of them. All the way.",
Emoji: "🧭",
Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 5) },
},
{
ID: "expedition_quiet_clear", Name: "Nobody Saw Anything", Description: "Cleared a zone without threat ever passing 50. You were never here.",
Emoji: "🌑",
Check: clearedUnderThreat50,
},
{
ID: "temper_legendary", Name: "Hot Enough", Description: "Tempered an item all the way to Legendary. The blacksmith needed a moment.",
Emoji: "🔨",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by the temper path on reaching Legendary
return false
},
},
}
}
// ── Expedition achievement helpers ──────────────────────────────────────────
// clearedZoneIDs returns the zones this player has beaten outright. Boss-
// defeated gates out extractions that merely ended in 'complete'.
func clearedZoneIDs(d *sql.DB, userID id.UserID) map[ZoneID]bool {
out := map[ZoneID]bool{}
rows, err := d.Query(
`SELECT DISTINCT zone_id FROM dnd_expedition
WHERE user_id = ? AND status = ? AND boss_defeated = 1`,
string(userID), ExpeditionStatusComplete)
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var z string
if err := rows.Scan(&z); err != nil {
return out
}
out[ZoneID(z)] = true
}
return out
}
func clearedAnyZoneOfTier(d *sql.DB, userID id.UserID, tier ZoneTier) bool {
cleared := clearedZoneIDs(d, userID)
for zid := range cleared {
if z, ok := getZone(zid); ok && z.Tier == tier {
return true
}
}
return false
}
func clearedEveryZoneOfTier(d *sql.DB, userID id.UserID, tier ZoneTier) bool {
cleared := clearedZoneIDs(d, userID)
seen := 0
for _, z := range allZones() {
if z.Tier != tier {
continue
}
if !cleared[z.ID] {
return false
}
seen++
}
return seen > 0
}
// clearedUnderThreat50 looks for a cleared expedition whose peak threat never
// crossed 50 — the same max_threat_seen sample the Patient Zero milestone
// reads, so the two reward the same play.
//
// The key's *presence* is required, not just a low value. recordMaxThreat only
// samples on day rollover and never stores a zero, so an absent key means the
// expedition has no threat history at all (a same-day clear) — which is not
// evidence that threat stayed low.
func clearedUnderThreat50(d *sql.DB, userID id.UserID) bool {
rows, err := d.Query(
`SELECT region_state FROM dnd_expedition
WHERE user_id = ? AND status = ? AND boss_defeated = 1`,
string(userID), ExpeditionStatusComplete)
if err != nil {
return false
}
defer rows.Close()
for rows.Next() {
var raw string
if err := rows.Scan(&raw); err != nil {
return false
}
var state map[string]any
if json.Unmarshal([]byte(raw), &state) != nil {
continue
}
peak, ok := state[regionStateMaxThreatKey].(float64)
if ok && int(peak) < 50 {
return true
}
}
return false
}
// statGTE checks if a user_stats column is >= threshold.
var allowedStatColumns = map[string]bool{
"total_messages": true,

View File

@@ -0,0 +1,184 @@
package plugin
import (
"fmt"
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// expeditionRowSeq gives each synthetic row a unique primary key; two rows can
// otherwise differ only in boss_defeated.
var expeditionRowSeq int
// insertClearedExpedition writes an expedition row directly. status and
// boss_defeated are the two gates the achievement checks read.
func insertClearedExpedition(t *testing.T, user id.UserID, zoneID ZoneID, status string, bossDefeated int, regionState string) {
t.Helper()
expeditionRowSeq++
_, err := db.Get().Exec(
`INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, status, boss_defeated, region_state)
VALUES (?, ?, ?, ?, ?, ?)`,
fmt.Sprintf("exp-%d", expeditionRowSeq), string(user), string(zoneID), status, bossDefeated, regionState)
if err != nil {
t.Fatal(err)
}
}
// zonesOfTier lists the zone ids at a tier, straight from the registry, so the
// test tracks content edits instead of hardcoding a zone roster.
func zonesOfTier(tier ZoneTier) []ZoneID {
var out []ZoneID
for _, z := range allZones() {
if z.Tier == tier {
out = append(out, z.ID)
}
}
return out
}
func newAchievementTestDB(t *testing.T) {
t.Helper()
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
}
func TestExpeditionClearAchievements(t *testing.T) {
newAchievementTestDB(t)
d := db.Get()
user := id.UserID("@clears:test.invalid")
t1 := zonesOfTier(1)
if len(t1) < 2 {
t.Skipf("tier 1 has %d zones; test wants at least 2", len(t1))
}
if clearedAnyZoneOfTier(d, user, 1) {
t.Error("clean slate reported a tier-1 clear")
}
// A boss-defeated complete run is the only thing that counts.
insertClearedExpedition(t, user, t1[0], ExpeditionStatusComplete, 1, "{}")
if !clearedAnyZoneOfTier(d, user, 1) {
t.Error("cleared tier-1 zone not detected")
}
if clearedEveryZoneOfTier(d, user, 1) {
t.Error("one of several tier-1 zones counted as all of them")
}
if clearedAnyZoneOfTier(d, user, 2) {
t.Error("a tier-1 clear leaked into tier 2")
}
for _, z := range t1[1:] {
insertClearedExpedition(t, user, z, ExpeditionStatusComplete, 1, "{}")
}
if !clearedEveryZoneOfTier(d, user, 1) {
t.Error("clearing every tier-1 zone did not satisfy the master check")
}
}
// TestExpeditionClearIgnoresUnfinishedRuns: an abandoned run, or one where the
// player walked out without killing the boss, must not count as a clear.
func TestExpeditionClearIgnoresUnfinishedRuns(t *testing.T) {
newAchievementTestDB(t)
d := db.Get()
user := id.UserID("@partial:test.invalid")
t2 := zonesOfTier(2)
if len(t2) == 0 {
t.Skip("no tier-2 zones")
}
insertClearedExpedition(t, user, t2[0], ExpeditionStatusAbandoned, 1, "{}")
if clearedAnyZoneOfTier(d, user, 2) {
t.Error("an abandoned expedition counted as a clear")
}
insertClearedExpedition(t, user, t2[0], ExpeditionStatusComplete, 0, "{}")
if clearedAnyZoneOfTier(d, user, 2) {
t.Error("a complete run with the boss alive counted as a clear")
}
insertClearedExpedition(t, user, t2[0], ExpeditionStatusComplete, 1, "{}")
if !clearedAnyZoneOfTier(d, user, 2) {
t.Error("a real clear was not detected")
}
}
// TestClearedUnderThreat50 pins the presence-vs-zero distinction. recordMaxThreat
// samples only on day rollover and never writes a zero, so an empty region_state
// means "no threat history", not "threat stayed at zero".
func TestClearedUnderThreat50(t *testing.T) {
newAchievementTestDB(t)
d := db.Get()
zones := zonesOfTier(1)
if len(zones) == 0 {
t.Skip("no tier-1 zones")
}
z := zones[0]
quiet := id.UserID("@quiet:test.invalid")
loud := id.UserID("@loud:test.invalid")
sameDay := id.UserID("@sameday:test.invalid")
insertClearedExpedition(t, quiet, z, ExpeditionStatusComplete, 1, `{"max_threat_seen":30}`)
if !clearedUnderThreat50(d, quiet) {
t.Error("peak threat 30 did not satisfy the under-50 check")
}
insertClearedExpedition(t, loud, z, ExpeditionStatusComplete, 1, `{"max_threat_seen":60}`)
if clearedUnderThreat50(d, loud) {
t.Error("peak threat 60 satisfied the under-50 check")
}
insertClearedExpedition(t, sameDay, z, ExpeditionStatusComplete, 1, "{}")
if clearedUnderThreat50(d, sameDay) {
t.Error("an expedition with no threat samples was awarded the quiet clear")
}
// Exactly 50 is not under 50.
edge := id.UserID("@edge:test.invalid")
insertClearedExpedition(t, edge, z, ExpeditionStatusComplete, 1, `{"max_threat_seen":50}`)
if clearedUnderThreat50(d, edge) {
t.Error("peak threat of exactly 50 counted as under 50")
}
}
// TestExpeditionAchievementIDsRegistered guards the wiring: every expedition
// achievement referenced by the tier helpers must exist in the registry, and a
// tier with no zones must not ship a master achievement nobody can earn.
func TestExpeditionAchievementIDsRegistered(t *testing.T) {
p := &AchievementsPlugin{}
defs := p.buildAchievements()
ids := map[string]bool{}
for _, a := range defs {
if ids[a.ID] {
t.Errorf("duplicate achievement id %q", a.ID)
}
ids[a.ID] = true
}
for tier := ZoneTier(1); tier <= 5; tier++ {
if len(zonesOfTier(tier)) == 0 {
continue
}
for _, prefix := range []string{"expedition_clear_t", "expedition_master_t"} {
id := prefix + string(rune('0'+tier))
if !ids[id] {
t.Errorf("tier %d has zones but %q is not registered", tier, id)
}
}
}
for _, id := range []string{"expedition_quiet_clear", "temper_legendary"} {
if !ids[id] {
t.Errorf("%q is not registered", id)
}
}
}

View File

@@ -221,10 +221,9 @@ func TestAdv2Scenario_ExpeditionCryptValdris(t *testing.T) {
t.Errorf("expected outfitting to debit coins (%.2f → %.2f)", balBefore, balAfter)
}
// Backdate start so deliverBriefing's same-day guard passes.
if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil {
t.Fatalf("backdate: %v", err)
}
// Backdate start so deliverBriefing's same-day guard passes, and to
// before eventAnchoredCutoff so the legacy mutator path still fires.
rewindToLegacyAnchor(t, exp)
// Drive 3 daily briefings — verify supply burn + day advance.
now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC)
@@ -305,7 +304,7 @@ func TestAdv2Scenario_HarvestForestShadows(t *testing.T) {
p := &AdventurePlugin{euro: euro}
// Forage-friendly expedition.
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start forest_shadows"); err != nil {
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start forest_shadows lean"); err != nil {
t.Fatalf("expedition start: %v", err)
}
exp, _ := getActiveExpedition(uid)

View File

@@ -21,13 +21,14 @@ import (
type AdventurePlugin struct {
Base
euro *EuroPlugin
xp *XPPlugin
achievements *AchievementsPlugin
mu sync.Mutex
euro *EuroPlugin
xp *XPPlugin
achievements *AchievementsPlugin
mu sync.Mutex
dmToPlayer map[id.RoomID]id.UserID
pending sync.Map // userID string -> *advPendingInteraction
userLocks sync.Map // userID string -> *sync.Mutex
expLocks sync.Map // expedition ID string -> *sync.Mutex
dmRemindedDate sync.Map // userID string -> "2006-01-02" date string
dmMenuSentAt sync.Map // userID string -> time.Time (last time actionable menu was DM'd)
arenaDeadlines sync.Map // userID string -> time.Time (auto-cashout deadline)
@@ -37,8 +38,8 @@ type AdventurePlugin struct {
hospitalNudges sync.Map // userID string -> time.Time (when to send nudge)
craftResults sync.Map // userID string -> []CraftResult (pending craft results for narrative)
treasureUndo sync.Map // userID string -> *advTreasureUndoToken (10-min auto-swap reversal)
morningHour int
summaryHour int
morningHour int
summaryHour int
// stopCh is closed by Stop() to signal all background tickers
// (morning/summary/midnight/event/rival/robbie/hospital/mortgage/
@@ -70,6 +71,16 @@ func (p *AdventurePlugin) advUserLock(userID id.UserID) *sync.Mutex {
return val.(*sync.Mutex)
}
// advExpeditionLock returns a per-expedition mutex, for state a whole party
// shares rather than one player. advUserLock cannot stand in: it is keyed by
// sender, so two members racing the same expedition row take two different
// mutexes and exclude nobody. Handlers run one goroutine per event, so any
// read-modify-write of the shared supply pool needs this.
func (p *AdventurePlugin) advExpeditionLock(expID string) *sync.Mutex {
val, _ := p.expLocks.LoadOrStore(expID, &sync.Mutex{})
return val.(*sync.Mutex)
}
// advDMResponseWindow is the default window for active state-holding
// prompts (shop/blacksmith/hospital/masterwork/treasure confirms).
// Passive overnight-tolerant prompts (pet arrival, flavor DMs) use
@@ -177,6 +188,7 @@ func (p *AdventurePlugin) Init() error {
// through the L4-L5h dual-write soak (fresh deploys, restored backups).
bootstrapPlayerMetaFromLegacy()
bootstrapRestoreExpeditionStreakDecay()
bootstrapRoomsTraversed()
// Rehydrate DM room mappings for existing characters
chars, err := loadAllAdvCharacters()
@@ -220,6 +232,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
@@ -279,6 +301,12 @@ func (p *AdventurePlugin) OnReaction(_ ReactionContext) error { return nil }
// ── Message Dispatch ─────────────────────────────────────────────────────────
func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
// D4-b: lazy morning briefing. When the 06:00 UTC ticker skips an idle
// player's event-anchored expedition, the briefing fires here on their
// next inbound message. Fast-paths to a no-op for users with no active
// expedition.
p.maybeDeliverDeferredBriefing(ctx.Sender, time.Now().UTC())
// 0. D&D layer commands (Phase 1 — work in rooms and DMs)
if p.IsCommand(ctx.Body, "setup") {
return p.handleDnDSetupCmd(ctx, p.GetArgs(ctx.Body, "setup"))
@@ -325,6 +353,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "zone") {
return p.handleDnDZoneCmd(ctx, p.GetArgs(ctx.Body, "zone"))
}
if p.IsCommand(ctx.Body, "revisit") {
return p.handleRevisitCmd(ctx, p.GetArgs(ctx.Body, "revisit"))
}
if p.IsCommand(ctx.Body, "fight") {
return p.handleFightCmd(ctx)
}
@@ -468,6 +499,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
return p.handleRepairAllCmd(ctx)
case strings.HasPrefix(lower, "repair "):
return p.handleRepairSlotCmd(ctx, strings.TrimSpace(args[7:]))
case lower == "temper":
return p.handleTemperCmd(ctx)
case lower == "boost":
return p.handleBoostCmd(ctx)
case lower == "recipes":
@@ -502,6 +535,7 @@ const advHelpText = `**Adventure Commands**
` + "`!adventure blacksmith`" + ` — Visit the blacksmith (view repair costs)
` + "`!adventure repair all`" + ` — Repair all damaged equipment
` + "`!adventure repair <slot>`" + ` — Repair a specific slot
` + "`!adventure temper`" + ` — Push a magic item one rarity step higher (blacksmith)
` + "`!adventure recipes`" + ` — List crafting recipes known at your current Foraging level
` + "`!adventure mastery`" + ` — Per-slot equipment mastery progress and active bonus
` + "`!adventure treasures`" + ` — List your treasures · ` + "`treasures lock`" + ` to refuse swaps
@@ -691,7 +725,10 @@ func (p *AdventurePlugin) handleSellCmd(ctx MessageContext, args string) error {
} else {
result = p.advSellItem(ctx.Sender, args)
}
return p.SendDM(ctx.Sender, result)
err = p.SendDM(ctx.Sender, result)
// N1/A6 — a sale at Thom's is a moment the player is present and reading.
p.maybeFireAnchoredEvent(ctx.Sender, advEventChanceSell)
return err
}
func (p *AdventurePlugin) handleInventoryCmd(ctx MessageContext) error {
@@ -834,6 +871,10 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
return p.resolveBlacksmithSlotChoice(ctx, interaction)
case "blacksmith_confirm":
return p.resolveBlacksmithConfirm(ctx, interaction)
case "temper_pick":
return p.resolveTemperPick(ctx, interaction)
case "temper_confirm":
return p.resolveTemperConfirm(ctx, interaction)
case "shop_category":
return p.resolveShopCategoryChoice(ctx, interaction)
case "shop_item":
@@ -1056,13 +1097,19 @@ func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharact
// ── Treasure Drop Check ─────────────────────────────────────────────────────
func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCharacter, loc *AdvLocation) {
drop, roll, rate := rollAdvTreasureDropDetailed(loc.Tier, userID, p.chatLevel(userID))
// checkTreasureDrop rolls the treasure table for one earned moment. weight
// scales the drop rate by how big that moment was (see advTreasureWeight*).
func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCharacter, loc *AdvLocation, weight float64) {
drop, roll, rate := rollAdvTreasureDropDetailed(loc.Tier, userID, p.chatLevel(userID), weight)
if drop == nil {
// Near-miss feedback: when the roll was within 2× of the drop rate,
// tell the player they almost got it. Treasure rates are 0.151.5%
// so without this signal the system feels invisible.
if rate > 0 && roll < rate*2 {
//
// Only for weighted moments (boss/elite/zone clear). A standard kill
// fires dozens of times a day on autopilot, and a near-miss DM on 3%
// of them turns the signal into noise.
if weight > 1 && rate > 0 && roll < rate*2 {
p.SendDM(userID, fmt.Sprintf("🎁 *Treasure: just missed* — rolled %.2f%% against %.2f%% drop chance.",
roll*100, rate*100))
}
@@ -1522,4 +1569,3 @@ func (p *AdventurePlugin) handleBoostCmd(ctx MessageContext) error {
advSetBoost(true)
return p.SendReply(ctx.RoomID, ctx.EventID, "⚡ Double XP/money boost **enabled**! All adventure XP and loot values are doubled.")
}

View File

@@ -642,7 +642,7 @@ type AdvActionResult struct {
FlavorKey string
EquipBroken []EquipmentSlot
NearDeath bool
StreakBonus int
StreakBonus int
CombatLog *CombatResult
// MasteryCrossings records equipment slots whose ActionsUsed crossed a
// mastery threshold (advMasteryThresholds) on this action. The caller

View File

@@ -116,12 +116,12 @@ func loadAdvDailyActivity(date string) (map[id.UserID][]AdvDailyActivity, error)
}
for rows.Next() {
var (
uid, zoneID string
visitedJSON string
totalRooms int
abandoned, bossDefeated int
completedAt *time.Time
lastAction time.Time
uid, zoneID string
visitedJSON string
totalRooms int
abandoned, bossDefeated int
completedAt *time.Time
lastAction time.Time
)
if err := rows.Scan(&uid, &zoneID, &visitedJSON, &totalRooms,
&abandoned, &bossDefeated, &completedAt, &lastAction); err != nil {

View File

@@ -16,20 +16,20 @@ import (
// ── Arena Run State ─────────────────────────────────────────────────────────
type ArenaRun struct {
ID int64
UserID id.UserID
RoomID id.RoomID
StartTier int
Tier int
Round int
Status string // "active", "awaiting", "completed", "dead", "cashed_out"
Earnings int64 // session total (multiplied euros accumulated across completed tiers)
TierEarnings int64 // current tier's raw earnings (reset each tier)
XPAccumulated int // session XP accumulator (raw, multiplied at payout)
RoundsSurvived int
LastMonster string
StartedAt time.Time
EndedAt *time.Time
ID int64
UserID id.UserID
RoomID id.RoomID
StartTier int
Tier int
Round int
Status string // "active", "awaiting", "completed", "dead", "cashed_out"
Earnings int64 // session total (multiplied euros accumulated across completed tiers)
TierEarnings int64 // current tier's raw earnings (reset each tier)
XPAccumulated int // session XP accumulator (raw, multiplied at payout)
RoundsSurvived int
LastMonster string
StartedAt time.Time
EndedAt *time.Time
}
// ── Command Dispatch ────────────────────────────────────────────────────────
@@ -304,13 +304,17 @@ func (p *AdventurePlugin) handleArenaStats(ctx MessageContext) error {
return p.SendDM(ctx.Sender, renderArenaPersonalStats(displayName, wins, losses, stats))
}
// handleArenaLeaderboard shows the current season's standings (C4). Lifetime
// totals stay reachable via `!arena stats`.
func (p *AdventurePlugin) handleArenaLeaderboard(ctx MessageContext) error {
entries, err := loadArenaLeaderboard()
now := time.Now().UTC()
start, end := arenaSeasonBounds(now)
entries, err := loadArenaSeasonLeaderboard(start, end)
if err != nil {
slog.Error("arena: failed to load leaderboard", "err", err)
slog.Error("arena: failed to load season leaderboard", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load arena leaderboard.")
}
return p.SendReply(ctx.RoomID, ctx.EventID, renderArenaLeaderboard(entries))
return p.SendReply(ctx.RoomID, ctx.EventID, renderArenaLeaderboard(arenaSeasonKey(now), entries))
}
// ── Combat Resolution ───────────────────────────────────────────────────────
@@ -624,7 +628,10 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
text += fmt.Sprintf("\n\n🎉 **Combat Level %d!**", newLevel)
}
return p.SendDM(userID, text)
err := p.SendDM(userID, text)
// N1/A6 — cashing out is the third mid-day event anchor.
p.maybeFireAnchoredEvent(userID, advEventChanceArena)
return err
}
// arenaProcessBail handles bail payout (called from handleArenaBail or countdown).
@@ -1008,8 +1015,8 @@ func loadArenaPersonalStats(userID id.UserID) *ArenaPersonalStats {
type ArenaGearSet struct {
Tier int
SetKey string // DB key: "bloodied", "ironclad", etc.
SetName string // Display: "Bloodied", "Ironclad", etc.
SetKey string // DB key: "bloodied", "ironclad", etc.
SetName string // Display: "Bloodied", "Ironclad", etc.
HelmetName string
Description string
DropRate float64

View File

@@ -138,12 +138,12 @@ func TestArenaBossOutcome_WinSurfacesStagedLogAndBossDeath(t *testing.T) {
victoryHeadline := "🏆 **" + monster.Name + "** falls (HP " + "60" + "→" + "42" + " / 60)."
outcome := renderBossOutcome(BossOutcomeInputs{
ZoneID: ZoneArena,
RunID: "arena-stagedlog-test",
RoomIdx: 11,
Monster: monster,
Result: result,
PreHP: 60, PostHP: 42, MaxHP: 60,
ZoneID: ZoneArena,
RunID: "arena-stagedlog-test",
RoomIdx: 11,
Monster: monster,
Result: result,
PreHP: 60, PostHP: 42, MaxHP: 60,
PhaseTwoAt: arenaBossPhaseTwoAt(1),
Nat20s: 1,
Nat1s: 0,

View File

@@ -126,21 +126,21 @@ func renderArenaStatus(run *ArenaRun) string {
// ── Leaderboard ─────────────────────────────────────────────────────────────
type ArenaLeaderboardEntry struct {
DisplayName string
TotalEarnings int64
HighestTier int
DisplayName string
TotalEarnings int64
HighestTier int
Tier5Completions int
TotalRuns int
TotalDeaths int
TotalRuns int
TotalDeaths int
}
func renderArenaLeaderboard(entries []ArenaLeaderboardEntry) string {
func renderArenaLeaderboard(season string, entries []ArenaLeaderboardEntry) string {
if len(entries) == 0 {
return "⚔️ **Arena Leaderboard**\n\nNo arena runs recorded yet. Be the first."
return fmt.Sprintf("⚔️ **Arena Leaderboard — Season %s**\n\nNobody has entered the arena this season. Be the first.", season)
}
var b strings.Builder
b.WriteString("⚔️ **Arena Leaderboard**\n\n")
b.WriteString(fmt.Sprintf("⚔️ **Arena Leaderboard — Season %s**\n\n", season))
medals := []string{"🥇", "🥈", "🥉"}
for i, e := range entries {
@@ -157,6 +157,7 @@ func renderArenaLeaderboard(entries []ArenaLeaderboardEntry) string {
b.WriteString(fmt.Sprintf("%s **%s** — €%d earned | %s | %d runs | %d deaths\n",
prefix, e.DisplayName, e.TotalEarnings, tierLabel, e.TotalRuns, e.TotalDeaths))
}
b.WriteString("\n_Season standings. `!arena stats` for your lifetime record._")
return b.String()
}

View File

@@ -0,0 +1,235 @@
package plugin
import (
"database/sql"
"fmt"
"log/slog"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// ── Arena seasons ───────────────────────────────────────────────────────────
//
// Quarterly standings (gogobee_engagement_plan.md C4). The plan called for a
// quarterly *reset* of arena_stats; this derives season standings from
// arena_history.created_at instead. Same player-visible effect — the board
// clears every quarter — but lifetime totals survive for `!arena stats`, and
// there is no destructive job that can fire twice or half-way.
//
// Season titles are archived to their own table rather than player_meta.title:
// that column already carries the Survivalist milestone, and a season champion
// overwriting someone's expedition title would silently destroy it.
// arenaSeasonTitleKinds are the two crowns awarded per season.
const (
arenaTitleEarnings = "earnings"
arenaTitleStreak = "streak"
)
// arenaSeasonKey names the quarter containing t, e.g. "2026-Q3".
func arenaSeasonKey(t time.Time) string {
t = t.UTC()
return fmt.Sprintf("%d-Q%d", t.Year(), (int(t.Month())-1)/3+1)
}
// arenaSeasonStart is the first instant of the quarter containing t.
func arenaSeasonStart(t time.Time) time.Time {
t = t.UTC()
firstMonth := time.Month(((int(t.Month())-1)/3)*3 + 1)
return time.Date(t.Year(), firstMonth, 1, 0, 0, 0, 0, time.UTC)
}
// arenaSeasonBounds returns [start, end) for the quarter containing t.
func arenaSeasonBounds(t time.Time) (time.Time, time.Time) {
start := arenaSeasonStart(t)
return start, start.AddDate(0, 3, 0)
}
// previousArenaSeason returns the key and bounds of the quarter before t's.
func previousArenaSeason(t time.Time) (string, time.Time, time.Time) {
prev := arenaSeasonStart(t).AddDate(0, -1, 0) // any instant inside the prior quarter
start, end := arenaSeasonBounds(prev)
return arenaSeasonKey(prev), start, end
}
// loadArenaSeasonLeaderboard aggregates arena_history over [start, end) into
// the same shape the lifetime board renders.
func loadArenaSeasonLeaderboard(start, end time.Time) ([]ArenaLeaderboardEntry, error) {
rows, err := db.Get().Query(`
SELECT h.user_id, COALESCE(c.display_name, h.user_id),
SUM(h.earnings),
MAX(h.tier),
SUM(CASE WHEN h.tier = 5 AND h.outcome = 'completed' THEN 1 ELSE 0 END),
COUNT(*),
SUM(CASE WHEN h.outcome = 'dead' THEN 1 ELSE 0 END)
FROM arena_history h
LEFT JOIN player_meta c ON c.user_id = h.user_id
WHERE h.created_at >= ? AND h.created_at < ?
GROUP BY h.user_id
ORDER BY SUM(h.earnings) DESC
LIMIT 10`, start.Unix(), end.Unix())
if err != nil {
return nil, err
}
defer rows.Close()
var entries []ArenaLeaderboardEntry
for rows.Next() {
var e ArenaLeaderboardEntry
var uid string
if err := rows.Scan(&uid, &e.DisplayName, &e.TotalEarnings, &e.HighestTier,
&e.Tier5Completions, &e.TotalRuns, &e.TotalDeaths); err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, rows.Err()
}
// arenaSeasonChampion finds the single top row for a season by the given
// metric. Returns ok=false when nobody entered the arena that quarter.
func arenaSeasonChampion(kind string, start, end time.Time) (id.UserID, int64, bool) {
var metric string
switch kind {
case arenaTitleEarnings:
metric = "SUM(earnings)"
case arenaTitleStreak:
metric = "MAX(rounds_survived)"
default:
return "", 0, false
}
// Only runs that earned or survived something can crown anyone: a season of
// nothing but deaths at round zero should award no streak title.
q := fmt.Sprintf(`
SELECT user_id, %s AS metric
FROM arena_history
WHERE created_at >= ? AND created_at < ?
GROUP BY user_id
HAVING metric > 0
ORDER BY metric DESC, user_id ASC
LIMIT 1`, metric)
var uid string
var value int64
err := db.Get().QueryRow(q, start.Unix(), end.Unix()).Scan(&uid, &value)
if err == sql.ErrNoRows {
return "", 0, false
}
if err != nil {
slog.Error("arena season: champion query", "kind", kind, "err", err)
return "", 0, false
}
return id.UserID(uid), value, true
}
// recordArenaSeasonTitle archives a crown. Idempotent on (season, kind).
func recordArenaSeasonTitle(season, kind string, userID id.UserID, value int64, at time.Time) error {
_, err := db.Get().Exec(`
INSERT INTO arena_season_titles (season, kind, user_id, value, awarded_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(season, kind) DO NOTHING`,
season, kind, string(userID), value, at.Unix())
return err
}
// loadArenaSeasonTitles returns every crown a player has ever taken.
func loadArenaSeasonTitles(userID id.UserID) ([]string, error) {
rows, err := db.Get().Query(`
SELECT season, kind FROM arena_season_titles
WHERE user_id = ? ORDER BY season DESC`, string(userID))
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var season, kind string
if err := rows.Scan(&season, &kind); err != nil {
return nil, err
}
out = append(out, fmt.Sprintf("%s %s", season, arenaTitleName(kind)))
}
return out, rows.Err()
}
func arenaTitleName(kind string) string {
switch kind {
case arenaTitleEarnings:
return "Coinlord of the Arena"
case arenaTitleStreak:
return "Longest Walk"
}
return kind
}
// ── Rollover ────────────────────────────────────────────────────────────────
// arenaSeasonRollover awards the previous season's crowns exactly once, and
// announces them. Safe to call every midnight: JobCompleted keyed on the season
// makes it a no-op for the rest of the quarter, and a bot that was down on the
// rollover day still catches up the next time it wakes.
func (p *AdventurePlugin) arenaSeasonRollover(now time.Time) {
season, start, end := previousArenaSeason(now)
jobName := "arena_season_rollover"
if db.JobCompleted(jobName, season) {
return
}
// Guard against awarding a season that hasn't finished yet — only possible
// if a caller passes a doctored clock.
if !now.UTC().After(end) {
return
}
var lines []string
failed := false
for _, kind := range []string{arenaTitleEarnings, arenaTitleStreak} {
uid, value, ok := arenaSeasonChampion(kind, start, end)
if !ok {
continue
}
if err := recordArenaSeasonTitle(season, kind, uid, value, now); err != nil {
slog.Error("arena season: record title failed", "season", season, "kind", kind, "err", err)
failed = true
continue // don't announce a crown we failed to persist
}
name, _ := loadDisplayName(uid)
switch kind {
case arenaTitleEarnings:
lines = append(lines, fmt.Sprintf("🥇 **%s** — _%s_ (€%d earned)",
name, arenaTitleName(kind), value))
case arenaTitleStreak:
lines = append(lines, fmt.Sprintf("🏃 **%s** — _%s_ (%d rounds in one run)",
name, arenaTitleName(kind), value))
}
}
// Marking the job done is what stops the next midnight from retrying, so a
// crown we failed to persist must not mark it. recordArenaSeasonTitle is
// idempotent on (season, kind), and a past season's data is frozen, so the
// retry re-derives the same champions and no-ops the ones already stored.
if failed {
slog.Warn("arena season: deferring completion after title failure", "season", season)
return
}
db.MarkJobCompleted(jobName, season)
if len(lines) == 0 {
slog.Info("arena season: closed with no entrants", "season", season)
return
}
slog.Info("arena season: crowned", "season", season, "titles", len(lines))
gr := gamesRoom()
if gr == "" {
return
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("⚔️ **Arena Season %s has ended.**\n\n", season))
sb.WriteString(strings.Join(lines, "\n"))
sb.WriteString("\n\nThe board is clear. Season " + arenaSeasonKey(now) + " starts now.")
p.SendMessage(id.RoomID(gr), sb.String())
}

View File

@@ -0,0 +1,187 @@
package plugin
import (
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func TestArenaSeasonKeyAndBounds(t *testing.T) {
tests := []struct {
when time.Time
key string
wantStart time.Time
}{
{time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), "2026-Q1", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)},
{time.Date(2026, 3, 31, 23, 59, 59, 0, time.UTC), "2026-Q1", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)},
{time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), "2026-Q2", time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)},
{time.Date(2026, 7, 9, 12, 0, 0, 0, time.UTC), "2026-Q3", time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)},
{time.Date(2026, 12, 31, 23, 0, 0, 0, time.UTC), "2026-Q4", time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)},
}
for _, tc := range tests {
if got := arenaSeasonKey(tc.when); got != tc.key {
t.Errorf("arenaSeasonKey(%s) = %s, want %s", tc.when, got, tc.key)
}
start, end := arenaSeasonBounds(tc.when)
if !start.Equal(tc.wantStart) {
t.Errorf("season start for %s = %s, want %s", tc.when, start, tc.wantStart)
}
if !end.Equal(start.AddDate(0, 3, 0)) {
t.Errorf("season end for %s is not start+3mo", tc.when)
}
if !tc.when.Before(end) || tc.when.Before(start) {
t.Errorf("%s does not fall inside its own season bounds", tc.when)
}
}
}
// TestPreviousArenaSeasonWrapsYear pins the Q1 → prior-year-Q4 edge.
func TestPreviousArenaSeasonWrapsYear(t *testing.T) {
key, start, end := previousArenaSeason(time.Date(2026, 2, 14, 0, 0, 0, 0, time.UTC))
if key != "2025-Q4" {
t.Errorf("previous season = %s, want 2025-Q4", key)
}
if !start.Equal(time.Date(2025, 10, 1, 0, 0, 0, 0, time.UTC)) {
t.Errorf("start = %s, want 2025-10-01", start)
}
if !end.Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) {
t.Errorf("end = %s, want 2026-01-01", end)
}
}
func insertArenaRun(t *testing.T, user id.UserID, tier, rounds int, earnings int64, outcome string, at time.Time) {
t.Helper()
_, err := db.Get().Exec(
`INSERT INTO arena_history (user_id, start_tier, tier, rounds_survived, earnings, outcome, monster_name, created_at)
VALUES (?, 1, ?, ?, ?, ?, 'Test Monster', ?)`,
string(user), tier, rounds, earnings, outcome, at.Unix())
if err != nil {
t.Fatal(err)
}
}
// TestArenaSeasonLeaderboardWindowing: only runs inside the season window count.
func TestArenaSeasonLeaderboardWindowing(t *testing.T) {
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
inSeason := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
start, end := arenaSeasonBounds(inSeason)
alice := id.UserID("@alice:test.invalid")
bob := id.UserID("@bob:test.invalid")
insertArenaRun(t, alice, 3, 4, 5000, "completed", inSeason)
insertArenaRun(t, alice, 5, 2, 1000, "dead", inSeason.AddDate(0, 0, 1))
insertArenaRun(t, bob, 2, 3, 9000, "cashed_out", inSeason)
// Last season — must not appear.
insertArenaRun(t, alice, 5, 4, 999999, "completed", start.AddDate(0, 0, -1))
entries, err := loadArenaSeasonLeaderboard(start, end)
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 {
t.Fatalf("got %d entries, want 2", len(entries))
}
// Bob out-earned Alice this season (9000 vs 6000).
if entries[0].DisplayName != string(bob) {
t.Errorf("top entry = %s, want bob", entries[0].DisplayName)
}
if entries[0].TotalEarnings != 9000 {
t.Errorf("bob earnings = %d, want 9000", entries[0].TotalEarnings)
}
if entries[1].TotalEarnings != 6000 {
t.Errorf("alice earnings = %d, want 6000 (last season's 999999 must not count)", entries[1].TotalEarnings)
}
if entries[1].TotalDeaths != 1 {
t.Errorf("alice deaths = %d, want 1", entries[1].TotalDeaths)
}
if entries[1].HighestTier != 5 {
t.Errorf("alice highest tier = %d, want 5", entries[1].HighestTier)
}
}
// TestArenaSeasonChampions: earnings crown goes by total, streak crown by the
// single longest run — they can be different players.
func TestArenaSeasonChampions(t *testing.T) {
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
when := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
start, end := arenaSeasonBounds(when)
rich := id.UserID("@rich:test.invalid")
tough := id.UserID("@tough:test.invalid")
insertArenaRun(t, rich, 5, 2, 50000, "cashed_out", when)
insertArenaRun(t, tough, 1, 12, 500, "completed", when)
uid, val, ok := arenaSeasonChampion(arenaTitleEarnings, start, end)
if !ok || uid != rich || val != 50000 {
t.Errorf("earnings champion = (%s, %d, %v), want rich/50000", uid, val, ok)
}
uid, val, ok = arenaSeasonChampion(arenaTitleStreak, start, end)
if !ok || uid != tough || val != 12 {
t.Errorf("streak champion = (%s, %d, %v), want tough/12", uid, val, ok)
}
// An empty season crowns nobody.
emptyStart, emptyEnd := arenaSeasonBounds(when.AddDate(1, 0, 0))
if _, _, ok := arenaSeasonChampion(arenaTitleEarnings, emptyStart, emptyEnd); ok {
t.Error("an empty season produced an earnings champion")
}
// A season of nothing but round-zero deaths crowns no streak.
deathsOnly := time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC)
dStart, dEnd := arenaSeasonBounds(deathsOnly)
insertArenaRun(t, tough, 1, 0, 0, "dead", deathsOnly)
if _, _, ok := arenaSeasonChampion(arenaTitleStreak, dStart, dEnd); ok {
t.Error("a season of round-zero deaths produced a streak champion")
}
}
// TestRecordArenaSeasonTitleIdempotent: the rollover must be safe to re-run.
func TestRecordArenaSeasonTitleIdempotent(t *testing.T) {
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
now := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
winner := id.UserID("@winner:test.invalid")
usurper := id.UserID("@usurper:test.invalid")
if err := recordArenaSeasonTitle("2026-Q2", arenaTitleEarnings, winner, 100, now); err != nil {
t.Fatal(err)
}
// A second write for the same (season, kind) must not overwrite the crown.
if err := recordArenaSeasonTitle("2026-Q2", arenaTitleEarnings, usurper, 999, now); err != nil {
t.Fatal(err)
}
titles, err := loadArenaSeasonTitles(winner)
if err != nil {
t.Fatal(err)
}
if len(titles) != 1 {
t.Fatalf("winner has %d titles, want 1", len(titles))
}
stolen, _ := loadArenaSeasonTitles(usurper)
if len(stolen) != 0 {
t.Errorf("usurper took the crown: %v", stolen)
}
}

View File

@@ -637,9 +637,9 @@ func TestRenderArenaPersonalStats_WithStats(t *testing.T) {
}
func TestRenderArenaLeaderboard_Empty(t *testing.T) {
text := renderArenaLeaderboard(nil)
if !strings.Contains(text, "No arena runs") {
t.Error("empty leaderboard should say no runs")
text := renderArenaLeaderboard("2026-Q3", nil)
if !strings.Contains(text, "Nobody has entered") {
t.Error("empty leaderboard should say nobody entered")
}
}
@@ -648,7 +648,7 @@ func TestRenderArenaLeaderboard_WithEntries(t *testing.T) {
{DisplayName: "Alice", TotalEarnings: 100000, HighestTier: 5, Tier5Completions: 1, TotalRuns: 5, TotalDeaths: 2},
{DisplayName: "Bob", TotalEarnings: 50000, HighestTier: 3, TotalRuns: 10, TotalDeaths: 7},
}
text := renderArenaLeaderboard(entries)
text := renderArenaLeaderboard("2026-Q3", entries)
if !strings.Contains(text, "Alice") {
t.Error("leaderboard should contain Alice")
}

View File

@@ -318,15 +318,15 @@ func renderBabysitSummary(char *AdventureCharacter, logs []babysitLogEntry) stri
// ── Babysit Log CRUD ────────────────────────────────────────────────────────
type babysitLogEntry struct {
ID int64
UserID id.UserID
LogDate string
Activity string
Outcome string
GoldEarned int
XPGained int
ItemsDropped string
RivalRefused string
ID int64
UserID id.UserID
LogDate string
Activity string
Outcome string
GoldEarned int
XPGained int
ItemsDropped string
RivalRefused string
}
func logBabysitActivity(userID id.UserID, activity, outcome string, gold, xp int, items string) {

View File

@@ -66,11 +66,11 @@ func TestRunBabysitDailyTrickle_AccumulatesAndLevels(t *testing.T) {
defer func() { _ = recover() }()
char := &AdventureCharacter{
UserID: id.UserID("@accum:example"),
PetType: "dog",
PetName: "Rex",
PetLevel: 1, // needs 10 XP = 1000 centi-XP to L2
PetXP: 900,
UserID: id.UserID("@accum:example"),
PetType: "dog",
PetName: "Rex",
PetLevel: 1, // needs 10 XP = 1000 centi-XP to L2
PetXP: 900,
BabysitActive: true,
}
expires := time.Now().UTC().Add(48 * time.Hour)

View File

@@ -24,32 +24,32 @@ var allSlots = []EquipmentSlot{SlotWeapon, SlotArmor, SlotHelmet, SlotBoots, Slo
// ── Core Types ───────────────────────────────────────────────────────────────
type AdventureCharacter struct {
UserID id.UserID
DisplayName string
CombatLevel int
MiningSkill int
ForagingSkill int
FishingSkill int // v2
CombatXP int
MiningXP int
ForagingXP int
FishingXP int // v2
Alive bool
DeadUntil *time.Time
ActionTakenToday bool
HolidayActionTaken bool
CombatActionsUsed int
HarvestActionsUsed int
ArenaWins int // v2
ArenaLosses int // v2
InvasionScore int // v2
Title string // v2
CurrentStreak int
BestStreak int
LastActionDate string
GrudgeLocation string
CreatedAt time.Time
LastActiveAt time.Time
UserID id.UserID
DisplayName string
CombatLevel int
MiningSkill int
ForagingSkill int
FishingSkill int // v2
CombatXP int
MiningXP int
ForagingXP int
FishingXP int // v2
Alive bool
DeadUntil *time.Time
ActionTakenToday bool
HolidayActionTaken bool
CombatActionsUsed int
HarvestActionsUsed int
ArenaWins int // v2
ArenaLosses int // v2
InvasionScore int // v2
Title string // v2
CurrentStreak int
BestStreak int
LastActionDate string
GrudgeLocation string
CreatedAt time.Time
LastActiveAt time.Time
DeathReprieveLast *time.Time
MasterworkDropsReceived int
RivalPool int
@@ -78,27 +78,27 @@ type AdventureCharacter struct {
HouseAutopay bool
HouseCurrentRate float64
// Pets
PetType string
PetName string
PetXP int
PetLevel int
PetArmorTier int
PetChasedAway bool
PetReactivated bool
PetArrived bool
MistyEncounterCount int
MistyDonatedCount int
ThomAnimalLineFired bool
PetType string
PetName string
PetXP int
PetLevel int
PetArmorTier int
PetChasedAway bool
PetReactivated bool
PetArrived bool
MistyEncounterCount int
MistyDonatedCount int
ThomAnimalLineFired bool
PetSupplyShopUnlocked bool
PetLevel10Date string
PetMorningDefense bool
AutoBabysit bool
AutoBabysitFocus string // mining|fishing|foraging — preferred skill for auto-babysit; "" defaults to weakest
TreasuresLocked bool // when true, treasure drops at cap are refused instead of auto-swapping
StreakDecayed bool
CraftsSucceeded int
DeathSource string // "adventure" | "arena" | "" (legacy/unknown — treated as adventure)
DeathLocation string // human-readable location of last death; cleared on revive is not required
PetLevel10Date string
PetMorningDefense bool
AutoBabysit bool
AutoBabysitFocus string // mining|fishing|foraging — preferred skill for auto-babysit; "" defaults to weakest
TreasuresLocked bool // when true, treasure drops at cap are refused instead of auto-swapping
StreakDecayed bool
CraftsSucceeded int
DeathSource string // "adventure" | "arena" | "" (legacy/unknown — treated as adventure)
DeathLocation string // human-readable location of last death; cleared on revive is not required
}
type AdvEquipment struct {
@@ -121,6 +121,7 @@ type AdvItem struct {
Value int64
Slot EquipmentSlot // non-empty for MasterworkGear
SkillSource string // non-empty for MasterworkGear
Temper int // rarity steps above base; magic_item rows only
}
type AdvBuff struct {
@@ -560,7 +561,7 @@ func saveAdvEquipment(userID id.UserID, eq *AdvEquipment) error {
func loadAdvInventory(userID id.UserID) ([]AdvItem, error) {
d := db.Get()
rows, err := d.Query(`
SELECT id, name, item_type, tier, value, slot, skill_source
SELECT id, name, item_type, tier, value, slot, skill_source, temper
FROM adventure_inventory WHERE user_id = ?
ORDER BY tier DESC, value DESC`, string(userID))
if err != nil {
@@ -572,7 +573,7 @@ func loadAdvInventory(userID id.UserID) ([]AdvItem, error) {
for rows.Next() {
var it AdvItem
var slot string
if err := rows.Scan(&it.ID, &it.Name, &it.Type, &it.Tier, &it.Value, &slot, &it.SkillSource); err != nil {
if err := rows.Scan(&it.ID, &it.Name, &it.Type, &it.Tier, &it.Value, &slot, &it.SkillSource, &it.Temper); err != nil {
return nil, err
}
it.Slot = EquipmentSlot(slot)
@@ -584,9 +585,19 @@ func loadAdvInventory(userID id.UserID) ([]AdvItem, error) {
func addAdvInventoryItem(userID id.UserID, item AdvItem) error {
d := db.Get()
_, err := d.Exec(`
INSERT INTO adventure_inventory (user_id, name, item_type, tier, value, slot, skill_source)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
string(userID), item.Name, item.Type, item.Tier, item.Value, string(item.Slot), item.SkillSource)
INSERT INTO adventure_inventory (user_id, name, item_type, tier, value, slot, skill_source, temper)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
string(userID), item.Name, item.Type, item.Tier, item.Value, string(item.Slot), item.SkillSource, item.Temper)
return err
}
// temperInventoryItem records a tempering step on an un-equipped magic item.
// Tier and value move with the item's new effective rarity so it keeps sorting
// and selling correctly.
func temperInventoryItem(itemID int64, temper, tier int, value int64) error {
_, err := db.Get().Exec(
`UPDATE adventure_inventory SET temper = ?, tier = ?, value = ? WHERE id = ?`,
temper, tier, value, itemID)
return err
}

View File

@@ -17,26 +17,26 @@ import (
type ConsumableEffect string
const (
EffectHeal ConsumableEffect = "heal"
EffectDefBoost ConsumableEffect = "def_boost"
EffectAtkBoost ConsumableEffect = "atk_boost"
EffectWard ConsumableEffect = "ward"
EffectHeal ConsumableEffect = "heal"
EffectDefBoost ConsumableEffect = "def_boost"
EffectAtkBoost ConsumableEffect = "atk_boost"
EffectWard ConsumableEffect = "ward"
EffectSpeedBoost ConsumableEffect = "speed_boost"
EffectCritBoost ConsumableEffect = "crit_boost"
EffectFlatDmg ConsumableEffect = "flat_dmg"
EffectSpore ConsumableEffect = "spore"
EffectReflect ConsumableEffect = "reflect"
EffectAutoCrit ConsumableEffect = "auto_crit"
EffectCritBoost ConsumableEffect = "crit_boost"
EffectFlatDmg ConsumableEffect = "flat_dmg"
EffectSpore ConsumableEffect = "spore"
EffectReflect ConsumableEffect = "reflect"
EffectAutoCrit ConsumableEffect = "auto_crit"
)
type ConsumableDef struct {
Name string
Effect ConsumableEffect
Value float64 // meaning depends on effect
Tier int
Buyable bool
Price int64
Slot string // "offensive" or "defensive"
Name string
Effect ConsumableEffect
Value float64 // meaning depends on effect
Tier int
Buyable bool
Price int64
Slot string // "offensive" or "defensive"
}
// ConsumableItem is an AdvItem that has consumable properties.
@@ -234,8 +234,13 @@ func RollConsumableDrop(activity AdvActivityType, tier int) *AdvItem {
if rand.Float64() >= 0.15 {
return nil
}
name := names[rand.IntN(len(names))]
def := consumableDefByName(name)
return consumableAdvItem(consumableDefByName(names[rand.IntN(len(names))]))
}
// consumableAdvItem builds the inventory row for a consumable def. Drop-only
// consumables carry no shop price, so their sell value falls back to a
// per-tier baseline.
func consumableAdvItem(def *ConsumableDef) *AdvItem {
if def == nil {
return nil
}
@@ -252,6 +257,23 @@ func RollConsumableDrop(activity AdvActivityType, tier int) *AdvItem {
}
}
// consumableCache draws n consumables from the dungeon drop pool at `tier`,
// used by guaranteed grants (milestone caches) rather than the drop roll.
// Tier 1 has no dungeon pool, so it yields the buyable Berry Poultice.
func consumableCache(tier, n int) []AdvItem {
names := consumableDropTable[AdvActivityDungeon][tier]
if len(names) == 0 {
names = []string{"Berry Poultice"}
}
out := make([]AdvItem, 0, n)
for range n {
if item := consumableAdvItem(consumableDefByName(names[rand.IntN(len(names))])); item != nil {
out = append(out, *item)
}
}
return out
}
// BuyableConsumables returns all consumable defs that can be purchased from the shop.
func BuyableConsumables() []ConsumableDef {
var result []ConsumableDef

View File

@@ -23,18 +23,65 @@ type advActiveEvent struct {
ExpiresAt time.Time
}
// ── In-memory schedule ───────────────────────────────────────────────────────
// When a player acts on a given day, the next ticker iteration assigns them
// a one-shot roll-minute 60180 minutes in the future. At that minute, the
// 0.5% trigger roll fires. Each player rolls at most once per UTC day.
// ── Event anchors ────────────────────────────────────────────────────────────
// N1/A6 — events used to roll at 0.5%/player/day from a deferred ticker slot,
// which put one sighting in front of a daily player roughly every 200 days.
// They now roll at moments the player is demonstrably present and reading a
// DM: the end-of-day digest, a sale at Thom's, and an arena cashout. Each
// player still sees at most one event per UTC day.
//
// The per-anchor chances below put a player who hits all three at ~1 event
// per week; see TestAnchoredEventWeeklyRate.
const (
advEventChanceDigest = 0.08
advEventChanceSell = 0.05
advEventChanceArena = 0.05
)
var (
advEventScheduleMu sync.Mutex
advEventSchedule map[string]int // userID -> minute-of-day for the deferred roll
advEventRolled map[string]bool // userID -> already rolled today
advEventScheduleDay string // "2006-01-02" the maps belong to
advEventFiredMu sync.Mutex
advEventFired map[string]bool // userID -> an event already fired today
advEventFiredDay string // "2006-01-02" the map belongs to
)
// claimDailyEventSlot reserves today's single event slot for `userID`.
// Returns false when the player already had one fire today.
func claimDailyEventSlot(userID id.UserID, day string) bool {
advEventFiredMu.Lock()
defer advEventFiredMu.Unlock()
if advEventFiredDay != day {
advEventFired = make(map[string]bool)
advEventFiredDay = day
}
if advEventFired[string(userID)] {
return false
}
advEventFired[string(userID)] = true
return true
}
// releaseDailyEventSlot hands the slot back when the trigger bailed before
// anything reached the player, so a later anchor the same day can still fire.
func releaseDailyEventSlot(userID id.UserID) {
advEventFiredMu.Lock()
defer advEventFiredMu.Unlock()
delete(advEventFired, string(userID))
}
// maybeFireAnchoredEvent rolls `chance` at one of the A6 anchor moments and,
// on a hit, triggers the player's one mid-day event for today.
func (p *AdventurePlugin) maybeFireAnchoredEvent(userID id.UserID, chance float64) {
if rand.Float64() >= chance {
return
}
if !claimDailyEventSlot(userID, time.Now().UTC().Format("2006-01-02")) {
return
}
if !p.tryTriggerEvent(userID) {
releaseDailyEventSlot(userID)
}
}
// ── Event Ticker ─────────────────────────────────────────────────────────────
func (p *AdventurePlugin) eventTicker() {
@@ -46,90 +93,45 @@ func (p *AdventurePlugin) eventTicker() {
case <-p.stopCh:
return
case <-ticker.C:
now := time.Now().UTC()
dateKey := now.Format("2006-01-02")
currentMinute := now.Hour()*60 + now.Minute()
// Expire stale pending events every tick
expireAdvPendingEvents()
// Auto-play any combat sessions past their 1h timeout.
p.reapExpiredCombatSessions()
advEventScheduleMu.Lock()
if advEventScheduleDay != dateKey {
advEventSchedule = make(map[string]int)
advEventRolled = make(map[string]bool)
advEventScheduleDay = dateKey
}
// Latch away party members onto autopilot so one absent player
// can't hold a co-op fight hostage (N3/P5). Solo fights are never
// listed — they answer to the reaper above.
p.nudgeStalledPartyTurns()
// Schedule deferred rolls for any newly-acted players
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("adventure: events: failed to load chars", "err", err)
advEventScheduleMu.Unlock()
continue
}
for _, c := range chars {
uid := string(c.UserID)
if !c.Alive || advEventRolled[uid] {
continue
}
if _, scheduled := advEventSchedule[uid]; scheduled {
continue
}
if !c.HasActedToday() {
continue
}
// Assign a one-shot roll 60180 minutes from now, capped to 23:50 UTC.
rollMinute := currentMinute + 60 + rand.IntN(121)
if rollMinute > 23*60+50 {
rollMinute = 23*60 + 50
}
advEventSchedule[uid] = rollMinute
slog.Info("adventure: event roll scheduled", "user", uid, "minute", rollMinute)
}
// Find players whose roll-minute has arrived
var toRoll []id.UserID
for uid, minute := range advEventSchedule {
if minute <= currentMinute && !advEventRolled[uid] {
toRoll = append(toRoll, id.UserID(uid))
advEventRolled[uid] = true
}
}
advEventScheduleMu.Unlock()
for _, uid := range toRoll {
p.tryTriggerEvent(uid)
}
// Reclaim invites nobody answered (N3/P6b). Every read already
// filters on the TTL; this just stops the rows accumulating.
purgeExpiredInvites()
}
}
}
func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
// Load character — must be alive and have acted today
// tryTriggerEvent fires the player's mid-day event. Reports whether an event
// actually reached them — a false return means the caller's daily slot was
// never spent. The trigger roll itself lives in maybeFireAnchoredEvent.
func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) bool {
// Load character — must be alive.
char, err := loadAdvCharacter(userID)
if err != nil || char == nil || !char.Alive || !char.HasActedToday() {
return
if err != nil || char == nil || !char.Alive {
return false
}
// Already has an active event?
active, _ := loadAdvActiveEvent(userID)
if active != nil {
return
return false
}
// Mid-fight: a turn-based session locks the run. Don't drop a random
// overworld event into a live fight — the player can't act on it without
// finishing the fight first, and the trigger DM talks over the combat feed.
if hasActiveCombatSession(userID) {
return
}
// 0.5% chance
if rand.Float64() >= 0.005 {
return
return false
}
// Determine today's activity for filtering
@@ -138,7 +140,7 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
// Pick an event
event := advPickRandomEvent(userID, activityType)
if event == nil {
return
return false
}
// Insert into DB
@@ -147,7 +149,7 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
eventID, err := insertAdvEvent(userID, event.Key, expiresAt)
if err != nil {
slog.Error("adventure: events: failed to insert event", "user", userID, "err", err)
return
return false
}
slog.Info("adventure: mid-day event triggered", "user", userID, "event", event.Key, "id", eventID)
@@ -170,6 +172,7 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
})
_ = p.SendMessage(id.RoomID(gr), roomLine)
}
return true
}
// handleEventRespond processes `!adventure respond`.

View File

@@ -0,0 +1,83 @@
package plugin
import (
"math/rand/v2"
"testing"
"maunium.net/go/mautrix/id"
)
// TestAnchoredEventWeeklyRate pins the A6 rebalance: a player who reads an
// end-of-day digest, sells at Thom's, and cashes out of the arena every day
// should see roughly one mid-day event per week. The old 0.5%/day roll put
// that at one per ~200 days.
//
// The simulation drives its own seeded RNG rather than the package-global
// one, so it measures the policy — the chance constants and the one-event-
// per-day cap — and never flakes on RNG ordering elsewhere in the suite.
func TestAnchoredEventWeeklyRate(t *testing.T) {
anchors := []float64{advEventChanceDigest, advEventChanceSell, advEventChanceArena}
const weeks = 20000
rng := rand.New(rand.NewPCG(0x5EED, 0xA6))
events := 0
for range weeks {
for range 7 {
firedToday := false
for _, chance := range anchors {
if firedToday {
break // one event per player per UTC day
}
if rng.Float64() < chance {
firedToday = true
}
}
if firedToday {
events++
}
}
}
perWeek := float64(events) / weeks
if perWeek < 0.8 || perWeek > 1.5 {
t.Errorf("anchored events = %.3f/week, want ~1 (0.81.5)", perWeek)
}
}
// TestClaimDailyEventSlot_OnePerDay guards the cap the rate test assumes.
func TestClaimDailyEventSlot_OnePerDay(t *testing.T) {
uid := id.UserID("@events-slot:example")
const day = "2026-07-09"
advEventFiredMu.Lock()
advEventFired = nil
advEventFiredDay = ""
advEventFiredMu.Unlock()
if !claimDailyEventSlot(uid, day) {
t.Fatal("first claim of the day should succeed")
}
if claimDailyEventSlot(uid, day) {
t.Error("second claim on the same day should be refused")
}
// A trigger that bailed before reaching the player hands the slot back.
releaseDailyEventSlot(uid)
if !claimDailyEventSlot(uid, day) {
t.Error("claim after release should succeed")
}
// A new UTC day resets everyone.
if !claimDailyEventSlot(uid, "2026-07-10") {
t.Error("claim on a new day should succeed")
}
}
// TestClaimDailyEventSlot_PerUser — one player's event doesn't consume another's.
func TestClaimDailyEventSlot_PerUser(t *testing.T) {
const day = "2026-07-11"
if !claimDailyEventSlot(id.UserID("@events-a:example"), day) {
t.Fatal("player A should claim")
}
if !claimDailyEventSlot(id.UserID("@events-b:example"), day) {
t.Error("player B should claim independently of A")
}
}

View File

@@ -49,6 +49,22 @@ var blacksmithArena = []string{
"The Arena gear gets the good tools. I don't use these for everything. Just for things that matter.",
}
var blacksmithTemperGreetings = []string{
"_sets aside the repair work_ You want me to make something _more_ than it is. That's different. That's the good work.",
"Tempering. _rolls his shoulders_ I don't repair it. I take it apart and I convince it to come back stronger. It doesn't always want to.",
"_wipes the counter clean with one sweep_ Put it down. Right there. Let me see what it could be instead of what it is.",
"Anything can be pushed further. Anything. It's just a question of what you're willing to spend and how much heat it can take before it gives in.",
"_eyes light up_ Oh, you've come for the real thing. Not a patch. A _change_. Show me. Show me everything you've got.",
}
var blacksmithTemperCompletion = []string{
"_lifts it from the quench, steam everywhere_ Feel the weight of it now. It's not the same thing you handed me. It never will be again.",
"_breathing hard_ It fought me. Right to the end it fought me. And now look at it. Look what it became.",
"There. _sets it down with enormous care_ I pushed it further than it wanted to go, and it thanked me for it. They always do.",
"_holds it up, turning it slowly_ I've been doing this a long time and this one still surprised me. Take it before I change my mind about giving it back.",
"It's still hot. It'll be hot for a while. That's what happens when you ask something to become more than it was.",
}
var blacksmithFullCondition = []string{
"_looks it over_ There's nothing to do here. It doesn't need me. _sounds slightly disappointed_ Come back when it does.",
"Full condition. You've been taking care of it. Good. I appreciate that in a person.",
@@ -63,4 +79,3 @@ var blacksmithBrokenCondition = []string{
"_looks at the condition, looks at you, looks back at the condition_ You know it costs more when you let it get like this. Of course you know. You just didn't care. That's fine. I care enough for both of us. It'll cost you.",
"This could have been avoided with regular visits. _slides the cost estimate across the counter without breaking eye contact_",
}

View File

@@ -924,4 +924,4 @@ var DungeonExceptional = map[int][]string{
"in a dungeon. Write it down. " +
"The dungeon will tell this story differently.",
},
}
}

View File

@@ -275,6 +275,7 @@ var PetCatDeflect = []string{
"Your cat is already somewhere else.\n\nIt blinks.\n\nNot at you.\n\nAt nothing.\n\n" +
"The way cats do.",
}
// Fires randomly in the morning DM. Cat has left something.
// Results in a defense boost for the day.
// The cat is proud. The cat will never stop doing this.

View File

@@ -97,12 +97,12 @@ func TestAdvMasteryRowSegment(t *testing.T) {
func TestAdvMasteryRollup(t *testing.T) {
cases := []struct {
name string
used map[EquipmentSlot]int
wantCrossed int
wantMaxed int
wantBonus float64
wantAnyProgress bool
name string
used map[EquipmentSlot]int
wantCrossed int
wantMaxed int
wantBonus float64
wantAnyProgress bool
}{
{"empty", nil, 0, 0, 0, false},
{"sub-50 only", map[EquipmentSlot]int{SlotWeapon: 30}, 0, 0, 0, true},
@@ -240,12 +240,12 @@ func TestAdvTreasureIrreplaceable(t *testing.T) {
func TestRenderCraftingTeaser_BracketBoundaries(t *testing.T) {
cases := []struct {
name string
foraging int
craftsSucceeded int
wantEmpty bool
wantContainsAny []string // any of these (OR)
wantNotContains []string
name string
foraging int
craftsSucceeded int
wantEmpty bool
wantContainsAny []string // any of these (OR)
wantNotContains []string
}{
{"foraging 6 — out of pre-window", 6, 0, true, nil, nil},
{"foraging 7 — pre-unlock with 3 levels left, plural", 7, 0, false, []string{"3 Foraging level"}, []string{"unlocks in 1 ", "unlocks in 0"}},
@@ -297,7 +297,7 @@ func TestCraftingReminderWeekday_Spread(t *testing.T) {
func TestCraftingReminderWeekday_StableWithinWeek(t *testing.T) {
uid := id.UserID("@stable:test")
mon := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC) // Monday
mon := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC) // Monday
sun := time.Date(2026, 5, 10, 23, 0, 0, 0, time.UTC) // Sunday same ISO week
if craftingReminderWeekday(uid, mon) != craftingReminderWeekday(uid, sun) {
t.Error("weekday should be stable within an ISO week")

View File

@@ -207,7 +207,7 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
// Store pending interaction
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "hospital_pay",
Type: "hospital_pay",
Data: &advPendingHospitalPay{
Cost: afterInsurance,
Discounted: haggleOutcome == haggleWin,
@@ -277,7 +277,7 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad
gr := gamesRoom()
if gr != "" {
name, _ := loadDisplayName(char.UserID)
p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, name))
p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, name))
}
return nil
}

View File

@@ -144,10 +144,10 @@ func TestHouseHPBonus(t *testing.T) {
expected float64
}{
{0, 0},
{1, 0}, // Base house — no bonus
{2, 0.05}, // Livable
{3, 0.12}, // Comfortable
{4, 0.20}, // Established
{1, 0}, // Base house — no bonus
{2, 0.05}, // Livable
{3, 0.12}, // Comfortable
{4, 0.20}, // Established
}
for _, tt := range tests {
char := &AdventureCharacter{HouseTier: tt.tier}

View File

@@ -0,0 +1,96 @@
package plugin
import (
"fmt"
"testing"
)
// N1/A3 — every crafting ingredient must be obtainable from some live drop
// source. Phase R retired the legacy activity loop, which orphaned
// generateAdvLoot (reachable only from the now-uncalled resolveDungeonAction)
// and with it every ingredient in the game. rollZoneIngredient reconnects the
// legacy tables to zone combat; this test is the guard that keeps them
// reachable.
// liveIngredientSources returns every item name a player can obtain from the
// loot tables zone combat draws on, keyed to the tier it drops at.
func liveIngredientSources() map[string][]int {
sources := map[string][]int{}
for _, act := range advIngredientActivities {
table := advLootTable(act)
for tier, defs := range table {
for _, d := range defs {
sources[d.Name] = append(sources[d.Name], tier)
}
}
}
return sources
}
func TestEveryRecipeIngredientHasALiveSource(t *testing.T) {
sources := liveIngredientSources()
for _, recipe := range craftingRecipes {
for _, ing := range recipe.Ingredients {
if tiers, ok := sources[ing]; !ok || len(tiers) == 0 {
t.Errorf("recipe %q needs %q, which no live loot table drops",
recipe.Result, ing)
}
}
}
}
// The drop is keyed by zone tier, so an ingredient that only exists at
// legacy tier N is only reachable in a tier-N zone. Assert each recipe is
// completable by someone — i.e. every ingredient sits in tier 1..5.
func TestIngredientTiersAreReachableByZoneTier(t *testing.T) {
sources := liveIngredientSources()
for _, recipe := range craftingRecipes {
for _, ing := range recipe.Ingredients {
for _, tier := range sources[ing] {
if tier < 1 || tier > 5 {
t.Errorf("%q drops at tier %d, outside the zone tier range",
ing, tier)
}
}
}
}
}
// Every zone tier must be able to produce an ingredient, or that tier's
// players are cut out of crafting entirely.
func TestRollZoneIngredient_EveryTierCanDrop(t *testing.T) {
for tier := 1; tier <= 5; tier++ {
t.Run(fmt.Sprintf("tier%d", tier), func(t *testing.T) {
got := false
for i := 0; i < 500 && !got; i++ {
if item := rollZoneIngredient(tier); item != nil {
got = true
if item.Tier != tier {
t.Errorf("tier %d drop carried tier %d", tier, item.Tier)
}
if item.Value <= 0 {
t.Errorf("%q dropped with value %d", item.Name, item.Value)
}
}
}
if !got {
t.Errorf("tier %d never dropped an ingredient in 500 rolls", tier)
}
})
}
}
func TestJoinLootLines_SkipsEmpties(t *testing.T) {
if got := joinLootLines("", ""); got != "" {
t.Errorf("all-empty = %q, want empty", got)
}
if got := joinLootLines("a", ""); got != "a" {
t.Errorf("trailing empty = %q, want %q", got, "a")
}
if got := joinLootLines("", "b"); got != "b" {
t.Errorf("leading empty = %q, want %q", got, "b")
}
if got := joinLootLines("a", "b"); got != "a\nb" {
t.Errorf("both = %q, want %q", got, "a\nb")
}
}

View File

@@ -15,8 +15,8 @@ import (
type MasterworkDef struct {
Slot EquipmentSlot
Activity AdvActivityType
SkillSource string // "mining", "fishing", "foraging"
Tier int // 1-5, matches location tier
SkillSource string // "mining", "fishing", "foraging"
Tier int // 1-5, matches location tier
Name string
Description string // character sheet / trade listing
DropRate float64 // per-tier: 0.05, 0.04, 0.03, 0.02, 0.005
@@ -115,10 +115,37 @@ func masterworkDefFor(activity AdvActivityType, tier int) *MasterworkDef {
return nil
}
// masterworkSlotActivities are the three activity lines the catalog covers,
// one equipment slot each: mining→weapon, fishing→armor, foraging→boots.
var masterworkSlotActivities = []AdvActivityType{
AdvActivityMining, AdvActivityFishing, AdvActivityForaging,
}
// masterworkDefForZone picks a masterwork for a dungeon kill. The catalog is
// keyed by gathering activity — there is no dungeon line — so a zone drop
// rolls across all three slots and hands back that line's item at the zone's
// tier. The item keeps its own SkillSource: a masterwork blade found in a
// crypt still helps you mine.
func masterworkDefForZone(tier int) *MasterworkDef {
act := masterworkSlotActivities[rand.IntN(len(masterworkSlotActivities))]
return masterworkDefFor(act, tier)
}
// ── Drop Flavor Text (DM) ─────────────────────────────────────────────────
func masterworkDropFlavorText(activity AdvActivityType, tier int) string {
switch activity {
case AdvActivityDungeon:
switch {
case tier <= 2:
return "The thing you killed was carrying it, which raises the question of where it got it. You check the body for a maker's mark and find none. You check the body for anything else and find nothing. You take the gear and you do not think about the question."
case tier == 3:
return "It's propped against the wall behind the corpse, upright, deliberate. Not dropped. Placed. Someone came down here, set this down carefully, and did not come back for it. You pick it up. It fits your hand better than anything you paid for."
case tier == 4:
return "There's a pile of gear in the corner, most of it ruined, all of it belonging to people who came this far and no further. One piece is untouched. The rust stopped at its edge, as if the rust knew better. You add it to your kit and you leave the rest as you found it."
default:
return "It is the only thing in the room that isn't broken. Whatever lived here kept it, and kept it well, and cleaned it, and did not use it. You take it because you won. That is the arrangement. On the way out you do not turn around, and you tell yourself that is a choice."
}
case AdvActivityMining:
switch {
case tier <= 2:
@@ -164,9 +191,15 @@ func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, equip map[Equipm
return
}
def := masterworkDefFor(loc.Activity, loc.Tier)
// Dungeons have no catalog line of their own; they roll across all three.
var def *MasterworkDef
if loc.Activity == AdvActivityDungeon {
def = masterworkDefForZone(loc.Tier)
} else {
def = masterworkDefFor(loc.Activity, loc.Tier)
}
if def == nil {
return // no masterwork available for this activity+tier (e.g. dungeon)
return // no masterwork available for this activity+tier
}
// Roll for drop (chat level rare bonus applied additively)
@@ -250,8 +283,9 @@ func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, equip map[Equipm
sb.WriteString("_This doesn't come from the shop._\n\n")
}
// Flavor text
flavor := masterworkDropFlavorText(def.Activity, def.Tier)
// Flavor follows where the item was found, not which catalog line it came
// from — a dungeon drop must not open with a pickaxe.
flavor := masterworkDropFlavorText(loc.Activity, def.Tier)
if flavor != "" {
sb.WriteString(fmt.Sprintf("_%s_\n\n", flavor))
}

View File

@@ -0,0 +1,68 @@
package plugin
import (
"strings"
"testing"
)
// N1/A2 — the masterwork catalog is keyed to gathering activities, so the
// zone-combat seam had nothing to look up. masterworkDefForZone bridges it.
func TestMasterworkDefForZone_CoversEveryTier(t *testing.T) {
for tier := 1; tier <= 5; tier++ {
if def := masterworkDefForZone(tier); def == nil {
t.Errorf("tier %d has no masterwork def", tier)
} else if def.Tier != tier {
t.Errorf("tier %d returned a tier-%d def", tier, def.Tier)
}
}
}
// A dungeon drop must be able to land in any of the three slots — otherwise
// zone play would only ever upgrade one piece of gear.
func TestMasterworkDefForZone_RollsAllSlots(t *testing.T) {
seen := map[EquipmentSlot]bool{}
for i := 0; i < 300; i++ {
if def := masterworkDefForZone(3); def != nil {
seen[def.Slot] = true
}
}
for _, slot := range []EquipmentSlot{SlotWeapon, SlotArmor, SlotBoots} {
if !seen[slot] {
t.Errorf("slot %v never rolled in 300 draws", slot)
}
}
}
// The pre-existing lookup must keep working for the gathering activities.
func TestMasterworkDefFor_GatheringUnchanged(t *testing.T) {
for _, act := range masterworkSlotActivities {
for tier := 1; tier <= 5; tier++ {
def := masterworkDefFor(act, tier)
if def == nil {
t.Fatalf("%s tier %d lost its def", act, tier)
}
if def.Activity != act {
t.Errorf("%s tier %d returned activity %s", act, tier, def.Activity)
}
}
}
}
// Flavor follows the place, not the catalog line. A crypt boss must not
// narrate a pickaxe striking ore.
func TestMasterworkDropFlavorText_DungeonHasItsOwnVoice(t *testing.T) {
gatheringWords := []string{"pickaxe", "fishing", "foraging", "vein", "ore", "branch"}
for tier := 1; tier <= 5; tier++ {
text := masterworkDropFlavorText(AdvActivityDungeon, tier)
if text == "" {
t.Fatalf("dungeon tier %d has no flavor", tier)
}
lower := strings.ToLower(text)
for _, w := range gatheringWords {
if strings.Contains(lower, w) {
t.Errorf("dungeon tier %d flavor leaks gathering word %q", tier, w)
}
}
}
}

View File

@@ -16,11 +16,11 @@ import (
// ── NPC Constants ──────────────────────────────────────────────────────────
const (
mistyCost = 100
arinaCost = 5000
npcCooldownDays = 7
mistyCost = 100
arinaCost = 5000
npcCooldownDays = 7
npcEncounterChance = 0.075 // 7.5%
npcBuffDuration = 7 * 24 * time.Hour
npcBuffDuration = 7 * 24 * time.Hour
// Arena effect chances per round
mistyEffectChance = 0.20 // 20%
@@ -337,10 +337,10 @@ func (p *AdventurePlugin) resolveArina(ctx MessageContext, char *AdventureCharac
// Returns: extra text to append to round log, enemy HP modifier, player damage taken.
type npcArenaResult struct {
Text string
EnemyDmg int // damage dealt to enemy
PlayerDmg int // damage dealt to player
SniperKill bool // enemy instant kill
CondRepair int // equipment condition repair amount
EnemyDmg int // damage dealt to enemy
PlayerDmg int // damage dealt to player
SniperKill bool // enemy instant kill
CondRepair int // equipment condition repair amount
}
func npcCheckArenaEffects(char *AdventureCharacter, monsterName string) *npcArenaResult {

View File

@@ -729,12 +729,12 @@ type AdvPlayerDaySummary struct {
ForagingSkill int
FishingSkill int
Activity string
Location string
Outcome string
LootValue int64
IsDead bool
DeadUntil string
Activity string
Location string
Outcome string
LootValue int64
IsDead bool
DeadUntil string
// DeadUntilHours is the integer hours-from-now until revival, used by
// the standout-death template's {hours} placeholder. Computed when
// the summary row is built; 0 if not dead or already past revival.
@@ -744,11 +744,11 @@ type AdvPlayerDaySummary struct {
// {duration} placeholder for templates that want precision over the
// rounded {hours} count.
DeadUntilDuration string
IsResting bool
SummaryLine string
HolidayActions int // 0 = not holiday or no action; 1 = took one; 2 = took both
DeathSource string
DeathLocation string
IsResting bool
SummaryLine string
HolidayActions int // 0 = not holiday or no action; 1 = took one; 2 = took both
DeathSource string
DeathLocation string
}
// advPlayerHeadline renders the per-player headline for the daily report.
@@ -920,7 +920,6 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
sb.WriteString("\n")
}
// Standout
if bestPlayer != nil && bestPlayer.LootValue > 0 {
pool := SummaryStandoutGood
@@ -984,8 +983,8 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
// Sort by score
type entry struct {
Name string
Score int
Name string
Score int
Levels string
Streak int
}

View File

@@ -389,6 +389,9 @@ func (p *AdventurePlugin) midnightTicker() {
slog.Error("adventure: midnight reset failed, will retry next tick", "err", err)
continue
}
// Close out the arena season if one just ended. Self-dedups on the
// season key, so this is a cheap no-op on every other night.
p.arenaSeasonRollover(time.Now().UTC())
db.MarkJobCompleted(jobName, dateKey)
lastRanDate = dateKey
}

View File

@@ -281,17 +281,17 @@ func TestLuigiCategoryIntros_Coverage(t *testing.T) {
func TestLuigiFlavorPools_NonEmpty(t *testing.T) {
pools := map[string][]string{
"luigiGreetings": luigiGreetings,
"luigiPurchaseConfirm": luigiPurchaseConfirm,
"luigiTier5Confirm": luigiTier5Confirm,
"luigiComboConfirm": luigiComboConfirm,
"luigiGreetings": luigiGreetings,
"luigiPurchaseConfirm": luigiPurchaseConfirm,
"luigiTier5Confirm": luigiTier5Confirm,
"luigiComboConfirm": luigiComboConfirm,
"luigiInsufficientFunds": luigiInsufficientFunds,
"luigiBrowseTimeout": luigiBrowseTimeout,
"luigiMaxedOut": luigiMaxedOut,
"luigiMasterworkAck": luigiMasterworkAck,
"luigiShowAllComment": luigiShowAllComment,
"luigiCommentary": luigiCommentary,
"luigiCancellation": luigiCancellation,
"luigiBrowseTimeout": luigiBrowseTimeout,
"luigiMaxedOut": luigiMaxedOut,
"luigiMasterworkAck": luigiMasterworkAck,
"luigiShowAllComment": luigiShowAllComment,
"luigiCommentary": luigiCommentary,
"luigiCancellation": luigiCancellation,
}
for name, pool := range pools {
if len(pool) == 0 {

View File

@@ -0,0 +1,406 @@
package plugin
import (
"fmt"
"log/slog"
"strings"
"time"
"maunium.net/go/mautrix/id"
)
// ── Tempering ───────────────────────────────────────────────────────────────
//
// The blacksmith's endgame role: push an owned magic item one rung up the
// rarity ladder (gogobee_engagement_plan.md B1). Rarity flows through the
// existing scalar formulas in magic_items_gameplay.go, so tempering adds no
// new combat math — and because it caps at Legendary it accelerates reaching
// the existing best-in-slot ceiling rather than raising it. Every duplicate
// drop becomes a candidate instead of vendor trash.
//
// Rarity itself lives on the registry definition, so a tempered instance
// records its progress as a `temper` step count on its own row; effective
// rarity is derived on read (see temperedItem).
// temperMaterialNames are the legendary crafting materials the final rung
// consumes. Both drop as UniqueAlways entries on their T5 zone slate, so one
// clear of the Underforge or the Abyss Portal yields exactly one.
var temperMaterialNames = []string{"Thyraks Core", "Portal Fragment"}
// temperStepCost is what one rung costs, keyed by the *target* rarity's tier.
// The euro costs and foraging gates mirror the crafting recipe tiers in
// adventure_consumables.go, so the blacksmith reads as an extension of the
// crafting ladder rather than a parallel economy.
type temperStepCost struct {
Euros int
MinForaging int
NeedsMaterial bool
}
// Only the final rung demands a T5 material. Gating the lower rungs on one too
// would make tempering unreachable until a player already clears T5 content —
// which is exactly when they stop needing it.
var temperCosts = map[int]temperStepCost{
2: {Euros: 10_000, MinForaging: 15},
3: {Euros: 25_000, MinForaging: 20},
4: {Euros: 60_000, MinForaging: 25},
5: {Euros: 150_000, MinForaging: 30, NeedsMaterial: true},
}
// temperTarget is one temperable item, from either side of the equip line.
type temperTarget struct {
Equipped bool
Slot DnDSlot // set when Equipped
InvID int64 // set when not Equipped
Base MagicItem
Temper int
}
// Effective is the item as it stands today, before this temper.
func (t temperTarget) Effective() MagicItem { return temperedItem(t.Base, t.Temper) }
// NextRarity is the rung this temper would buy.
func (t temperTarget) NextRarity() DnDRarity { return temperedRarity(t.Base.Rarity, t.Temper+1) }
// cost is the price of this target's next rung.
func (t temperTarget) cost() temperStepCost { return temperCosts[rarityLootTierNum(t.NextRarity())] }
// sameItemAs reports whether other is the same physical instance, used to
// re-find a target after a fresh reload so a concurrent equip/sell can't let a
// stale pending confirm temper the wrong thing.
func (t temperTarget) sameItemAs(other temperTarget) bool {
if t.Equipped != other.Equipped {
return false
}
if t.Equipped {
return t.Slot == other.Slot && t.Base.ID == other.Base.ID
}
return t.InvID == other.InvID
}
type advPendingTemperPick struct {
Targets []temperTarget
}
type advPendingTemperConfirm struct {
Target temperTarget
Material *AdvItem // consumed on confirm; nil when the rung needs none
}
// collectTemperTargets gathers every magic item the player owns that still has
// a rung ahead of it — worn or stowed. Potions and scrolls are excluded: they
// carry a zero combat effect, so a rarity bump would buy nothing.
func collectTemperTargets(userID id.UserID) ([]temperTarget, error) {
var out []temperTarget
equipped, err := loadEquippedMagicItems(userID)
if err != nil {
return nil, err
}
for _, ds := range dndSlotOrder {
e, ok := equipped[ds]
if !ok || e.Item.ID == "" {
continue
}
if temperStepsToLegendary(e.Item.Rarity, e.Temper) == 0 {
continue
}
out = append(out, temperTarget{Equipped: true, Slot: ds, Base: e.Item, Temper: e.Temper})
}
items, err := loadAdvInventory(userID)
if err != nil {
return nil, err
}
for _, it := range items {
if it.Type != "magic_item" {
continue
}
mi, ok := magicItemFromAdvItem(it)
if !ok || mi.Kind == MagicItemPotion || mi.Kind == MagicItemScroll {
continue
}
if temperStepsToLegendary(mi.Rarity, it.Temper) == 0 {
continue
}
out = append(out, temperTarget{InvID: it.ID, Base: mi, Temper: it.Temper})
}
return out, nil
}
// findTemperMaterial returns the first legendary material row in inventory.
func findTemperMaterial(items []AdvItem) (AdvItem, bool) {
for _, it := range items {
for _, name := range temperMaterialNames {
if it.Name == name {
return it, true
}
}
}
return AdvItem{}, false
}
// parseTemperIndex reads a leading 1-indexed number and bounds-checks it.
func parseTemperIndex(reply string, n int) (int, bool) {
idx, parsed := 0, false
for _, c := range strings.TrimSpace(reply) {
if c < '0' || c > '9' {
break
}
idx = idx*10 + int(c-'0')
parsed = true
}
idx--
if !parsed || idx < 0 || idx >= n {
return 0, false
}
return idx, true
}
// ── Command ─────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleTemperCmd(ctx MessageContext) error {
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
targets, err := collectTemperTargets(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to read your curios.")
}
if len(targets) == 0 {
return p.SendDM(ctx.Sender,
"_He looks over your kit and shrugs._ Nothing here I can push any further. "+
"Bring me something that isn't already Legendary.")
}
greeting, _ := advPickFlavor(blacksmithTemperGreetings, ctx.Sender, "temper_greet")
var sb strings.Builder
sb.WriteString("🔨 **Tempering**\n\n")
sb.WriteString(greeting)
sb.WriteString("\n\n")
for i, t := range targets {
cur := t.Effective()
cost := t.cost()
where := "stowed"
if t.Equipped {
where = "worn — " + string(t.Slot)
}
gate := ""
switch {
case char.ForagingSkill < cost.MinForaging:
gate = fmt.Sprintf(" — 🔒 needs Foraging %d", cost.MinForaging)
case cost.NeedsMaterial:
gate = " — needs a legendary material"
}
sb.WriteString(fmt.Sprintf("%d. **%s** _(%s → %s)_ — €%d _(%s)_%s\n",
i+1, cur.Name, cur.Rarity, t.NextRarity(), cost.Euros, where, gate))
}
sb.WriteString("\nReply with a number to temper it, or \"cancel\".")
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "temper_pick",
Data: &advPendingTemperPick{Targets: targets},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
return p.SendDM(ctx.Sender, sb.String())
}
func (p *AdventurePlugin) resolveTemperPick(ctx MessageContext, interaction *advPendingInteraction) error {
data := interaction.Data.(*advPendingTemperPick)
reply := strings.TrimSpace(ctx.Body)
if strings.EqualFold(reply, "cancel") {
return p.SendDM(ctx.Sender, "_The forge keeps burning._ Come back when you've decided.")
}
idx, ok := parseTemperIndex(reply, len(data.Targets))
if !ok {
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, "Reply with a number from the list, or \"cancel\".")
}
return p.buildTemperConfirm(ctx.Sender, data.Targets[idx])
}
// buildTemperConfirm re-checks every gate against fresh state before quoting a
// price, then parks a confirm. executeTemper re-checks them all again — this
// pass exists to give the player a real error message, not to be trusted.
func (p *AdventurePlugin) buildTemperConfirm(userID id.UserID, t temperTarget) error {
char, _, err := p.ensureCharacter(userID)
if err != nil {
return p.SendDM(userID, "Failed to load your character.")
}
cost := t.cost()
if char.ForagingSkill < cost.MinForaging {
return p.SendDM(userID, fmt.Sprintf(
"_He turns the piece over, then sets it down._ I could ruin this. Come back when you know your "+
"materials better — **Foraging %d**. You're at %d.", cost.MinForaging, char.ForagingSkill))
}
items, err := loadAdvInventory(userID)
if err != nil {
return p.SendDM(userID, "Failed to read your inventory.")
}
var material *AdvItem
if cost.NeedsMaterial {
mat, ok := findTemperMaterial(items)
if !ok {
return p.SendDM(userID, fmt.Sprintf(
"_He exhales through his teeth._ Legendary is a different conversation. I need a **%s** on this "+
"table before I touch it. They come out of the deepest places — the Underforge, the Portal.",
strings.Join(temperMaterialNames, "** or a **")))
}
material = &mat
}
if bal := p.euro.GetBalance(userID); bal < float64(cost.Euros) {
return p.SendDM(userID, fmt.Sprintf(
"_He names the price without apology._ €%d. You have €%.0f. The forge doesn't run on good intentions.",
cost.Euros, bal))
}
cur := t.Effective()
next := temperedItem(t.Base, t.Temper+1)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🔨 **Temper %s?**\n\n", cur.Name))
sb.WriteString(fmt.Sprintf(" %s _(%s)_ → %s _(%s)_\n", cur.Name, cur.Rarity, next.Name, next.Rarity))
sb.WriteString(fmt.Sprintf(" %s\n ↳ %s\n\n", magicItemEffectSummary(cur), magicItemEffectSummary(next)))
sb.WriteString(fmt.Sprintf(" Cost: **€%d**", cost.Euros))
if material != nil {
sb.WriteString(fmt.Sprintf(" + one **%s**", material.Name))
}
sb.WriteString("\n\nReply \"yes\" to hand it over, or anything else to keep it as it is.")
p.pending.Store(string(userID), &advPendingInteraction{
Type: "temper_confirm",
Data: &advPendingTemperConfirm{Target: t, Material: material},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
return p.SendDM(userID, sb.String())
}
func (p *AdventurePlugin) resolveTemperConfirm(ctx MessageContext, interaction *advPendingInteraction) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
data := interaction.Data.(*advPendingTemperConfirm)
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
if reply != "yes" && reply != "y" && reply != "confirm" {
return p.SendDM(ctx.Sender, "_He nods once and turns back to the fire._ It's a good piece as it is.")
}
return p.executeTemper(ctx.Sender, data)
}
// executeTemper re-validates against fresh state, then charges and applies.
// Order matters: the euro debit is the only atomic guard we have, so it goes
// first; the material and the temper write both roll back onto it.
func (p *AdventurePlugin) executeTemper(userID id.UserID, data *advPendingTemperConfirm) error {
char, _, err := p.ensureCharacter(userID)
if err != nil {
return p.SendDM(userID, "Failed to load your character.")
}
// Re-find the target. Between the confirm prompt and this reply the
// player may have equipped, sold, or already tempered it.
fresh, err := collectTemperTargets(userID)
if err != nil {
return p.SendDM(userID, "Failed to read your curios.")
}
var target temperTarget
found := false
for _, t := range fresh {
if t.sameItemAs(data.Target) {
target, found = t, true
break
}
}
if !found {
return p.SendDM(userID, "_He looks at the empty table._ That piece isn't here any more.")
}
if target.Temper != data.Target.Temper {
return p.SendDM(userID, "_He raises an eyebrow._ Someone's already been at this one. Ask me again.")
}
cost := target.cost()
if char.ForagingSkill < cost.MinForaging {
return p.SendDM(userID, fmt.Sprintf("You need **Foraging %d** for that.", cost.MinForaging))
}
// Re-locate the material by row id — a concurrent craft may have eaten it.
var material *AdvItem
if cost.NeedsMaterial {
items, err := loadAdvInventory(userID)
if err != nil {
return p.SendDM(userID, "Failed to read your inventory.")
}
mat, ok := findTemperMaterial(items)
if !ok {
return p.SendDM(userID, "_He taps the empty spot on the table._ The material's gone. Find another.")
}
material = &mat
}
if !p.euro.Debit(userID, float64(cost.Euros), "temper") {
return p.SendDM(userID, fmt.Sprintf("Payment failed — tempering costs €%d.", cost.Euros))
}
refund := func(reason string) {
p.euro.Credit(userID, float64(cost.Euros), "temper_refund")
slog.Error("temper: rolled back", "user", userID, "item", target.Base.ID, "reason", reason)
}
if material != nil {
if err := removeAdvInventoryItem(material.ID); err != nil {
refund("material consume failed")
return p.SendDM(userID, "Something went wrong at the forge. Your money is back.")
}
}
newTemper := target.Temper + 1
if target.Equipped {
err = temperEquippedItem(userID, target.Slot, newTemper)
} else {
bumped := temperedItem(target.Base, newTemper)
err = temperInventoryItem(target.InvID, newTemper, rarityLootTierNum(bumped.Rarity), int64(bumped.Value))
}
if err != nil {
refund("temper write failed")
if material != nil {
if rbErr := addAdvInventoryItem(userID, *material); rbErr != nil {
slog.Error("temper: material rollback failed",
"user", userID, "material", material.Name, "err", rbErr)
}
}
return p.SendDM(userID, "Something went wrong at the forge. Your money is back.")
}
result := temperedItem(target.Base, newTemper)
line, _ := advPickFlavor(blacksmithTemperCompletion, userID, "temper_done")
var sb strings.Builder
sb.WriteString(line)
sb.WriteString(fmt.Sprintf("\n\n🔨 **%s** is now **%s**.\n%s\n",
result.Name, result.Rarity, magicItemEffectSummary(result)))
if remaining := temperStepsToLegendary(target.Base.Rarity, newTemper); remaining > 0 {
sb.WriteString(fmt.Sprintf("\n_%d more temper%s would make it Legendary._",
remaining, pluralS(remaining)))
}
if err := p.SendDM(userID, sb.String()); err != nil {
return err
}
if result.Rarity == RarityLegendary {
if p.achievements != nil {
p.achievements.GrantAchievement(userID, "temper_legendary")
}
if gr := gamesRoom(); gr != "" {
displayName, _ := loadDisplayName(userID)
p.SendMessage(id.RoomID(gr), fmt.Sprintf(
"🔨 The forge in town ran hot all night. **%s** walked out with a **Legendary %s**.",
displayName, result.Name))
}
}
return nil
}

View File

@@ -0,0 +1,355 @@
package plugin
import (
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// TestTemperLadderWalk pins the rung order and the Legendary clamp. VeryRare
// must fold onto Epic — it shares Epic's tier and power scalar, so treating it
// as its own rung would sell a step that changes no number.
func TestTemperLadderWalk(t *testing.T) {
tests := []struct {
base DnDRarity
steps int
want DnDRarity
}{
{RarityCommon, 0, RarityCommon},
{RarityCommon, 1, RarityUncommon},
{RarityCommon, 4, RarityLegendary},
{RarityCommon, 99, RarityLegendary}, // clamp, never past the ceiling
{RarityRare, 1, RarityEpic},
{RarityEpic, 1, RarityLegendary},
{RarityLegendary, 1, RarityLegendary},
{RarityVeryRare, 0, RarityVeryRare}, // untempered items keep their label
{RarityVeryRare, 1, RarityLegendary},
{RarityUncommon, -1, RarityUncommon}, // negative steps are inert
}
for _, tc := range tests {
if got := temperedRarity(tc.base, tc.steps); got != tc.want {
t.Errorf("temperedRarity(%s, %d) = %s, want %s", tc.base, tc.steps, got, tc.want)
}
}
}
func TestTemperStepsToLegendary(t *testing.T) {
tests := []struct {
base DnDRarity
steps int
want int
}{
{RarityCommon, 0, 4},
{RarityCommon, 2, 2},
{RarityCommon, 4, 0},
{RarityCommon, 10, 0},
{RarityVeryRare, 0, 1}, // folds onto Epic, one rung short
{RarityLegendary, 0, 0},
}
for _, tc := range tests {
if got := temperStepsToLegendary(tc.base, tc.steps); got != tc.want {
t.Errorf("temperStepsToLegendary(%s, %d) = %d, want %d", tc.base, tc.steps, got, tc.want)
}
}
}
// TestTemperCostsCoverEveryRung guards against a ladder edit that leaves a rung
// priced at the zero value — a free temper.
func TestTemperCostsCoverEveryRung(t *testing.T) {
for rung := 1; rung < len(temperLadder); rung++ {
target := temperLadder[rung]
cost, ok := temperCosts[rarityLootTierNum(target)]
if !ok {
t.Fatalf("no temper cost for target rarity %s", target)
}
if cost.Euros <= 0 || cost.MinForaging <= 0 {
t.Errorf("temper to %s is free: %+v", target, cost)
}
}
// Only the Legendary rung consumes a material.
for tier, cost := range temperCosts {
if want := tier == 5; cost.NeedsMaterial != want {
t.Errorf("tier %d NeedsMaterial = %v, want %v", tier, cost.NeedsMaterial, want)
}
}
}
// TestTemperedItemScalesPowerAndValue asserts a tempered item is exactly an
// item of its effective rarity — the whole point of B1 is that rarity flows
// through the existing scalar formulas with no new combat math.
func TestTemperedItemScalesPowerAndValue(t *testing.T) {
base := MagicItem{ID: "x", Name: "Thing", Kind: MagicItemWeapon, Rarity: RarityRare, Value: 300}
if got := temperedItem(base, 0); got != base {
t.Errorf("temper 0 mutated the item: %+v", got)
}
up := temperedItem(base, 1)
if up.Rarity != RarityEpic {
t.Fatalf("rarity = %s, want Epic", up.Rarity)
}
// Value tracks the power axis: Rare=3 → Epic=4.
if wantVal := int(300 * (rarityPowerScalar(RarityEpic) / rarityPowerScalar(RarityRare))); up.Value != wantVal {
t.Errorf("value = %d, want %d", up.Value, wantVal)
}
// A tempered Epic must fight exactly like a native Epic. (Its Value is
// deliberately higher than a native Epic definition's — value tracks the
// power axis, and the player paid to move it there.)
native := base
native.Rarity = RarityEpic
if magicItemEffectFor(up) != magicItemEffectFor(native) {
t.Errorf("tempered effect diverges from native Epic effect")
}
// Base must be untouched — temperedItem takes a copy.
if base.Rarity != RarityRare || base.Value != 300 {
t.Errorf("temperedItem mutated its argument: %+v", base)
}
}
// TestTemperNeverDoubleBumps is the invariant the whole design hangs on: the
// stored row keeps BASE rarity plus a step count, so loading and re-saving an
// item any number of times must not compound the upgrade.
func TestTemperNeverDoubleBumps(t *testing.T) {
base := MagicItem{ID: "x", Name: "Thing", Kind: MagicItemWeapon, Rarity: RarityCommon, Value: 100}
e := EquippedMagicItem{Item: base, Temper: 2}
first := e.Effective()
// Simulate a load→save→load cycle: Item stays base, Temper stays put.
again := EquippedMagicItem{Item: e.Item, Temper: e.Temper}.Effective()
if first != again {
t.Errorf("round-trip changed the item: %+v vs %+v", first, again)
}
if e.Item.Rarity != RarityCommon {
t.Errorf("Effective() mutated the stored base rarity to %s", e.Item.Rarity)
}
if first.Rarity != RarityRare {
t.Errorf("Common + 2 tempers = %s, want Rare", first.Rarity)
}
}
// TestTemperSurvivesEquipRoundTrip: a tempered item that gets swapped back to
// inventory must carry its temper with it, priced at its effective rarity.
// This is the path resolveMagicEquipReply takes on a slot swap.
func TestTemperSurvivesEquipRoundTrip(t *testing.T) {
base := MagicItem{ID: "x", Name: "Thing", Kind: MagicItemWeapon, Rarity: RarityCommon, Value: 100}
back := magicItemSellAt(base, 3)
if back.Temper != 3 {
t.Errorf("swap-back lost temper: %d", back.Temper)
}
if back.Tier != rarityLootTierNum(RarityEpic) {
t.Errorf("swap-back tier = %d, want Epic tier", back.Tier)
}
if back.Value <= int64(base.Value) {
t.Errorf("swap-back value %d not scaled up from %d", back.Value, base.Value)
}
// An untempered sell must be byte-identical to the old behaviour.
if magicItemSellAt(base, 0) != magicItemSell(base) {
t.Errorf("magicItemSellAt(_, 0) diverges from magicItemSell")
}
}
// TestTemperPersistence exercises the two DB writes: an equipped item's temper
// and a stowed item's temper both survive a reload, and loadEquippedMagicItems
// hands back a base-rarity Item with the step count beside it.
func TestTemperPersistence(t *testing.T) {
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
user := id.UserID("@temper:test.invalid")
var item MagicItem
for _, mi := range magicItemRegistry {
if mi.Slot != "" && mi.Rarity == RarityCommon {
item = mi
break
}
}
if item.ID == "" {
for _, mi := range magicItemRegistry {
if mi.Slot != "" && temperStepsToLegendary(mi.Rarity, 0) > 0 {
item = mi
break
}
}
}
if item.ID == "" {
t.Skip("no slotted sub-Legendary item in registry")
}
if err := equipMagicItem(user, item.Slot, item.ID, false, 0); err != nil {
t.Fatal(err)
}
if err := temperEquippedItem(user, item.Slot, 1); err != nil {
t.Fatal(err)
}
equipped, err := loadEquippedMagicItems(user)
if err != nil {
t.Fatal(err)
}
e := equipped[item.Slot]
if e.Temper != 1 {
t.Fatalf("equipped temper = %d, want 1", e.Temper)
}
if e.Item.Rarity != item.Rarity {
t.Errorf("stored Item.Rarity = %s, want the untouched base %s", e.Item.Rarity, item.Rarity)
}
if want := temperedRarity(item.Rarity, 1); e.Effective().Rarity != want {
t.Errorf("Effective().Rarity = %s, want %s", e.Effective().Rarity, want)
}
// Stowed side: a fresh inventory row defaults to temper 0, then bumps.
inv := AdvItem{Name: item.Name, Type: "magic_item", Tier: 1, Value: 100,
SkillSource: "magic_item:" + item.ID}
if err := addAdvInventoryItem(user, inv); err != nil {
t.Fatal(err)
}
items, err := loadAdvInventory(user)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].Temper != 0 {
t.Fatalf("fresh inventory row should default to temper 0, got %+v", items)
}
bumped := temperedItem(item, 2)
if err := temperInventoryItem(items[0].ID, 2, rarityLootTierNum(bumped.Rarity), int64(bumped.Value)); err != nil {
t.Fatal(err)
}
items, _ = loadAdvInventory(user)
if items[0].Temper != 2 {
t.Errorf("inventory temper = %d, want 2", items[0].Temper)
}
if items[0].Tier != rarityLootTierNum(bumped.Rarity) {
t.Errorf("inventory tier = %d, want %d", items[0].Tier, rarityLootTierNum(bumped.Rarity))
}
}
// TestCollectTemperTargetsFiltering: Legendary items and consumable-kind magic
// items (potions/scrolls, whose combat effect is zero) are not offerable.
func TestCollectTemperTargetsFiltering(t *testing.T) {
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
user := id.UserID("@filter:test.invalid")
var legendary, potion, upgradable MagicItem
for _, mi := range magicItemRegistry {
if legendary.ID == "" && mi.Rarity == RarityLegendary && mi.Slot != "" {
legendary = mi
}
if potion.ID == "" && (mi.Kind == MagicItemPotion || mi.Kind == MagicItemScroll) {
potion = mi
}
if upgradable.ID == "" && mi.Slot != "" && mi.Kind == MagicItemWeapon &&
temperStepsToLegendary(mi.Rarity, 0) > 0 {
upgradable = mi
}
}
add := func(mi MagicItem) {
if mi.ID == "" {
return
}
if err := addAdvInventoryItem(user, AdvItem{
Name: mi.Name, Type: "magic_item", Tier: rarityLootTierNum(mi.Rarity),
Value: int64(mi.Value), SkillSource: "magic_item:" + mi.ID,
}); err != nil {
t.Fatal(err)
}
}
add(legendary)
add(potion)
add(upgradable)
targets, err := collectTemperTargets(user)
if err != nil {
t.Fatal(err)
}
for _, tg := range targets {
if legendary.ID != "" && tg.Base.ID == legendary.ID {
t.Errorf("Legendary %s offered for tempering", legendary.Name)
}
if potion.ID != "" && tg.Base.ID == potion.ID {
t.Errorf("consumable %s offered for tempering", potion.Name)
}
}
if upgradable.ID != "" {
found := false
for _, tg := range targets {
if tg.Base.ID == upgradable.ID {
found = true
}
}
if !found {
t.Errorf("upgradable %s (%s) was not offered", upgradable.Name, upgradable.Rarity)
}
}
}
// TestTemperMaterialsAreObtainable guards the economy: every material name the
// final rung demands must actually drop from some zone's loot slate. If a slate
// is renamed, tempering to Legendary silently becomes impossible.
func TestTemperMaterialsAreObtainable(t *testing.T) {
live := map[string]bool{}
for _, zone := range allZones() {
for _, entry := range zone.Loot {
live[titleCaseUnderscored(entry.ItemID)] = true
}
}
for _, name := range temperMaterialNames {
if !live[name] {
t.Errorf("temper material %q drops from no zone loot slate", name)
}
}
}
// TestFindTemperMaterial picks the first material regardless of which one.
func TestFindTemperMaterial(t *testing.T) {
if _, ok := findTemperMaterial(nil); ok {
t.Error("found a material in an empty inventory")
}
if _, ok := findTemperMaterial([]AdvItem{{Name: "Iron Ore"}}); ok {
t.Error("Iron Ore accepted as a legendary material")
}
for _, name := range temperMaterialNames {
got, ok := findTemperMaterial([]AdvItem{{Name: "Iron Ore"}, {ID: 7, Name: name}})
if !ok || got.ID != 7 {
t.Errorf("did not find material %q", name)
}
}
}
func TestParseTemperIndex(t *testing.T) {
tests := []struct {
in string
n int
want int
wantOK bool
}{
{"1", 3, 0, true},
{"3", 3, 2, true},
{" 2 ", 3, 1, true},
{"2. Thing", 3, 1, true},
{"0", 3, 0, false},
{"4", 3, 0, false},
{"", 3, 0, false},
{"cancel", 3, 0, false},
}
for _, tc := range tests {
got, ok := parseTemperIndex(tc.in, tc.n)
if ok != tc.wantOK || (ok && got != tc.want) {
t.Errorf("parseTemperIndex(%q, %d) = (%d, %v), want (%d, %v)",
tc.in, tc.n, got, ok, tc.want, tc.wantOK)
}
}
}

View File

@@ -231,13 +231,13 @@ func TestCalculateAdvProbabilities_BetterGearReducesDeath(t *testing.T) {
func TestAdvEffectiveSkill(t *testing.T) {
char := &AdventureCharacter{
CombatLevel: 10,
MiningSkill: 15,
CombatLevel: 10,
MiningSkill: 15,
ForagingSkill: 20,
}
bonuses := &AdvBonusSummary{
CombatBonus: 5,
MiningBonus: 3,
CombatBonus: 5,
MiningBonus: 3,
ForagingBonus: 0,
}

View File

@@ -246,12 +246,17 @@ var advAllTreasures = map[int][]AdvTreasureDef{
// and the effective drop rate, so callers can surface near-miss feedback
// ("rolled 1.8% vs 1.5% chance — just missed"). Players never see treasure
// math otherwise, which makes rare drops feel mythical.
func rollAdvTreasureDropDetailed(tier int, userID id.UserID, chatLevel int) (drop *AdvTreasureDrop, roll, rate float64) {
//
// weight scales the base rate for the moment that produced the roll: a boss
// kill is worth more than a corridor skirmish. The chat-level bonus is added
// after scaling so it stays a flat contribution rather than being multiplied
// up alongside it.
func rollAdvTreasureDropDetailed(tier int, userID id.UserID, chatLevel int, weight float64) (drop *AdvTreasureDrop, roll, rate float64) {
r, ok := advTreasureDropRates[tier]
if !ok {
return nil, 0, 0
}
rate = r + chatLevelRareBonus(chatLevel)
rate = r*weight + chatLevelRareBonus(chatLevel)
roll = rand.Float64()
if roll >= rate {
@@ -283,7 +288,7 @@ func rollAdvTreasureDropDetailed(tier int, userID id.UserID, chatLevel int) (dro
// rollAdvTreasureDrop is the legacy single-return variant used by call sites
// that don't surface near-miss feedback (auto-babysit, twinbee shares).
func rollAdvTreasureDrop(tier int, userID id.UserID, chatLevel int) *AdvTreasureDrop {
d, _, _ := rollAdvTreasureDropDetailed(tier, userID, chatLevel)
d, _, _ := rollAdvTreasureDropDetailed(tier, userID, chatLevel, 1)
return d
}

View File

@@ -0,0 +1,81 @@
package plugin
import (
"testing"
"gogobee/internal/db"
)
// N1/A1 — treasure drops were orphaned by the Phase R transition and now
// hang off zone combat. The weight is what makes that safe: expedition
// combat rolls far more often than the one-a-day legacy activity the base
// rates were tuned for, so only boss/elite moments get a multiplier.
func TestAdvTreasureDropRate_ScalesWithWeight(t *testing.T) {
// A roll under the (weighted) rate reaches the duplicate check, which
// reads the treasure table.
if err := db.Init(t.TempDir()); err != nil {
t.Fatalf("db.Init: %v", err)
}
const user = "@weight:example.org"
base := advTreasureDropRates[1]
cases := []struct {
name string
weight float64
want float64
}{
{"standard kill", advTreasureWeightStandard, base},
{"elite doubles", advTreasureWeightElite, base * 2},
{"boss quadruples", advTreasureWeightBoss, base * 4},
{"zone clear is one bonus roll", advTreasureWeightZoneClear, base},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, _, rate := rollAdvTreasureDropDetailed(1, user, 0, c.weight)
if rate != c.want {
t.Fatalf("rate for weight %v = %v, want %v", c.weight, rate, c.want)
}
})
}
}
func TestAdvTreasureDropRate_ZeroWeightNeverDrops(t *testing.T) {
for i := 0; i < 200; i++ {
drop, _, rate := rollAdvTreasureDropDetailed(1, "@zero:example.org", 0, 0)
if drop != nil || rate != 0 {
t.Fatalf("weight 0 produced drop=%v rate=%v", drop, rate)
}
}
}
// A forced roll must actually yield a treasure — this is the "the seam is
// live again" assertion. A weight above 1/rate drives the effective rate
// past 1.0, so every roll lands on the drop path.
func TestAdvTreasureDropDetailed_ForcedRollGrantsTreasure(t *testing.T) {
if err := db.Init(t.TempDir()); err != nil {
t.Fatalf("db.Init: %v", err)
}
drop, _, _ := rollAdvTreasureDropDetailed(1, "@forced:example.org", 0, 1000)
if drop == nil || drop.Def == nil {
t.Fatal("forced roll produced no treasure")
}
if drop.Def.Tier != 1 {
t.Fatalf("tier-1 roll produced a tier-%d treasure", drop.Def.Tier)
}
}
// The treasure and masterwork systems predate zones and speak AdvLocation.
func TestAdvLocForZone(t *testing.T) {
loc := advLocForZone(ZoneGoblinWarrens)
if loc.Activity != AdvActivityDungeon {
t.Errorf("activity = %q, want dungeon", loc.Activity)
}
if want := zoneTierFromID(ZoneGoblinWarrens); loc.Tier != want {
t.Errorf("tier = %d, want %d", loc.Tier, want)
}
if loc.Name == "" || loc.Name == string(ZoneGoblinWarrens) {
t.Errorf("name = %q, want the zone's display name", loc.Name)
}
}

View File

@@ -322,11 +322,11 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe
// ── TwinBee Gifts (Temporary Buffs) ──────────────────────────────────────────
type twinBeeGiftDef struct {
BuffType string
BuffName string
Modifier float64
Duration time.Duration
Flavor string
BuffType string
BuffName string
Modifier float64
Duration time.Duration
Flavor string
}
var twinBeeGifts = []twinBeeGiftDef{

View File

@@ -20,15 +20,15 @@ import (
// jikanAnime represents an anime entry from the Jikan API.
type jikanAnime struct {
MalID int `json:"mal_id"`
URL string `json:"url"`
Title string `json:"title"`
TitleEng string `json:"title_english"`
Type string `json:"type"`
Episodes int `json:"episodes"`
Status string `json:"status"`
MalID int `json:"mal_id"`
URL string `json:"url"`
Title string `json:"title"`
TitleEng string `json:"title_english"`
Type string `json:"type"`
Episodes int `json:"episodes"`
Status string `json:"status"`
Score float64 `json:"score"`
Synopsis string `json:"synopsis"`
Synopsis string `json:"synopsis"`
Aired struct {
String string `json:"string"`
From string `json:"from"`

View File

@@ -29,51 +29,51 @@ const (
thLinkmasterMinMsgs = 50
// Temporal
thNightOwlPct = 40
thNightOwlMinMsgs = 100
thEarlyBirdPct = 40
thNightOwlPct = 40
thNightOwlMinMsgs = 100
thEarlyBirdPct = 40
thEarlyBirdMinMsgs = 100
// Emotional (LLM-gated)
thEmotionalMinClassified = 100
thCheerleaderPosPct = 50
thPhilosopherNeutPct = 40
thPhilosopherQPct = 10
thPhilosopherAvgWords = 8
thAgitatorNegPct = 30
thAgitatorMinMsgs = 200
thWildcardStdDev = 0.5
thWildcardMinClassified = 150
thHypeMachineExclPct = 20
thHypeMachinePosPct = 60
thEmotionalMinClassified = 100
thCheerleaderPosPct = 50
thPhilosopherNeutPct = 40
thPhilosopherQPct = 10
thPhilosopherAvgWords = 8
thAgitatorNegPct = 30
thAgitatorMinMsgs = 200
thWildcardStdDev = 0.5
thWildcardMinClassified = 150
thHypeMachineExclPct = 20
thHypeMachinePosPct = 60
// Economy
thBrokeSpiritedMaxBalance = 100.0
thDegenerateMinLosses = 10
// Games
thSharkWinRate = 55
thSharkMinGames = 15
thWordleMinPuzzles = 10
thArenaChampMinTier = 4
thArenaChampWinRate = 50
thArenaCowardMinRuns = 3
thArenaCowardMaxAvgTier = 2
thTriviaNerdMinCorrect = 10
thSharkWinRate = 55
thSharkMinGames = 15
thWordleMinPuzzles = 10
thArenaChampMinTier = 4
thArenaChampWinRate = 50
thArenaCowardMinRuns = 3
thArenaCowardMaxAvgTier = 2
thTriviaNerdMinCorrect = 10
// Adventure
thAdvMinDays = 10
thAdvDiverseMinDays = 15
thAdvDiverseMinTypes = 3
thAdvDiverseMaxShare = 40
thAdvMinDays = 10
thAdvDiverseMinDays = 15
thAdvDiverseMinTypes = 3
thAdvDiverseMaxShare = 40
thGearheadMinMasterwork = 3
// Communication (vocabulary)
thWordsmithMinFancyWords = 10
// Social
thPatronMinRepGiven = 5
thPatronRatioMultiplier = 2
thPatronMinRepGiven = 5
thPatronRatioMultiplier = 2
// Display
maxDisplayArchetypes = 6
@@ -83,13 +83,13 @@ const (
var archetypeFlavors = map[string]string{
// Communication
"Novelist": "Writes in paragraphs. Has opinions. Probably re-reads their own messages.",
"Minimalist": "Says a lot with very little. You're never sure if they're fine or not.",
"Inquisitor": "Always asking. Never satisfied with the first answer. Probably has follow-ups.",
"Enthusiast": "Genuinely excited about things. All the things. Possibly all at once.",
"Chatterbox": "Has thoughts. Many thoughts. Shares them all. You wouldn't have it any other way.",
"Linkmaster": "The community's unofficial curator. Their tab count is not your business.",
"Wordsmith": "Uses words most people have to look up. The thesaurus fears them.",
"Novelist": "Writes in paragraphs. Has opinions. Probably re-reads their own messages.",
"Minimalist": "Says a lot with very little. You're never sure if they're fine or not.",
"Inquisitor": "Always asking. Never satisfied with the first answer. Probably has follow-ups.",
"Enthusiast": "Genuinely excited about things. All the things. Possibly all at once.",
"Chatterbox": "Has thoughts. Many thoughts. Shares them all. You wouldn't have it any other way.",
"Linkmaster": "The community's unofficial curator. Their tab count is not your business.",
"Wordsmith": "Uses words most people have to look up. The thesaurus fears them.",
// Temporal
"Night Owl": "Awake when they probably shouldn't be. Thriving despite all evidence.",
@@ -103,8 +103,8 @@ var archetypeFlavors = map[string]string{
"Hype Machine": "Arrived and immediately made everything louder. The room is better for it.",
// Economy
"Whale": "Has money. Spends money. Has more money somehow. The math is unclear.",
"Degenerate": "Knows exactly what they're doing. Does it anyway. Respects it.",
"Whale": "Has money. Spends money. Has more money somehow. The math is unclear.",
"Degenerate": "Knows exactly what they're doing. Does it anyway. Respects it.",
"Broke But Spirited": "Down but not out. The pot fears them anyway.",
// Games
@@ -221,16 +221,16 @@ type userData struct {
userID string
// user_stats
totalMsgs int
totalWords int
totalLinks int
totalImages int
totalMsgs int
totalWords int
totalLinks int
totalImages int
totalQuestions int
totalExcl int
totalEmojis int
nightMsgs int
morningMsgs int
fancyWords int
totalExcl int
totalEmojis int
nightMsgs int
morningMsgs int
fancyWords int
// sentiment_stats
sentPositive int
@@ -242,7 +242,7 @@ type userData struct {
sentVariance float64
// economy
balance float64
balance float64
gamblingLosses int
recentGaming bool
@@ -251,7 +251,7 @@ type userData struct {
hmPlayed, hmWon int
holdemPlayed int
holdemNetPositive bool
unoSoloPlayed, unoSoloWon int
unoSoloPlayed, unoSoloWon int
unoMultiPlayed, unoMultiWon int
wordlePlayed int
triviaCorrect int
@@ -263,13 +263,13 @@ type userData struct {
arenaAvgCashTier float64
// adventure
advDays int
advActivities map[string]int // activity_type -> count
advDays int
advActivities map[string]int // activity_type -> count
masterworkCount int
// social
repGiven int
repReceived int
repGiven int
repReceived int
reactionsGiven int
}

View File

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

View File

@@ -39,9 +39,23 @@ func TestEnemyAttackProfile_Registered(t *testing.T) {
}
}
// testCombatState seats a single actor, the shape every direct-primitive test
// wants. The engine reads per-actor state through the embedded cursor, so a
// combatState with a nil actor panics on the first st.playerHP touch.
func testCombatState(playerHP, enemyHP, round int, rng *rand.Rand) *combatState {
a := &actor{playerHP: playerHP}
return &combatState{
actor: a,
actors: []*actor{a},
enemyHP: enemyHP,
round: round,
rng: rng,
}
}
func TestTurnAbilityFires(t *testing.T) {
enemy := baseEnemy() // MaxHP 60
st := &combatState{rng: rand.New(rand.NewPCG(1, 1))}
st := testCombatState(0, 0, 0, rand.New(rand.NewPCG(1, 1)))
cases := []struct {
name string
@@ -78,10 +92,7 @@ func TestTurnAbilityFires(t *testing.T) {
// within applyAbility with no persistent state.
func TestApplyAbility_Slice2Effects(t *testing.T) {
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
st := &combatState{
playerHP: playerHP, enemyHP: enemyHP, round: 1,
rng: rand.New(rand.NewPCG(7, 7)),
}
st := testCombatState(playerHP, enemyHP, 1, rand.New(rand.NewPCG(7, 7)))
return st, basePlayer(), baseEnemy()
}
phase := &turnCombatPhase
@@ -136,10 +147,7 @@ func TestApplyAbility_Slice2Effects(t *testing.T) {
// all but evade), and the shared resolution primitives read that state.
func TestApplyAbility_Slice3Effects(t *testing.T) {
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
st := &combatState{
playerHP: playerHP, enemyHP: enemyHP, round: 1,
rng: rand.New(rand.NewPCG(9, 9)),
}
st := testCombatState(playerHP, enemyHP, 1, rand.New(rand.NewPCG(9, 9)))
return st, basePlayer(), baseEnemy()
}
phase := &turnCombatPhase
@@ -188,7 +196,8 @@ func TestApplyAbility_Slice3Effects(t *testing.T) {
}
// enemyDown lets survive_at_1 cheat death exactly once.
stS := &combatState{enemyHP: 0, enemySurviveArmed: true}
stS := testCombatState(100, 0, 1, rand.New(rand.NewPCG(13, 13)))
stS.enemySurviveArmed = true
if enemyDown(stS, "Duel") {
t.Error("survive_at_1: armed enemy at 0 HP should not be down")
}
@@ -224,10 +233,7 @@ func TestApplyAbility_Slice3Effects(t *testing.T) {
// itself, and the shared resolution primitives / helpers read that state.
func TestApplyAbility_Slice4Effects(t *testing.T) {
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
st := &combatState{
playerHP: playerHP, enemyHP: enemyHP, round: 1,
rng: rand.New(rand.NewPCG(11, 11)),
}
st := testCombatState(playerHP, enemyHP, 1, rand.New(rand.NewPCG(11, 11)))
return st, basePlayer(), baseEnemy()
}
phase := &turnCombatPhase

View File

@@ -14,327 +14,327 @@ package plugin
func buildTunedBestiarySRD() map[string]DnDMonsterTemplate {
return map[string]DnDMonsterTemplate{
"awakened_shrub": {ID: "awakened_shrub", Name: "Awakened Shrub", CR: 0, HP: 10, AC: 10, Attack: 1, AttackBonus: 1, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: False Appearance"},
"baboon": {ID: "baboon", Name: "Baboon", CR: 0, HP: 3, AC: 12, Attack: 1, AttackBonus: 1, Speed: 12, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Pack Tactics"},
"badger": {ID: "badger", Name: "Badger", CR: 0, HP: 3, AC: 10, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Smell"},
"bat": {ID: "bat", Name: "Bat", CR: 0, HP: 1, AC: 12, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Echolocation, Keen Hearing"},
"cat": {ID: "cat", Name: "Cat", CR: 0, HP: 2, AC: 12, Attack: 1, AttackBonus: 0, Speed: 16, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Smell"},
"commoner": {ID: "commoner", Name: "Commoner", CR: 0, HP: 4, AC: 10, Attack: 1, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"crab": {ID: "crab", Name: "Crab", CR: 0, HP: 2, AC: 11, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious"},
"deer": {ID: "deer", Name: "Deer", CR: 0, HP: 4, AC: 13, Attack: 1, AttackBonus: 2, Speed: 18, BlockRate: 0.05, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"eagle": {ID: "eagle", Name: "Eagle", CR: 0, HP: 3, AC: 12, Attack: 1, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Sight"},
"frog": {ID: "frog", Name: "Frog", CR: 0, HP: 1, AC: 11, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Amphibious, Standing Leap"},
"giant_fire_beetle": {ID: "giant_fire_beetle", Name: "Giant Fire Beetle", CR: 0, HP: 4, AC: 13, Attack: 1, AttackBonus: 1, Speed: 12, BlockRate: 0.05, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Illumination"},
"goat": {ID: "goat", Name: "Goat", CR: 0, HP: 4, AC: 10, Attack: 1, AttackBonus: 3, Speed: 16, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge, Sure-Footed"},
"hawk": {ID: "hawk", Name: "Hawk", CR: 0, HP: 1, AC: 13, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0.05, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Sight"},
"homunculus": {ID: "homunculus", Name: "Homunculus", CR: 0, HP: 5, AC: 13, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0.05, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Telepathic Bond"},
"hyena": {ID: "hyena", Name: "Hyena", CR: 0, HP: 5, AC: 11, Attack: 1, AttackBonus: 2, Speed: 18, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Pack Tactics"},
"jackal": {ID: "jackal", Name: "Jackal", CR: 0, HP: 3, AC: 12, Attack: 1, AttackBonus: 1, Speed: 16, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Hearing and Smell, Pack Tactics"},
"lemure": {ID: "lemure", Name: "Lemure", CR: 0, HP: 13, AC: 10, Attack: 1, AttackBonus: 3, Speed: 6, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Regeneration", Phase: "any", ProcChance: 0.5, Effect: "regenerate"}, Notes: "SRD-tuned baseline — Regeneration (regenerate) wired from traits. Traits: Devil's Sight, Hellish Rejuvenation"},
"lizard": {ID: "lizard", Name: "Lizard", CR: 0, HP: 2, AC: 10, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"octopus": {ID: "octopus", Name: "Octopus", CR: 0, HP: 3, AC: 12, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Hold Breath, Underwater Camouflage, Water Breathing"},
"owl": {ID: "owl", Name: "Owl", CR: 0, HP: 1, AC: 11, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Flyby, Keen Hearing and Sight"},
"quipper": {ID: "quipper", Name: "Quipper", CR: 0, HP: 1, AC: 13, Attack: 1, AttackBonus: 0, Speed: 16, BlockRate: 0.05, XPValue: 10, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Blood Frenzy, Water Breathing"},
"rat": {ID: "rat", Name: "Rat", CR: 0, HP: 1, AC: 10, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Smell"},
"raven": {ID: "raven", Name: "Raven", CR: 0, HP: 1, AC: 12, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Mimicry"},
"scorpion": {ID: "scorpion", Name: "Scorpion", CR: 0, HP: 1, AC: 11, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"sea_horse": {ID: "sea_horse", Name: "Sea Horse", CR: 0, HP: 1, AC: 11, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Water Breathing"},
"shrieker": {ID: "shrieker", Name: "Shrieker", CR: 0, HP: 13, AC: 10, Attack: 1, AttackBonus: 0, Speed: 10, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: False Appearance"},
"spider": {ID: "spider", Name: "Spider", CR: 0, HP: 1, AC: 12, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Spider Climb, Web Sense, Web Walker"},
"vulture": {ID: "vulture", Name: "Vulture", CR: 0, HP: 5, AC: 10, Attack: 1, AttackBonus: 2, Speed: 6, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Sight and Smell, Pack Tactics"},
"weasel": {ID: "weasel", Name: "Weasel", CR: 0, HP: 1, AC: 13, Attack: 1, AttackBonus: 0, Speed: 12, BlockRate: 0.05, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Hearing and Smell"},
"bandit": {ID: "bandit", Name: "Bandit", CR: 0.125, HP: 11, AC: 12, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"blood_hawk": {ID: "blood_hawk", Name: "Blood Hawk", CR: 0.125, HP: 7, AC: 12, Attack: 1, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 25, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Sight, Pack Tactics"},
"camel": {ID: "camel", Name: "Camel", CR: 0.125, HP: 15, AC: 10, Attack: 1, AttackBonus: 5, Speed: 18, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"cultist": {ID: "cultist", Name: "Cultist", CR: 0.125, HP: 9, AC: 12, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Dark Devotion"},
"flying_snake": {ID: "flying_snake", Name: "Flying Snake", CR: 0.125, HP: 5, AC: 14, Attack: 1, AttackBonus: 0, Speed: 12, BlockRate: 0.05, XPValue: 25, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Flyby"},
"giant_crab": {ID: "giant_crab", Name: "Giant Crab", CR: 0.125, HP: 13, AC: 15, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0.1, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious"},
"giant_rat": {ID: "giant_rat", Name: "Giant Rat", CR: 0.125, HP: 7, AC: 12, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 25, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Smell, Pack Tactics"},
"giant_rat_diseased": {ID: "giant_rat_diseased", Name: "Giant Rat", CR: 0.125, HP: 7, AC: 12, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"giant_weasel": {ID: "giant_weasel", Name: "Giant Weasel", CR: 0.125, HP: 9, AC: 13, Attack: 1, AttackBonus: 5, Speed: 16, BlockRate: 0.05, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Hearing and Smell"},
"guard": {ID: "guard", Name: "Guard", CR: 0.125, HP: 11, AC: 16, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0.1, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"kobold": {ID: "kobold", Name: "Kobold", CR: 0.125, HP: 5, AC: 12, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 25, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Sunlight Sensitivity, Pack Tactics"},
"mastiff": {ID: "mastiff", Name: "Mastiff", CR: 0.125, HP: 5, AC: 12, Attack: 1, AttackBonus: 3, Speed: 16, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Hearing and Smell"},
"merfolk": {ID: "merfolk", Name: "Merfolk", CR: 0.125, HP: 11, AC: 11, Attack: 1, AttackBonus: 2, Speed: 6, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious"},
"mule": {ID: "mule", Name: "Mule", CR: 0.125, HP: 11, AC: 10, Attack: 1, AttackBonus: 4, Speed: 16, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Beast of Burden, Sure-Footed"},
"noble": {ID: "noble", Name: "Noble", CR: 0.125, HP: 9, AC: 15, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0.1, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"poisonous_snake": {ID: "poisonous_snake", Name: "Poisonous Snake", CR: 0.125, HP: 2, AC: 13, Attack: 1, AttackBonus: 0, Speed: 12, BlockRate: 0.05, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"pony": {ID: "pony", Name: "Pony", CR: 0.125, HP: 11, AC: 10, Attack: 1, AttackBonus: 4, Speed: 16, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"stirge": {ID: "stirge", Name: "Stirge", CR: 0.125, HP: 2, AC: 14, Attack: 1, AttackBonus: 5, Speed: 6, BlockRate: 0.05, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"tribal_warrior": {ID: "tribal_warrior", Name: "Tribal Warrior", CR: 0.125, HP: 11, AC: 12, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 25, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Pack Tactics"},
"acolyte": {ID: "acolyte", Name: "Acolyte", CR: 0.25, HP: 9, AC: 10, Attack: 1, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Spellcasting"},
"axe_beak": {ID: "axe_beak", Name: "Axe Beak", CR: 0.25, HP: 19, AC: 11, Attack: 1, AttackBonus: 4, Speed: 18, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"blink_dog": {ID: "blink_dog", Name: "Blink Dog", CR: 0.25, HP: 22, AC: 13, Attack: 1, AttackBonus: 3, Speed: 16, BlockRate: 0.05, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Hearing and Smell"},
"boar": {ID: "boar", Name: "Boar", CR: 0.25, HP: 11, AC: 11, Attack: 1, AttackBonus: 3, Speed: 16, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge, Relentless (Recharges after a Short or Long Rest)"},
"constrictor_snake": {ID: "constrictor_snake", Name: "Constrictor Snake", CR: 0.25, HP: 13, AC: 12, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"draft_horse": {ID: "draft_horse", Name: "Draft Horse", CR: 0.25, HP: 19, AC: 10, Attack: 1, AttackBonus: 6, Speed: 16, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"dretch": {ID: "dretch", Name: "Dretch", CR: 0.25, HP: 18, AC: 11, Attack: 1, AttackBonus: 2, Speed: 8, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dretch makes two attacks: one with its bite and one with its claws."},
"drow": {ID: "drow", Name: "Drow", CR: 0.25, HP: 13, AC: 15, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0.1, XPValue: 50, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Fey Ancestry, Innate Spellcasting, Sunlight Sensitivity"},
"elk": {ID: "elk", Name: "Elk", CR: 0.25, HP: 13, AC: 10, Attack: 1, AttackBonus: 0, Speed: 18, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge"},
"flying_sword": {ID: "flying_sword", Name: "Flying Sword", CR: 0.25, HP: 17, AC: 17, Attack: 1, AttackBonus: 3, Speed: 18, BlockRate: 0.1, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Antimagic Susceptibility, False Appearance"},
"giant_badger": {ID: "giant_badger", Name: "Giant Badger", CR: 0.25, HP: 13, AC: 10, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The badger makes two attacks: one with its bite and one with its claws. Traits: Keen Smell"},
"giant_bat": {ID: "giant_bat", Name: "Giant Bat", CR: 0.25, HP: 22, AC: 13, Attack: 1, AttackBonus: 4, Speed: 6, BlockRate: 0.05, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Echolocation, Keen Hearing"},
"giant_centipede": {ID: "giant_centipede", Name: "Giant Centipede", CR: 0.25, HP: 4, AC: 13, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"giant_frog": {ID: "giant_frog", Name: "Giant Frog", CR: 0.25, HP: 18, AC: 11, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Amphibious, Standing Leap"},
"giant_lizard": {ID: "giant_lizard", Name: "Giant Lizard", CR: 0.25, HP: 19, AC: 12, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Variant: Hold Breath, Variant: Spider Climb"},
"giant_owl": {ID: "giant_owl", Name: "Giant Owl", CR: 0.25, HP: 19, AC: 12, Attack: 1, AttackBonus: 3, Speed: 6, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Flyby, Keen Hearing and Sight"},
"giant_poisonous_snake": {ID: "giant_poisonous_snake", Name: "Giant Poisonous Snake", CR: 0.25, HP: 11, AC: 14, Attack: 1, AttackBonus: 6, Speed: 12, BlockRate: 0.05, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"giant_wolf_spider": {ID: "giant_wolf_spider", Name: "Giant Wolf Spider", CR: 0.25, HP: 11, AC: 13, Attack: 1, AttackBonus: 3, Speed: 16, BlockRate: 0.05, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Spider Climb, Web Sense, Web Walker"},
"goblin": {ID: "goblin", Name: "Goblin", CR: 0.25, HP: 7, AC: 15, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0.1, XPValue: 50, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Nimble Escape"},
"grimlock": {ID: "grimlock", Name: "Grimlock", CR: 0.25, HP: 11, AC: 11, Attack: 1, AttackBonus: 5, Speed: 12, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Blind Senses, Keen Hearing and Smell, Stone Camouflage"},
"panther": {ID: "panther", Name: "Panther", CR: 0.25, HP: 13, AC: 12, Attack: 1, AttackBonus: 4, Speed: 18, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Keen Smell, Pounce"},
"pseudodragon": {ID: "pseudodragon", Name: "Pseudodragon", CR: 0.25, HP: 7, AC: 13, Attack: 1, AttackBonus: 4, Speed: 6, BlockRate: 0.05, XPValue: 50, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Traits: Keen Senses, Magic Resistance, Limited Telepathy, Variant: Familiar"},
"riding_horse": {ID: "riding_horse", Name: "Riding Horse", CR: 0.25, HP: 13, AC: 10, Attack: 1, AttackBonus: 5, Speed: 18, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"skeleton": {ID: "skeleton", Name: "Skeleton", CR: 0.25, HP: 13, AC: 13, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"sprite": {ID: "sprite", Name: "Sprite", CR: 0.25, HP: 2, AC: 15, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0.1, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"steam_mephit": {ID: "steam_mephit", Name: "Steam Mephit", CR: 0.25, HP: 21, AC: 10, Attack: 1, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Traits: Death Burst, Innate Spellcasting (1/Day)"},
"swarm_of_bats": {ID: "swarm_of_bats", Name: "Swarm of Bats", CR: 0.25, HP: 22, AC: 12, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Echolocation, Keen Hearing, Swarm"},
"swarm_of_rats": {ID: "swarm_of_rats", Name: "Swarm of Rats", CR: 0.25, HP: 24, AC: 10, Attack: 1, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Smell, Swarm"},
"swarm_of_ravens": {ID: "swarm_of_ravens", Name: "Swarm of Ravens", CR: 0.25, HP: 24, AC: 12, Attack: 1, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Swarm"},
"violet_fungus": {ID: "violet_fungus", Name: "Violet Fungus", CR: 0.25, HP: 18, AC: 10, Attack: 1, AttackBonus: 2, Speed: 6, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The fungus makes 1d4 Rotting Touch attacks. Traits: False Appearance"},
"wolf": {ID: "wolf", Name: "Wolf", CR: 0.25, HP: 11, AC: 13, Attack: 1, AttackBonus: 4, Speed: 16, BlockRate: 0.05, XPValue: 50, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Hearing and Smell, Pack Tactics"},
"zombie": {ID: "zombie", Name: "Zombie", CR: 0.25, HP: 22, AC: 10, Attack: 1, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Undead Fortitude", Phase: "decisive", ProcChance: 0.5, Effect: "survive_at_1"}, Notes: "SRD-tuned baseline — Undead Fortitude (survive_at_1) wired from traits. Traits: Undead Fortitude"},
"ape": {ID: "ape", Name: "Ape", CR: 0.5, HP: 19, AC: 12, Attack: 2, AttackBonus: 5, Speed: 12, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The ape makes two fist attacks."},
"black_bear": {ID: "black_bear", Name: "Black Bear", CR: 0.5, HP: 19, AC: 11, Attack: 2, AttackBonus: 3, Speed: 16, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The bear makes two attacks: one with its bite and one with its claws. Traits: Keen Smell"},
"cockatrice": {ID: "cockatrice", Name: "Cockatrice", CR: 0.5, HP: 27, AC: 11, Attack: 2, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"crocodile": {ID: "crocodile", Name: "Crocodile", CR: 0.5, HP: 19, AC: 12, Attack: 2, AttackBonus: 4, Speed: 8, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Hold Breath"},
"darkmantle": {ID: "darkmantle", Name: "Darkmantle", CR: 0.5, HP: 22, AC: 11, Attack: 2, AttackBonus: 5, Speed: 6, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Echolocation, False Appearance"},
"deep_gnome_svirfneblin": {ID: "deep_gnome_svirfneblin", Name: "Deep Gnome", CR: 0.5, HP: 16, AC: 15, Attack: 2, AttackBonus: 4, Speed: 8, BlockRate: 0.1, XPValue: 100, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Stone Camouflage, Gnome Cunning, Innate Spellcasting"},
"dust_mephit": {ID: "dust_mephit", Name: "Dust Mephit", CR: 0.5, HP: 17, AC: 12, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Traits: Death Burst, Innate Spellcasting (1/Day)"},
"giant_goat": {ID: "giant_goat", Name: "Giant Goat", CR: 0.5, HP: 19, AC: 11, Attack: 2, AttackBonus: 5, Speed: 16, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge, Sure-Footed"},
"giant_sea_horse": {ID: "giant_sea_horse", Name: "Giant Sea Horse", CR: 0.5, HP: 16, AC: 13, Attack: 2, AttackBonus: 3, Speed: 16, BlockRate: 0.05, XPValue: 100, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge, Water Breathing"},
"giant_wasp": {ID: "giant_wasp", Name: "Giant Wasp", CR: 0.5, HP: 13, AC: 12, Attack: 2, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"gnoll": {ID: "gnoll", Name: "Gnoll", CR: 0.5, HP: 22, AC: 15, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0.1, XPValue: 100, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Rampage"},
"gray_ooze": {ID: "gray_ooze", Name: "Gray Ooze", CR: 0.5, HP: 22, AC: 10, Attack: 2, AttackBonus: 3, Speed: 6, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amorphous, Corrode Metal, False Appearance"},
"hobgoblin": {ID: "hobgoblin", Name: "Hobgoblin", CR: 0.5, HP: 11, AC: 18, Attack: 2, AttackBonus: 3, Speed: 12, BlockRate: 0.15, XPValue: 100, Ability: &MonsterAbility{Name: "Martial Advantage", Phase: "any", ProcChance: 0.35, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Martial Advantage (bonus_damage) wired from traits. Traits: Martial Advantage"},
"ice_mephit": {ID: "ice_mephit", Name: "Ice Mephit", CR: 0.5, HP: 21, AC: 11, Attack: 2, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Traits: Death Burst, False Appearance, Innate Spellcasting (1/Day)"},
"lizardfolk": {ID: "lizardfolk", Name: "Lizardfolk", CR: 0.5, HP: 22, AC: 15, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0.1, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The lizardfolk makes two melee attacks, each one with a different weapon. Traits: Hold Breath"},
"magma_mephit": {ID: "magma_mephit", Name: "Magma Mephit", CR: 0.5, HP: 22, AC: 11, Attack: 2, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Traits: Death Burst, False Appearance, Innate Spellcasting (1/Day)"},
"magmin": {ID: "magmin", Name: "Magmin", CR: 0.5, HP: 9, AC: 14, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 100, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Traits: Death Burst, Ignited Illumination", FireAttacker: true},
"orc": {ID: "orc", Name: "Orc", CR: 0.5, HP: 15, AC: 13, Attack: 2, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 100, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Aggressive"},
"reef_shark": {ID: "reef_shark", Name: "Reef Shark", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 4, Speed: 16, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Pack Tactics, Water Breathing"},
"rust_monster": {ID: "rust_monster", Name: "Rust Monster", CR: 0.5, HP: 27, AC: 14, Attack: 2, AttackBonus: 3, Speed: 16, BlockRate: 0.05, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Iron Scent, Rust Metal"},
"sahuagin": {ID: "sahuagin", Name: "Sahuagin", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Multiattack: The sahuagin makes two melee attacks: one with its bite and one with its claws or spear. Traits: Blood Frenzy, Limited Amphibiousness, Shark Telepathy"},
"satyr": {ID: "satyr", Name: "Satyr", CR: 0.5, HP: 31, AC: 14, Attack: 2, AttackBonus: 3, Speed: 16, BlockRate: 0.05, XPValue: 100, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Traits: Magic Resistance"},
"scout": {ID: "scout", Name: "Scout", CR: 0.5, HP: 16, AC: 13, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The scout makes two melee attacks or two ranged attacks. Traits: Keen Hearing and Sight"},
"shadow": {ID: "shadow", Name: "Shadow", CR: 0.5, HP: 16, AC: 12, Attack: 2, AttackBonus: 4, Speed: 16, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Surprise Attack", Phase: "opening", ProcChance: 0.8, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Surprise Attack (bonus_damage) wired from traits. Traits: Amorphous, Shadow Stealth, Sunlight Weakness"},
"swarm_of_beetles": {ID: "swarm_of_beetles", Name: "Swarm of Beetles", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Swarm"},
"swarm_of_centipedes": {ID: "swarm_of_centipedes", Name: "Swarm of Centipedes", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Swarm"},
"swarm_of_insects": {ID: "swarm_of_insects", Name: "Swarm of Insects", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Swarm"},
"swarm_of_spiders": {ID: "swarm_of_spiders", Name: "Swarm of Spiders", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Swarm, Spider Climb, Web Sense, Web Walker"},
"swarm_of_wasps": {ID: "swarm_of_wasps", Name: "Swarm of Wasps", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 3, Speed: 6, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Swarm"},
"thug": {ID: "thug", Name: "Thug", CR: 0.5, HP: 32, AC: 11, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Multiattack: The thug makes two melee attacks. Traits: Pack Tactics"},
"warhorse": {ID: "warhorse", Name: "Warhorse", CR: 0.5, HP: 19, AC: 11, Attack: 2, AttackBonus: 6, Speed: 18, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Trampling Charge"},
"warhorse_skeleton": {ID: "warhorse_skeleton", Name: "Warhorse Skeleton", CR: 0.5, HP: 22, AC: 13, Attack: 2, AttackBonus: 6, Speed: 18, BlockRate: 0.05, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"worg": {ID: "worg", Name: "Worg", CR: 0.5, HP: 26, AC: 13, Attack: 2, AttackBonus: 5, Speed: 18, BlockRate: 0.05, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Hearing and Smell"},
"animated_armor": {ID: "animated_armor", Name: "Animated Armor", CR: 1, HP: 33, AC: 18, Attack: 2, AttackBonus: 4, Speed: 10, BlockRate: 0.15, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The armor makes two melee attacks. Traits: Antimagic Susceptibility, False Appearance"},
"brass_dragon_wyrmling": {ID: "brass_dragon_wyrmling", Name: "Brass Dragon Wyrmling", CR: 1, HP: 16, AC: 16, Attack: 2, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat).", FireAttacker: true},
"brown_bear": {ID: "brown_bear", Name: "Brown Bear", CR: 1, HP: 34, AC: 11, Attack: 2, AttackBonus: 5, Speed: 16, BlockRate: 0, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The bear makes two attacks: one with its bite and one with its claws. Traits: Keen Smell"},
"bugbear": {ID: "bugbear", Name: "Bugbear", CR: 1, HP: 27, AC: 16, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0.1, XPValue: 200, Ability: &MonsterAbility{Name: "Surprise Attack", Phase: "opening", ProcChance: 0.8, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Surprise Attack (bonus_damage) wired from traits. Traits: Brute, Surprise Attack"},
"copper_dragon_wyrmling": {ID: "copper_dragon_wyrmling", Name: "Copper Dragon Wyrmling", CR: 1, HP: 22, AC: 16, Attack: 2, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"death_dog": {ID: "death_dog", Name: "Death Dog", CR: 1, HP: 39, AC: 12, Attack: 2, AttackBonus: 4, Speed: 16, BlockRate: 0, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dog makes two bite attacks. Traits: Two-Headed"},
"dire_wolf": {ID: "dire_wolf", Name: "Dire Wolf", CR: 1, HP: 37, AC: 14, Attack: 2, AttackBonus: 5, Speed: 18, BlockRate: 0.05, XPValue: 200, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Hearing and Smell, Pack Tactics"},
"dryad": {ID: "dryad", Name: "Dryad", CR: 1, HP: 22, AC: 11, Attack: 2, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Innate Spellcasting, Magic Resistance, Speak with Beasts and Plants, Tree Stride"},
"duergar": {ID: "duergar", Name: "Duergar", CR: 1, HP: 26, AC: 16, Attack: 2, AttackBonus: 4, Speed: 10, BlockRate: 0.1, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Duergar Resilience, Sunlight Sensitivity"},
"ghoul": {ID: "ghoul", Name: "Ghoul", CR: 1, HP: 22, AC: 12, Attack: 2, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"giant_eagle": {ID: "giant_eagle", Name: "Giant Eagle", CR: 1, HP: 26, AC: 13, Attack: 2, AttackBonus: 5, Speed: 6, BlockRate: 0.05, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The eagle makes two attacks: one with its beak and one with its talons. Traits: Keen Sight"},
"giant_hyena": {ID: "giant_hyena", Name: "Giant Hyena", CR: 1, HP: 45, AC: 12, Attack: 2, AttackBonus: 5, Speed: 18, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Rampage"},
"giant_octopus": {ID: "giant_octopus", Name: "Giant Octopus", CR: 1, HP: 52, AC: 11, Attack: 2, AttackBonus: 5, Speed: 6, BlockRate: 0, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Hold Breath, Underwater Camouflage, Water Breathing"},
"giant_spider": {ID: "giant_spider", Name: "Giant Spider", CR: 1, HP: 26, AC: 14, Attack: 2, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Spider Climb, Web Sense, Web Walker"},
"giant_toad": {ID: "giant_toad", Name: "Giant Toad", CR: 1, HP: 39, AC: 11, Attack: 2, AttackBonus: 4, Speed: 8, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Amphibious, Standing Leap"},
"giant_vulture": {ID: "giant_vulture", Name: "Giant Vulture", CR: 1, HP: 22, AC: 10, Attack: 2, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Multiattack: The vulture makes two attacks: one with its beak and one with its talons. Traits: Keen Sight and Smell, Pack Tactics"},
"harpy": {ID: "harpy", Name: "Harpy", CR: 1, HP: 38, AC: 11, Attack: 2, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The harpy makes two attacks: one with its claws and one with its club."},
"hippogriff": {ID: "hippogriff", Name: "Hippogriff", CR: 1, HP: 19, AC: 11, Attack: 2, AttackBonus: 5, Speed: 16, BlockRate: 0, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The hippogriff makes two attacks: one with its beak and one with its claws. Traits: Keen Sight"},
"imp": {ID: "imp", Name: "Imp", CR: 1, HP: 10, AC: 13, Attack: 2, AttackBonus: 5, Speed: 8, BlockRate: 0.05, XPValue: 200, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Traits: Shapechanger, Devil's Sight, Magic Resistance, Variant: Familiar"},
"lion": {ID: "lion", Name: "Lion", CR: 1, HP: 26, AC: 12, Attack: 2, AttackBonus: 5, Speed: 18, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Keen Smell, Pack Tactics, Pounce, Running Leap"},
"quasit": {ID: "quasit", Name: "Quasit", CR: 1, HP: 7, AC: 13, Attack: 2, AttackBonus: 4, Speed: 16, BlockRate: 0.05, XPValue: 200, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Traits: Shapechanger, Magic Resistance, Variant: Familiar"},
"specter": {ID: "specter", Name: "Specter", CR: 1, HP: 22, AC: 12, Attack: 2, AttackBonus: 4, Speed: 18, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Incorporeal Movement, Sunlight Sensitivity"},
"spy": {ID: "spy", Name: "Spy", CR: 1, HP: 27, AC: 12, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Martial Advantage", Phase: "any", ProcChance: 0.35, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Martial Advantage (bonus_damage) wired from traits. Multiattack: The spy makes two melee attacks. Traits: Cunning Action, Sneak Attack (1/Turn)"},
"swarm_of_quippers": {ID: "swarm_of_quippers", Name: "Swarm of Quippers", CR: 1, HP: 28, AC: 13, Attack: 2, AttackBonus: 5, Speed: 16, BlockRate: 0.05, XPValue: 200, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Blood Frenzy, Swarm, Water Breathing"},
"tiger": {ID: "tiger", Name: "Tiger", CR: 1, HP: 37, AC: 12, Attack: 2, AttackBonus: 5, Speed: 16, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Keen Smell, Pounce"},
"ankheg": {ID: "ankheg", Name: "Ankheg", CR: 2, HP: 39, AC: 14, Attack: 4, AttackBonus: 0, Speed: 12, BlockRate: 0.05, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"awakened_tree": {ID: "awakened_tree", Name: "Awakened Tree", CR: 2, HP: 59, AC: 13, Attack: 4, AttackBonus: 6, Speed: 8, BlockRate: 0.05, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: False Appearance"},
"azer": {ID: "azer", Name: "Azer", CR: 2, HP: 39, AC: 17, Attack: 4, AttackBonus: 5, Speed: 12, BlockRate: 0.1, XPValue: 450, Ability: &MonsterAbility{Name: "Damaging Aura", Phase: "any", ProcChance: 0.4, Effect: "retaliate"}, Notes: "SRD-tuned baseline — Damaging Aura (retaliate) wired from traits. Traits: Heated Body, Heated Weapons, Illumination"},
"bandit_captain": {ID: "bandit_captain", Name: "Bandit Captain", CR: 2, HP: 65, AC: 15, Attack: 4, AttackBonus: 5, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The captain makes three melee attacks: two with its scimitar and one with its dagger."},
"berserker": {ID: "berserker", Name: "Berserker", CR: 2, HP: 67, AC: 13, Attack: 4, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 450, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Reckless"},
"black_dragon_wyrmling": {ID: "black_dragon_wyrmling", Name: "Black Dragon Wyrmling", CR: 2, HP: 33, AC: 17, Attack: 4, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious"},
"bronze_dragon_wyrmling": {ID: "bronze_dragon_wyrmling", Name: "Bronze Dragon Wyrmling", CR: 2, HP: 32, AC: 17, Attack: 4, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious"},
"centaur": {ID: "centaur", Name: "Centaur", CR: 2, HP: 45, AC: 12, Attack: 4, AttackBonus: 6, Speed: 18, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Multiattack: The centaur makes two attacks: one with its pike and one with its hooves or two with its longbow. Traits: Charge"},
"cult_fanatic": {ID: "cult_fanatic", Name: "Cult Fanatic", CR: 2, HP: 22, AC: 13, Attack: 4, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 450, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The fanatic makes two melee attacks. Traits: Dark Devotion, Spellcasting"},
"druid": {ID: "druid", Name: "Druid", CR: 2, HP: 27, AC: 11, Attack: 4, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Spellcasting"},
"ettercap": {ID: "ettercap", Name: "Ettercap", CR: 2, HP: 44, AC: 13, Attack: 4, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The ettercap makes two attacks: one with its bite and one with its claws. Traits: Spider Climb, Web Sense, Web Walker"},
"gargoyle": {ID: "gargoyle", Name: "Gargoyle", CR: 2, HP: 52, AC: 15, Attack: 4, AttackBonus: 4, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The gargoyle makes two attacks: one with its bite and one with its claws. Traits: False Appearance"},
"gelatinous_cube": {ID: "gelatinous_cube", Name: "Gelatinous Cube", CR: 2, HP: 84, AC: 10, Attack: 4, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Ooze Cube, Transparent"},
"ghast": {ID: "ghast", Name: "Ghast", CR: 2, HP: 36, AC: 13, Attack: 4, AttackBonus: 3, Speed: 12, BlockRate: 0.05, XPValue: 450, Ability: &MonsterAbility{Name: "Damaging Aura", Phase: "any", ProcChance: 0.4, Effect: "retaliate"}, Notes: "SRD-tuned baseline — Damaging Aura (retaliate) wired from traits. Traits: Stench, Turn Defiance"},
"giant_boar": {ID: "giant_boar", Name: "Giant Boar", CR: 2, HP: 42, AC: 12, Attack: 4, AttackBonus: 5, Speed: 16, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge, Relentless (Recharges after a Short or Long Rest)"},
"giant_constrictor_snake": {ID: "giant_constrictor_snake", Name: "Giant Constrictor Snake", CR: 2, HP: 60, AC: 12, Attack: 4, AttackBonus: 6, Speed: 12, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"giant_elk": {ID: "giant_elk", Name: "Giant Elk", CR: 2, HP: 42, AC: 15, Attack: 4, AttackBonus: 6, Speed: 18, BlockRate: 0.1, XPValue: 450, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge"},
"gibbering_mouther": {ID: "gibbering_mouther", Name: "Gibbering Mouther", CR: 2, HP: 67, AC: 10, Attack: 4, AttackBonus: 2, Speed: 6, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The gibbering mouther makes one bite attack and, if it can, uses its Blinding Spittle. Traits: Aberrant Ground, Gibbering"},
"green_dragon_wyrmling": {ID: "green_dragon_wyrmling", Name: "Green Dragon Wyrmling", CR: 2, HP: 38, AC: 17, Attack: 4, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious"},
"grick": {ID: "grick", Name: "Grick", CR: 2, HP: 27, AC: 14, Attack: 4, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The grick makes one attack with its tentacles. Traits: Stone Camouflage"},
"griffon": {ID: "griffon", Name: "Griffon", CR: 2, HP: 59, AC: 12, Attack: 4, AttackBonus: 6, Speed: 12, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The griffon makes two attacks: one with its beak and one with its claws. Traits: Keen Sight"},
"hunter_shark": {ID: "hunter_shark", Name: "Hunter Shark", CR: 2, HP: 45, AC: 12, Attack: 4, AttackBonus: 6, Speed: 16, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Blood Frenzy, Water Breathing"},
"merrow": {ID: "merrow", Name: "Merrow", CR: 2, HP: 45, AC: 13, Attack: 4, AttackBonus: 6, Speed: 6, BlockRate: 0.05, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The merrow makes two attacks: one with its bite and one with its claws or harpoon. Traits: Amphibious"},
"mimic": {ID: "mimic", Name: "Mimic", CR: 2, HP: 58, AC: 12, Attack: 4, AttackBonus: 5, Speed: 6, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Shapechanger, Adhesive (Object Form Only), False Appearance (Object Form Only), Grappler"},
"minotaur_skeleton": {ID: "minotaur_skeleton", Name: "Minotaur Skeleton", CR: 2, HP: 67, AC: 12, Attack: 4, AttackBonus: 6, Speed: 16, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge"},
"ochre_jelly": {ID: "ochre_jelly", Name: "Ochre Jelly", CR: 2, HP: 45, AC: 10, Attack: 4, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amorphous, Spider Climb"},
"ogre": {ID: "ogre", Name: "Ogre", CR: 2, HP: 59, AC: 11, Attack: 4, AttackBonus: 6, Speed: 16, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"ogre_zombie": {ID: "ogre_zombie", Name: "Ogre Zombie", CR: 2, HP: 85, AC: 10, Attack: 4, AttackBonus: 6, Speed: 12, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Undead Fortitude", Phase: "decisive", ProcChance: 0.5, Effect: "survive_at_1"}, Notes: "SRD-tuned baseline — Undead Fortitude (survive_at_1) wired from traits. Traits: Undead Fortitude"},
"pegasus": {ID: "pegasus", Name: "Pegasus", CR: 2, HP: 59, AC: 12, Attack: 4, AttackBonus: 6, Speed: 18, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"plesiosaurus": {ID: "plesiosaurus", Name: "Plesiosaurus", CR: 2, HP: 68, AC: 13, Attack: 4, AttackBonus: 6, Speed: 8, BlockRate: 0.05, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Hold Breath"},
"polar_bear": {ID: "polar_bear", Name: "Polar Bear", CR: 2, HP: 42, AC: 12, Attack: 4, AttackBonus: 7, Speed: 16, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The bear makes two attacks: one with its bite and one with its claws. Traits: Keen Smell"},
"priest": {ID: "priest", Name: "Priest", CR: 2, HP: 27, AC: 13, Attack: 4, AttackBonus: 2, Speed: 10, BlockRate: 0.05, XPValue: 450, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Divine Eminence, Spellcasting"},
"rhinoceros": {ID: "rhinoceros", Name: "Rhinoceros", CR: 2, HP: 45, AC: 11, Attack: 4, AttackBonus: 7, Speed: 16, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge"},
"rug_of_smothering": {ID: "rug_of_smothering", Name: "Rug of Smothering", CR: 2, HP: 33, AC: 12, Attack: 4, AttackBonus: 0, Speed: 6, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Antimagic Susceptibility, Damage Transfer, False Appearance"},
"saber_toothed_tiger": {ID: "saber_toothed_tiger", Name: "Saber-Toothed Tiger", CR: 2, HP: 52, AC: 12, Attack: 4, AttackBonus: 6, Speed: 16, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Keen Smell, Pounce"},
"sea_hag": {ID: "sea_hag", Name: "Sea Hag", CR: 2, HP: 52, AC: 14, Attack: 4, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 450, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Amphibious, Horrific Appearance, Hag Coven, Shared Spellcasting (Coven Only), Hag Eye (Coven Only)"},
"silver_dragon_wyrmling": {ID: "silver_dragon_wyrmling", Name: "Silver Dragon Wyrmling", CR: 2, HP: 45, AC: 17, Attack: 4, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"awakened_shrub": {ID: "awakened_shrub", Name: "Awakened Shrub", CR: 0, HP: 10, AC: 10, Attack: 1, AttackBonus: 1, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: False Appearance"},
"baboon": {ID: "baboon", Name: "Baboon", CR: 0, HP: 3, AC: 12, Attack: 1, AttackBonus: 1, Speed: 12, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Pack Tactics"},
"badger": {ID: "badger", Name: "Badger", CR: 0, HP: 3, AC: 10, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Smell"},
"bat": {ID: "bat", Name: "Bat", CR: 0, HP: 1, AC: 12, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Echolocation, Keen Hearing"},
"cat": {ID: "cat", Name: "Cat", CR: 0, HP: 2, AC: 12, Attack: 1, AttackBonus: 0, Speed: 16, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Smell"},
"commoner": {ID: "commoner", Name: "Commoner", CR: 0, HP: 4, AC: 10, Attack: 1, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"crab": {ID: "crab", Name: "Crab", CR: 0, HP: 2, AC: 11, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious"},
"deer": {ID: "deer", Name: "Deer", CR: 0, HP: 4, AC: 13, Attack: 1, AttackBonus: 2, Speed: 18, BlockRate: 0.05, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"eagle": {ID: "eagle", Name: "Eagle", CR: 0, HP: 3, AC: 12, Attack: 1, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Sight"},
"frog": {ID: "frog", Name: "Frog", CR: 0, HP: 1, AC: 11, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Amphibious, Standing Leap"},
"giant_fire_beetle": {ID: "giant_fire_beetle", Name: "Giant Fire Beetle", CR: 0, HP: 4, AC: 13, Attack: 1, AttackBonus: 1, Speed: 12, BlockRate: 0.05, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Illumination"},
"goat": {ID: "goat", Name: "Goat", CR: 0, HP: 4, AC: 10, Attack: 1, AttackBonus: 3, Speed: 16, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge, Sure-Footed"},
"hawk": {ID: "hawk", Name: "Hawk", CR: 0, HP: 1, AC: 13, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0.05, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Sight"},
"homunculus": {ID: "homunculus", Name: "Homunculus", CR: 0, HP: 5, AC: 13, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0.05, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Telepathic Bond"},
"hyena": {ID: "hyena", Name: "Hyena", CR: 0, HP: 5, AC: 11, Attack: 1, AttackBonus: 2, Speed: 18, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Pack Tactics"},
"jackal": {ID: "jackal", Name: "Jackal", CR: 0, HP: 3, AC: 12, Attack: 1, AttackBonus: 1, Speed: 16, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Hearing and Smell, Pack Tactics"},
"lemure": {ID: "lemure", Name: "Lemure", CR: 0, HP: 13, AC: 10, Attack: 1, AttackBonus: 3, Speed: 6, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Regeneration", Phase: "any", ProcChance: 0.5, Effect: "regenerate"}, Notes: "SRD-tuned baseline — Regeneration (regenerate) wired from traits. Traits: Devil's Sight, Hellish Rejuvenation"},
"lizard": {ID: "lizard", Name: "Lizard", CR: 0, HP: 2, AC: 10, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"octopus": {ID: "octopus", Name: "Octopus", CR: 0, HP: 3, AC: 12, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Hold Breath, Underwater Camouflage, Water Breathing"},
"owl": {ID: "owl", Name: "Owl", CR: 0, HP: 1, AC: 11, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Flyby, Keen Hearing and Sight"},
"quipper": {ID: "quipper", Name: "Quipper", CR: 0, HP: 1, AC: 13, Attack: 1, AttackBonus: 0, Speed: 16, BlockRate: 0.05, XPValue: 10, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Blood Frenzy, Water Breathing"},
"rat": {ID: "rat", Name: "Rat", CR: 0, HP: 1, AC: 10, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Smell"},
"raven": {ID: "raven", Name: "Raven", CR: 0, HP: 1, AC: 12, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Mimicry"},
"scorpion": {ID: "scorpion", Name: "Scorpion", CR: 0, HP: 1, AC: 11, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"sea_horse": {ID: "sea_horse", Name: "Sea Horse", CR: 0, HP: 1, AC: 11, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Water Breathing"},
"shrieker": {ID: "shrieker", Name: "Shrieker", CR: 0, HP: 13, AC: 10, Attack: 1, AttackBonus: 0, Speed: 10, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: False Appearance"},
"spider": {ID: "spider", Name: "Spider", CR: 0, HP: 1, AC: 12, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Spider Climb, Web Sense, Web Walker"},
"vulture": {ID: "vulture", Name: "Vulture", CR: 0, HP: 5, AC: 10, Attack: 1, AttackBonus: 2, Speed: 6, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Sight and Smell, Pack Tactics"},
"weasel": {ID: "weasel", Name: "Weasel", CR: 0, HP: 1, AC: 13, Attack: 1, AttackBonus: 0, Speed: 12, BlockRate: 0.05, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Hearing and Smell"},
"bandit": {ID: "bandit", Name: "Bandit", CR: 0.125, HP: 11, AC: 12, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"blood_hawk": {ID: "blood_hawk", Name: "Blood Hawk", CR: 0.125, HP: 7, AC: 12, Attack: 1, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 25, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Sight, Pack Tactics"},
"camel": {ID: "camel", Name: "Camel", CR: 0.125, HP: 15, AC: 10, Attack: 1, AttackBonus: 5, Speed: 18, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"cultist": {ID: "cultist", Name: "Cultist", CR: 0.125, HP: 9, AC: 12, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Dark Devotion"},
"flying_snake": {ID: "flying_snake", Name: "Flying Snake", CR: 0.125, HP: 5, AC: 14, Attack: 1, AttackBonus: 0, Speed: 12, BlockRate: 0.05, XPValue: 25, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Flyby"},
"giant_crab": {ID: "giant_crab", Name: "Giant Crab", CR: 0.125, HP: 13, AC: 15, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0.1, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious"},
"giant_rat": {ID: "giant_rat", Name: "Giant Rat", CR: 0.125, HP: 7, AC: 12, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 25, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Smell, Pack Tactics"},
"giant_rat_diseased": {ID: "giant_rat_diseased", Name: "Giant Rat", CR: 0.125, HP: 7, AC: 12, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"giant_weasel": {ID: "giant_weasel", Name: "Giant Weasel", CR: 0.125, HP: 9, AC: 13, Attack: 1, AttackBonus: 5, Speed: 16, BlockRate: 0.05, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Hearing and Smell"},
"guard": {ID: "guard", Name: "Guard", CR: 0.125, HP: 11, AC: 16, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0.1, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"kobold": {ID: "kobold", Name: "Kobold", CR: 0.125, HP: 5, AC: 12, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 25, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Sunlight Sensitivity, Pack Tactics"},
"mastiff": {ID: "mastiff", Name: "Mastiff", CR: 0.125, HP: 5, AC: 12, Attack: 1, AttackBonus: 3, Speed: 16, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Hearing and Smell"},
"merfolk": {ID: "merfolk", Name: "Merfolk", CR: 0.125, HP: 11, AC: 11, Attack: 1, AttackBonus: 2, Speed: 6, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious"},
"mule": {ID: "mule", Name: "Mule", CR: 0.125, HP: 11, AC: 10, Attack: 1, AttackBonus: 4, Speed: 16, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Beast of Burden, Sure-Footed"},
"noble": {ID: "noble", Name: "Noble", CR: 0.125, HP: 9, AC: 15, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0.1, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"poisonous_snake": {ID: "poisonous_snake", Name: "Poisonous Snake", CR: 0.125, HP: 2, AC: 13, Attack: 1, AttackBonus: 0, Speed: 12, BlockRate: 0.05, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"pony": {ID: "pony", Name: "Pony", CR: 0.125, HP: 11, AC: 10, Attack: 1, AttackBonus: 4, Speed: 16, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"stirge": {ID: "stirge", Name: "Stirge", CR: 0.125, HP: 2, AC: 14, Attack: 1, AttackBonus: 5, Speed: 6, BlockRate: 0.05, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"tribal_warrior": {ID: "tribal_warrior", Name: "Tribal Warrior", CR: 0.125, HP: 11, AC: 12, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 25, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Pack Tactics"},
"acolyte": {ID: "acolyte", Name: "Acolyte", CR: 0.25, HP: 9, AC: 10, Attack: 1, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Spellcasting"},
"axe_beak": {ID: "axe_beak", Name: "Axe Beak", CR: 0.25, HP: 19, AC: 11, Attack: 1, AttackBonus: 4, Speed: 18, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"blink_dog": {ID: "blink_dog", Name: "Blink Dog", CR: 0.25, HP: 22, AC: 13, Attack: 1, AttackBonus: 3, Speed: 16, BlockRate: 0.05, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Hearing and Smell"},
"boar": {ID: "boar", Name: "Boar", CR: 0.25, HP: 11, AC: 11, Attack: 1, AttackBonus: 3, Speed: 16, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge, Relentless (Recharges after a Short or Long Rest)"},
"constrictor_snake": {ID: "constrictor_snake", Name: "Constrictor Snake", CR: 0.25, HP: 13, AC: 12, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"draft_horse": {ID: "draft_horse", Name: "Draft Horse", CR: 0.25, HP: 19, AC: 10, Attack: 1, AttackBonus: 6, Speed: 16, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"dretch": {ID: "dretch", Name: "Dretch", CR: 0.25, HP: 18, AC: 11, Attack: 1, AttackBonus: 2, Speed: 8, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dretch makes two attacks: one with its bite and one with its claws."},
"drow": {ID: "drow", Name: "Drow", CR: 0.25, HP: 13, AC: 15, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0.1, XPValue: 50, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Fey Ancestry, Innate Spellcasting, Sunlight Sensitivity"},
"elk": {ID: "elk", Name: "Elk", CR: 0.25, HP: 13, AC: 10, Attack: 1, AttackBonus: 0, Speed: 18, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge"},
"flying_sword": {ID: "flying_sword", Name: "Flying Sword", CR: 0.25, HP: 17, AC: 17, Attack: 1, AttackBonus: 3, Speed: 18, BlockRate: 0.1, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Antimagic Susceptibility, False Appearance"},
"giant_badger": {ID: "giant_badger", Name: "Giant Badger", CR: 0.25, HP: 13, AC: 10, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The badger makes two attacks: one with its bite and one with its claws. Traits: Keen Smell"},
"giant_bat": {ID: "giant_bat", Name: "Giant Bat", CR: 0.25, HP: 22, AC: 13, Attack: 1, AttackBonus: 4, Speed: 6, BlockRate: 0.05, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Echolocation, Keen Hearing"},
"giant_centipede": {ID: "giant_centipede", Name: "Giant Centipede", CR: 0.25, HP: 4, AC: 13, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"giant_frog": {ID: "giant_frog", Name: "Giant Frog", CR: 0.25, HP: 18, AC: 11, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Amphibious, Standing Leap"},
"giant_lizard": {ID: "giant_lizard", Name: "Giant Lizard", CR: 0.25, HP: 19, AC: 12, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Variant: Hold Breath, Variant: Spider Climb"},
"giant_owl": {ID: "giant_owl", Name: "Giant Owl", CR: 0.25, HP: 19, AC: 12, Attack: 1, AttackBonus: 3, Speed: 6, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Flyby, Keen Hearing and Sight"},
"giant_poisonous_snake": {ID: "giant_poisonous_snake", Name: "Giant Poisonous Snake", CR: 0.25, HP: 11, AC: 14, Attack: 1, AttackBonus: 6, Speed: 12, BlockRate: 0.05, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"giant_wolf_spider": {ID: "giant_wolf_spider", Name: "Giant Wolf Spider", CR: 0.25, HP: 11, AC: 13, Attack: 1, AttackBonus: 3, Speed: 16, BlockRate: 0.05, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Spider Climb, Web Sense, Web Walker"},
"goblin": {ID: "goblin", Name: "Goblin", CR: 0.25, HP: 7, AC: 15, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0.1, XPValue: 50, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Nimble Escape"},
"grimlock": {ID: "grimlock", Name: "Grimlock", CR: 0.25, HP: 11, AC: 11, Attack: 1, AttackBonus: 5, Speed: 12, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Blind Senses, Keen Hearing and Smell, Stone Camouflage"},
"panther": {ID: "panther", Name: "Panther", CR: 0.25, HP: 13, AC: 12, Attack: 1, AttackBonus: 4, Speed: 18, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Keen Smell, Pounce"},
"pseudodragon": {ID: "pseudodragon", Name: "Pseudodragon", CR: 0.25, HP: 7, AC: 13, Attack: 1, AttackBonus: 4, Speed: 6, BlockRate: 0.05, XPValue: 50, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Traits: Keen Senses, Magic Resistance, Limited Telepathy, Variant: Familiar"},
"riding_horse": {ID: "riding_horse", Name: "Riding Horse", CR: 0.25, HP: 13, AC: 10, Attack: 1, AttackBonus: 5, Speed: 18, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"skeleton": {ID: "skeleton", Name: "Skeleton", CR: 0.25, HP: 13, AC: 13, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"sprite": {ID: "sprite", Name: "Sprite", CR: 0.25, HP: 2, AC: 15, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0.1, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"steam_mephit": {ID: "steam_mephit", Name: "Steam Mephit", CR: 0.25, HP: 21, AC: 10, Attack: 1, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Traits: Death Burst, Innate Spellcasting (1/Day)"},
"swarm_of_bats": {ID: "swarm_of_bats", Name: "Swarm of Bats", CR: 0.25, HP: 22, AC: 12, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Echolocation, Keen Hearing, Swarm"},
"swarm_of_rats": {ID: "swarm_of_rats", Name: "Swarm of Rats", CR: 0.25, HP: 24, AC: 10, Attack: 1, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Smell, Swarm"},
"swarm_of_ravens": {ID: "swarm_of_ravens", Name: "Swarm of Ravens", CR: 0.25, HP: 24, AC: 12, Attack: 1, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Swarm"},
"violet_fungus": {ID: "violet_fungus", Name: "Violet Fungus", CR: 0.25, HP: 18, AC: 10, Attack: 1, AttackBonus: 2, Speed: 6, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The fungus makes 1d4 Rotting Touch attacks. Traits: False Appearance"},
"wolf": {ID: "wolf", Name: "Wolf", CR: 0.25, HP: 11, AC: 13, Attack: 1, AttackBonus: 4, Speed: 16, BlockRate: 0.05, XPValue: 50, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Hearing and Smell, Pack Tactics"},
"zombie": {ID: "zombie", Name: "Zombie", CR: 0.25, HP: 22, AC: 10, Attack: 1, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Undead Fortitude", Phase: "decisive", ProcChance: 0.5, Effect: "survive_at_1"}, Notes: "SRD-tuned baseline — Undead Fortitude (survive_at_1) wired from traits. Traits: Undead Fortitude"},
"ape": {ID: "ape", Name: "Ape", CR: 0.5, HP: 19, AC: 12, Attack: 2, AttackBonus: 5, Speed: 12, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The ape makes two fist attacks."},
"black_bear": {ID: "black_bear", Name: "Black Bear", CR: 0.5, HP: 19, AC: 11, Attack: 2, AttackBonus: 3, Speed: 16, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The bear makes two attacks: one with its bite and one with its claws. Traits: Keen Smell"},
"cockatrice": {ID: "cockatrice", Name: "Cockatrice", CR: 0.5, HP: 27, AC: 11, Attack: 2, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"crocodile": {ID: "crocodile", Name: "Crocodile", CR: 0.5, HP: 19, AC: 12, Attack: 2, AttackBonus: 4, Speed: 8, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Hold Breath"},
"darkmantle": {ID: "darkmantle", Name: "Darkmantle", CR: 0.5, HP: 22, AC: 11, Attack: 2, AttackBonus: 5, Speed: 6, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Echolocation, False Appearance"},
"deep_gnome_svirfneblin": {ID: "deep_gnome_svirfneblin", Name: "Deep Gnome", CR: 0.5, HP: 16, AC: 15, Attack: 2, AttackBonus: 4, Speed: 8, BlockRate: 0.1, XPValue: 100, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Stone Camouflage, Gnome Cunning, Innate Spellcasting"},
"dust_mephit": {ID: "dust_mephit", Name: "Dust Mephit", CR: 0.5, HP: 17, AC: 12, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Traits: Death Burst, Innate Spellcasting (1/Day)"},
"giant_goat": {ID: "giant_goat", Name: "Giant Goat", CR: 0.5, HP: 19, AC: 11, Attack: 2, AttackBonus: 5, Speed: 16, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge, Sure-Footed"},
"giant_sea_horse": {ID: "giant_sea_horse", Name: "Giant Sea Horse", CR: 0.5, HP: 16, AC: 13, Attack: 2, AttackBonus: 3, Speed: 16, BlockRate: 0.05, XPValue: 100, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge, Water Breathing"},
"giant_wasp": {ID: "giant_wasp", Name: "Giant Wasp", CR: 0.5, HP: 13, AC: 12, Attack: 2, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"gnoll": {ID: "gnoll", Name: "Gnoll", CR: 0.5, HP: 22, AC: 15, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0.1, XPValue: 100, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Rampage"},
"gray_ooze": {ID: "gray_ooze", Name: "Gray Ooze", CR: 0.5, HP: 22, AC: 10, Attack: 2, AttackBonus: 3, Speed: 6, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amorphous, Corrode Metal, False Appearance"},
"hobgoblin": {ID: "hobgoblin", Name: "Hobgoblin", CR: 0.5, HP: 11, AC: 18, Attack: 2, AttackBonus: 3, Speed: 12, BlockRate: 0.15, XPValue: 100, Ability: &MonsterAbility{Name: "Martial Advantage", Phase: "any", ProcChance: 0.35, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Martial Advantage (bonus_damage) wired from traits. Traits: Martial Advantage"},
"ice_mephit": {ID: "ice_mephit", Name: "Ice Mephit", CR: 0.5, HP: 21, AC: 11, Attack: 2, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Traits: Death Burst, False Appearance, Innate Spellcasting (1/Day)"},
"lizardfolk": {ID: "lizardfolk", Name: "Lizardfolk", CR: 0.5, HP: 22, AC: 15, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0.1, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The lizardfolk makes two melee attacks, each one with a different weapon. Traits: Hold Breath"},
"magma_mephit": {ID: "magma_mephit", Name: "Magma Mephit", CR: 0.5, HP: 22, AC: 11, Attack: 2, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Traits: Death Burst, False Appearance, Innate Spellcasting (1/Day)"},
"magmin": {ID: "magmin", Name: "Magmin", CR: 0.5, HP: 9, AC: 14, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 100, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Traits: Death Burst, Ignited Illumination", FireAttacker: true},
"orc": {ID: "orc", Name: "Orc", CR: 0.5, HP: 15, AC: 13, Attack: 2, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 100, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Aggressive"},
"reef_shark": {ID: "reef_shark", Name: "Reef Shark", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 4, Speed: 16, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Pack Tactics, Water Breathing"},
"rust_monster": {ID: "rust_monster", Name: "Rust Monster", CR: 0.5, HP: 27, AC: 14, Attack: 2, AttackBonus: 3, Speed: 16, BlockRate: 0.05, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Iron Scent, Rust Metal"},
"sahuagin": {ID: "sahuagin", Name: "Sahuagin", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Multiattack: The sahuagin makes two melee attacks: one with its bite and one with its claws or spear. Traits: Blood Frenzy, Limited Amphibiousness, Shark Telepathy"},
"satyr": {ID: "satyr", Name: "Satyr", CR: 0.5, HP: 31, AC: 14, Attack: 2, AttackBonus: 3, Speed: 16, BlockRate: 0.05, XPValue: 100, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Traits: Magic Resistance"},
"scout": {ID: "scout", Name: "Scout", CR: 0.5, HP: 16, AC: 13, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The scout makes two melee attacks or two ranged attacks. Traits: Keen Hearing and Sight"},
"shadow": {ID: "shadow", Name: "Shadow", CR: 0.5, HP: 16, AC: 12, Attack: 2, AttackBonus: 4, Speed: 16, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Surprise Attack", Phase: "opening", ProcChance: 0.8, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Surprise Attack (bonus_damage) wired from traits. Traits: Amorphous, Shadow Stealth, Sunlight Weakness"},
"swarm_of_beetles": {ID: "swarm_of_beetles", Name: "Swarm of Beetles", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Swarm"},
"swarm_of_centipedes": {ID: "swarm_of_centipedes", Name: "Swarm of Centipedes", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Swarm"},
"swarm_of_insects": {ID: "swarm_of_insects", Name: "Swarm of Insects", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Swarm"},
"swarm_of_spiders": {ID: "swarm_of_spiders", Name: "Swarm of Spiders", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Swarm, Spider Climb, Web Sense, Web Walker"},
"swarm_of_wasps": {ID: "swarm_of_wasps", Name: "Swarm of Wasps", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 3, Speed: 6, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Swarm"},
"thug": {ID: "thug", Name: "Thug", CR: 0.5, HP: 32, AC: 11, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Multiattack: The thug makes two melee attacks. Traits: Pack Tactics"},
"warhorse": {ID: "warhorse", Name: "Warhorse", CR: 0.5, HP: 19, AC: 11, Attack: 2, AttackBonus: 6, Speed: 18, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Trampling Charge"},
"warhorse_skeleton": {ID: "warhorse_skeleton", Name: "Warhorse Skeleton", CR: 0.5, HP: 22, AC: 13, Attack: 2, AttackBonus: 6, Speed: 18, BlockRate: 0.05, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"worg": {ID: "worg", Name: "Worg", CR: 0.5, HP: 26, AC: 13, Attack: 2, AttackBonus: 5, Speed: 18, BlockRate: 0.05, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Hearing and Smell"},
"animated_armor": {ID: "animated_armor", Name: "Animated Armor", CR: 1, HP: 33, AC: 18, Attack: 2, AttackBonus: 4, Speed: 10, BlockRate: 0.15, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The armor makes two melee attacks. Traits: Antimagic Susceptibility, False Appearance"},
"brass_dragon_wyrmling": {ID: "brass_dragon_wyrmling", Name: "Brass Dragon Wyrmling", CR: 1, HP: 16, AC: 16, Attack: 2, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat).", FireAttacker: true},
"brown_bear": {ID: "brown_bear", Name: "Brown Bear", CR: 1, HP: 34, AC: 11, Attack: 2, AttackBonus: 5, Speed: 16, BlockRate: 0, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The bear makes two attacks: one with its bite and one with its claws. Traits: Keen Smell"},
"bugbear": {ID: "bugbear", Name: "Bugbear", CR: 1, HP: 27, AC: 16, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0.1, XPValue: 200, Ability: &MonsterAbility{Name: "Surprise Attack", Phase: "opening", ProcChance: 0.8, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Surprise Attack (bonus_damage) wired from traits. Traits: Brute, Surprise Attack"},
"copper_dragon_wyrmling": {ID: "copper_dragon_wyrmling", Name: "Copper Dragon Wyrmling", CR: 1, HP: 22, AC: 16, Attack: 2, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"death_dog": {ID: "death_dog", Name: "Death Dog", CR: 1, HP: 39, AC: 12, Attack: 2, AttackBonus: 4, Speed: 16, BlockRate: 0, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dog makes two bite attacks. Traits: Two-Headed"},
"dire_wolf": {ID: "dire_wolf", Name: "Dire Wolf", CR: 1, HP: 37, AC: 14, Attack: 2, AttackBonus: 5, Speed: 18, BlockRate: 0.05, XPValue: 200, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Hearing and Smell, Pack Tactics"},
"dryad": {ID: "dryad", Name: "Dryad", CR: 1, HP: 22, AC: 11, Attack: 2, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Innate Spellcasting, Magic Resistance, Speak with Beasts and Plants, Tree Stride"},
"duergar": {ID: "duergar", Name: "Duergar", CR: 1, HP: 26, AC: 16, Attack: 2, AttackBonus: 4, Speed: 10, BlockRate: 0.1, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Duergar Resilience, Sunlight Sensitivity"},
"ghoul": {ID: "ghoul", Name: "Ghoul", CR: 1, HP: 22, AC: 12, Attack: 2, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"giant_eagle": {ID: "giant_eagle", Name: "Giant Eagle", CR: 1, HP: 26, AC: 13, Attack: 2, AttackBonus: 5, Speed: 6, BlockRate: 0.05, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The eagle makes two attacks: one with its beak and one with its talons. Traits: Keen Sight"},
"giant_hyena": {ID: "giant_hyena", Name: "Giant Hyena", CR: 1, HP: 45, AC: 12, Attack: 2, AttackBonus: 5, Speed: 18, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Rampage"},
"giant_octopus": {ID: "giant_octopus", Name: "Giant Octopus", CR: 1, HP: 52, AC: 11, Attack: 2, AttackBonus: 5, Speed: 6, BlockRate: 0, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Hold Breath, Underwater Camouflage, Water Breathing"},
"giant_spider": {ID: "giant_spider", Name: "Giant Spider", CR: 1, HP: 26, AC: 14, Attack: 2, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Spider Climb, Web Sense, Web Walker"},
"giant_toad": {ID: "giant_toad", Name: "Giant Toad", CR: 1, HP: 39, AC: 11, Attack: 2, AttackBonus: 4, Speed: 8, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Amphibious, Standing Leap"},
"giant_vulture": {ID: "giant_vulture", Name: "Giant Vulture", CR: 1, HP: 22, AC: 10, Attack: 2, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Multiattack: The vulture makes two attacks: one with its beak and one with its talons. Traits: Keen Sight and Smell, Pack Tactics"},
"harpy": {ID: "harpy", Name: "Harpy", CR: 1, HP: 38, AC: 11, Attack: 2, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The harpy makes two attacks: one with its claws and one with its club."},
"hippogriff": {ID: "hippogriff", Name: "Hippogriff", CR: 1, HP: 19, AC: 11, Attack: 2, AttackBonus: 5, Speed: 16, BlockRate: 0, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The hippogriff makes two attacks: one with its beak and one with its claws. Traits: Keen Sight"},
"imp": {ID: "imp", Name: "Imp", CR: 1, HP: 10, AC: 13, Attack: 2, AttackBonus: 5, Speed: 8, BlockRate: 0.05, XPValue: 200, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Traits: Shapechanger, Devil's Sight, Magic Resistance, Variant: Familiar"},
"lion": {ID: "lion", Name: "Lion", CR: 1, HP: 26, AC: 12, Attack: 2, AttackBonus: 5, Speed: 18, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Keen Smell, Pack Tactics, Pounce, Running Leap"},
"quasit": {ID: "quasit", Name: "Quasit", CR: 1, HP: 7, AC: 13, Attack: 2, AttackBonus: 4, Speed: 16, BlockRate: 0.05, XPValue: 200, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Traits: Shapechanger, Magic Resistance, Variant: Familiar"},
"specter": {ID: "specter", Name: "Specter", CR: 1, HP: 22, AC: 12, Attack: 2, AttackBonus: 4, Speed: 18, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Incorporeal Movement, Sunlight Sensitivity"},
"spy": {ID: "spy", Name: "Spy", CR: 1, HP: 27, AC: 12, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Martial Advantage", Phase: "any", ProcChance: 0.35, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Martial Advantage (bonus_damage) wired from traits. Multiattack: The spy makes two melee attacks. Traits: Cunning Action, Sneak Attack (1/Turn)"},
"swarm_of_quippers": {ID: "swarm_of_quippers", Name: "Swarm of Quippers", CR: 1, HP: 28, AC: 13, Attack: 2, AttackBonus: 5, Speed: 16, BlockRate: 0.05, XPValue: 200, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Blood Frenzy, Swarm, Water Breathing"},
"tiger": {ID: "tiger", Name: "Tiger", CR: 1, HP: 37, AC: 12, Attack: 2, AttackBonus: 5, Speed: 16, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Keen Smell, Pounce"},
"ankheg": {ID: "ankheg", Name: "Ankheg", CR: 2, HP: 39, AC: 14, Attack: 4, AttackBonus: 0, Speed: 12, BlockRate: 0.05, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"awakened_tree": {ID: "awakened_tree", Name: "Awakened Tree", CR: 2, HP: 59, AC: 13, Attack: 4, AttackBonus: 6, Speed: 8, BlockRate: 0.05, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: False Appearance"},
"azer": {ID: "azer", Name: "Azer", CR: 2, HP: 39, AC: 17, Attack: 4, AttackBonus: 5, Speed: 12, BlockRate: 0.1, XPValue: 450, Ability: &MonsterAbility{Name: "Damaging Aura", Phase: "any", ProcChance: 0.4, Effect: "retaliate"}, Notes: "SRD-tuned baseline — Damaging Aura (retaliate) wired from traits. Traits: Heated Body, Heated Weapons, Illumination"},
"bandit_captain": {ID: "bandit_captain", Name: "Bandit Captain", CR: 2, HP: 65, AC: 15, Attack: 4, AttackBonus: 5, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The captain makes three melee attacks: two with its scimitar and one with its dagger."},
"berserker": {ID: "berserker", Name: "Berserker", CR: 2, HP: 67, AC: 13, Attack: 4, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 450, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Reckless"},
"black_dragon_wyrmling": {ID: "black_dragon_wyrmling", Name: "Black Dragon Wyrmling", CR: 2, HP: 33, AC: 17, Attack: 4, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious"},
"bronze_dragon_wyrmling": {ID: "bronze_dragon_wyrmling", Name: "Bronze Dragon Wyrmling", CR: 2, HP: 32, AC: 17, Attack: 4, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious"},
"centaur": {ID: "centaur", Name: "Centaur", CR: 2, HP: 45, AC: 12, Attack: 4, AttackBonus: 6, Speed: 18, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Multiattack: The centaur makes two attacks: one with its pike and one with its hooves or two with its longbow. Traits: Charge"},
"cult_fanatic": {ID: "cult_fanatic", Name: "Cult Fanatic", CR: 2, HP: 22, AC: 13, Attack: 4, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 450, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The fanatic makes two melee attacks. Traits: Dark Devotion, Spellcasting"},
"druid": {ID: "druid", Name: "Druid", CR: 2, HP: 27, AC: 11, Attack: 4, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Spellcasting"},
"ettercap": {ID: "ettercap", Name: "Ettercap", CR: 2, HP: 44, AC: 13, Attack: 4, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The ettercap makes two attacks: one with its bite and one with its claws. Traits: Spider Climb, Web Sense, Web Walker"},
"gargoyle": {ID: "gargoyle", Name: "Gargoyle", CR: 2, HP: 52, AC: 15, Attack: 4, AttackBonus: 4, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The gargoyle makes two attacks: one with its bite and one with its claws. Traits: False Appearance"},
"gelatinous_cube": {ID: "gelatinous_cube", Name: "Gelatinous Cube", CR: 2, HP: 84, AC: 10, Attack: 4, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Ooze Cube, Transparent"},
"ghast": {ID: "ghast", Name: "Ghast", CR: 2, HP: 36, AC: 13, Attack: 4, AttackBonus: 3, Speed: 12, BlockRate: 0.05, XPValue: 450, Ability: &MonsterAbility{Name: "Damaging Aura", Phase: "any", ProcChance: 0.4, Effect: "retaliate"}, Notes: "SRD-tuned baseline — Damaging Aura (retaliate) wired from traits. Traits: Stench, Turn Defiance"},
"giant_boar": {ID: "giant_boar", Name: "Giant Boar", CR: 2, HP: 42, AC: 12, Attack: 4, AttackBonus: 5, Speed: 16, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge, Relentless (Recharges after a Short or Long Rest)"},
"giant_constrictor_snake": {ID: "giant_constrictor_snake", Name: "Giant Constrictor Snake", CR: 2, HP: 60, AC: 12, Attack: 4, AttackBonus: 6, Speed: 12, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"giant_elk": {ID: "giant_elk", Name: "Giant Elk", CR: 2, HP: 42, AC: 15, Attack: 4, AttackBonus: 6, Speed: 18, BlockRate: 0.1, XPValue: 450, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge"},
"gibbering_mouther": {ID: "gibbering_mouther", Name: "Gibbering Mouther", CR: 2, HP: 67, AC: 10, Attack: 4, AttackBonus: 2, Speed: 6, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The gibbering mouther makes one bite attack and, if it can, uses its Blinding Spittle. Traits: Aberrant Ground, Gibbering"},
"green_dragon_wyrmling": {ID: "green_dragon_wyrmling", Name: "Green Dragon Wyrmling", CR: 2, HP: 38, AC: 17, Attack: 4, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious"},
"grick": {ID: "grick", Name: "Grick", CR: 2, HP: 27, AC: 14, Attack: 4, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The grick makes one attack with its tentacles. Traits: Stone Camouflage"},
"griffon": {ID: "griffon", Name: "Griffon", CR: 2, HP: 59, AC: 12, Attack: 4, AttackBonus: 6, Speed: 12, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The griffon makes two attacks: one with its beak and one with its claws. Traits: Keen Sight"},
"hunter_shark": {ID: "hunter_shark", Name: "Hunter Shark", CR: 2, HP: 45, AC: 12, Attack: 4, AttackBonus: 6, Speed: 16, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Blood Frenzy, Water Breathing"},
"merrow": {ID: "merrow", Name: "Merrow", CR: 2, HP: 45, AC: 13, Attack: 4, AttackBonus: 6, Speed: 6, BlockRate: 0.05, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The merrow makes two attacks: one with its bite and one with its claws or harpoon. Traits: Amphibious"},
"mimic": {ID: "mimic", Name: "Mimic", CR: 2, HP: 58, AC: 12, Attack: 4, AttackBonus: 5, Speed: 6, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Shapechanger, Adhesive (Object Form Only), False Appearance (Object Form Only), Grappler"},
"minotaur_skeleton": {ID: "minotaur_skeleton", Name: "Minotaur Skeleton", CR: 2, HP: 67, AC: 12, Attack: 4, AttackBonus: 6, Speed: 16, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge"},
"ochre_jelly": {ID: "ochre_jelly", Name: "Ochre Jelly", CR: 2, HP: 45, AC: 10, Attack: 4, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amorphous, Spider Climb"},
"ogre": {ID: "ogre", Name: "Ogre", CR: 2, HP: 59, AC: 11, Attack: 4, AttackBonus: 6, Speed: 16, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"ogre_zombie": {ID: "ogre_zombie", Name: "Ogre Zombie", CR: 2, HP: 85, AC: 10, Attack: 4, AttackBonus: 6, Speed: 12, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Undead Fortitude", Phase: "decisive", ProcChance: 0.5, Effect: "survive_at_1"}, Notes: "SRD-tuned baseline — Undead Fortitude (survive_at_1) wired from traits. Traits: Undead Fortitude"},
"pegasus": {ID: "pegasus", Name: "Pegasus", CR: 2, HP: 59, AC: 12, Attack: 4, AttackBonus: 6, Speed: 18, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"plesiosaurus": {ID: "plesiosaurus", Name: "Plesiosaurus", CR: 2, HP: 68, AC: 13, Attack: 4, AttackBonus: 6, Speed: 8, BlockRate: 0.05, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Hold Breath"},
"polar_bear": {ID: "polar_bear", Name: "Polar Bear", CR: 2, HP: 42, AC: 12, Attack: 4, AttackBonus: 7, Speed: 16, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The bear makes two attacks: one with its bite and one with its claws. Traits: Keen Smell"},
"priest": {ID: "priest", Name: "Priest", CR: 2, HP: 27, AC: 13, Attack: 4, AttackBonus: 2, Speed: 10, BlockRate: 0.05, XPValue: 450, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Divine Eminence, Spellcasting"},
"rhinoceros": {ID: "rhinoceros", Name: "Rhinoceros", CR: 2, HP: 45, AC: 11, Attack: 4, AttackBonus: 7, Speed: 16, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge"},
"rug_of_smothering": {ID: "rug_of_smothering", Name: "Rug of Smothering", CR: 2, HP: 33, AC: 12, Attack: 4, AttackBonus: 0, Speed: 6, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Antimagic Susceptibility, Damage Transfer, False Appearance"},
"saber_toothed_tiger": {ID: "saber_toothed_tiger", Name: "Saber-Toothed Tiger", CR: 2, HP: 52, AC: 12, Attack: 4, AttackBonus: 6, Speed: 16, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Keen Smell, Pounce"},
"sea_hag": {ID: "sea_hag", Name: "Sea Hag", CR: 2, HP: 52, AC: 14, Attack: 4, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 450, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Amphibious, Horrific Appearance, Hag Coven, Shared Spellcasting (Coven Only), Hag Eye (Coven Only)"},
"silver_dragon_wyrmling": {ID: "silver_dragon_wyrmling", Name: "Silver Dragon Wyrmling", CR: 2, HP: 45, AC: 17, Attack: 4, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"swarm_of_poisonous_snakes": {ID: "swarm_of_poisonous_snakes", Name: "Swarm of Poisonous Snakes", CR: 2, HP: 36, AC: 14, Attack: 4, AttackBonus: 6, Speed: 12, BlockRate: 0.05, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Swarm"},
"wererat": {ID: "wererat", Name: "Wererat", CR: 2, HP: 33, AC: 12, Attack: 4, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Shapechanger, Keen Smell"},
"white_dragon_wyrmling": {ID: "white_dragon_wyrmling", Name: "White Dragon Wyrmling", CR: 2, HP: 32, AC: 16, Attack: 4, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"will_o_wisp": {ID: "will_o_wisp", Name: "Will-o'-Wisp", CR: 2, HP: 22, AC: 19, Attack: 4, AttackBonus: 4, Speed: 18, BlockRate: 0.15, XPValue: 450, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Consume Life, Ephemeral, Incorporeal Movement, Variable Illumination"},
"basilisk": {ID: "basilisk", Name: "Basilisk", CR: 3, HP: 52, AC: 12, Attack: 6, AttackBonus: 5, Speed: 8, BlockRate: 0, XPValue: 700, Ability: &MonsterAbility{Name: "Frightful Presence", Phase: "opening", ProcChance: 0.4, Effect: "stun"}, Notes: "SRD-tuned baseline — Frightful Presence (stun) wired from traits. Traits: Petrifying Gaze"},
"bearded_devil": {ID: "bearded_devil", Name: "Bearded Devil", CR: 3, HP: 52, AC: 13, Attack: 6, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 700, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The devil makes two attacks: one with its beard and one with its glaive. Traits: Devil's Sight, Magic Resistance, Steadfast"},
"blue_dragon_wyrmling": {ID: "blue_dragon_wyrmling", Name: "Blue Dragon Wyrmling", CR: 3, HP: 52, AC: 17, Attack: 6, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"doppelganger": {ID: "doppelganger", Name: "Doppelganger", CR: 3, HP: 52, AC: 14, Attack: 6, AttackBonus: 6, Speed: 12, BlockRate: 0.05, XPValue: 700, Ability: &MonsterAbility{Name: "Surprise Attack", Phase: "opening", ProcChance: 0.8, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Surprise Attack (bonus_damage) wired from traits. Multiattack: The doppelganger makes two melee attacks. Traits: Shapechanger, Ambusher, Surprise Attack"},
"giant_scorpion": {ID: "giant_scorpion", Name: "Giant Scorpion", CR: 3, HP: 52, AC: 15, Attack: 6, AttackBonus: 4, Speed: 16, BlockRate: 0.1, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The scorpion makes three attacks: two with its claws and one with its sting."},
"gold_dragon_wyrmling": {ID: "gold_dragon_wyrmling", Name: "Gold Dragon Wyrmling", CR: 3, HP: 60, AC: 17, Attack: 6, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious", FireAttacker: true},
"green_hag": {ID: "green_hag", Name: "Green Hag", CR: 3, HP: 82, AC: 17, Attack: 6, AttackBonus: 6, Speed: 12, BlockRate: 0.1, XPValue: 700, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Amphibious, Innate Spellcasting, Mimicry, Hag Coven, Shared Spellcasting (Coven Only), Hag Eye (Coven Only)"},
"hell_hound": {ID: "hell_hound", Name: "Hell Hound", CR: 3, HP: 45, AC: 15, Attack: 6, AttackBonus: 0, Speed: 18, BlockRate: 0.1, XPValue: 700, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Hearing and Smell, Pack Tactics", FireAttacker: true},
"killer_whale": {ID: "killer_whale", Name: "Killer Whale", CR: 3, HP: 90, AC: 12, Attack: 6, AttackBonus: 0, Speed: 18, BlockRate: 0, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Echolocation, Hold Breath, Keen Hearing"},
"knight": {ID: "knight", Name: "Knight", CR: 3, HP: 52, AC: 18, Attack: 6, AttackBonus: 5, Speed: 12, BlockRate: 0.15, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The knight makes two melee attacks. Traits: Brave"},
"manticore": {ID: "manticore", Name: "Manticore", CR: 3, HP: 68, AC: 14, Attack: 6, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The manticore makes three attacks: one with its bite and two with its claws or three with its tail spikes. Traits: Tail Spike Regrowth"},
"minotaur": {ID: "minotaur", Name: "Minotaur", CR: 3, HP: 76, AC: 14, Attack: 6, AttackBonus: 6, Speed: 16, BlockRate: 0.05, XPValue: 700, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Charge, Labyrinthine Recall, Reckless"},
"mummy": {ID: "mummy", Name: "Mummy", CR: 3, HP: 58, AC: 11, Attack: 6, AttackBonus: 5, Speed: 8, BlockRate: 0, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The mummy can use its Dreadful Glare and makes one attack with its rotting fist."},
"nightmare": {ID: "nightmare", Name: "Nightmare", CR: 3, HP: 68, AC: 13, Attack: 6, AttackBonus: 6, Speed: 18, BlockRate: 0.05, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Confer Fire Resistance, Illumination"},
"owlbear": {ID: "owlbear", Name: "Owlbear", CR: 3, HP: 59, AC: 13, Attack: 6, AttackBonus: 7, Speed: 16, BlockRate: 0.05, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The owlbear makes two attacks: one with its beak and one with its claws. Traits: Keen Sight and Smell"},
"phase_spider": {ID: "phase_spider", Name: "Phase Spider", CR: 3, HP: 32, AC: 13, Attack: 6, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 700, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Ethereal Jaunt, Spider Climb, Web Walker"},
"veteran": {ID: "veteran", Name: "Veteran", CR: 3, HP: 58, AC: 17, Attack: 6, AttackBonus: 5, Speed: 12, BlockRate: 0.1, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The veteran makes two longsword attacks."},
"werewolf": {ID: "werewolf", Name: "Werewolf", CR: 3, HP: 58, AC: 11, Attack: 6, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 700, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Shapechanger, Keen Hearing and Smell"},
"wight": {ID: "wight", Name: "Wight", CR: 3, HP: 45, AC: 14, Attack: 6, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The wight makes two longsword attacks or two longbow attacks. Traits: Sunlight Sensitivity"},
"winter_wolf": {ID: "winter_wolf", Name: "Winter Wolf", CR: 3, HP: 75, AC: 13, Attack: 6, AttackBonus: 0, Speed: 18, BlockRate: 0.05, XPValue: 700, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Hearing and Smell, Pack Tactics, Snow Camouflage"},
"black_pudding": {ID: "black_pudding", Name: "Black Pudding", CR: 4, HP: 85, AC: 10, Attack: 7, AttackBonus: 5, Speed: 8, BlockRate: 0, XPValue: 1100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amorphous, Corrosive Form, Spider Climb"},
"chuul": {ID: "chuul", Name: "Chuul", CR: 4, HP: 93, AC: 16, Attack: 7, AttackBonus: 6, Speed: 12, BlockRate: 0.1, XPValue: 1100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The chuul makes two pincer attacks. Traits: Amphibious, Sense Magic"},
"couatl": {ID: "couatl", Name: "Couatl", CR: 4, HP: 97, AC: 19, Attack: 7, AttackBonus: 6, Speed: 12, BlockRate: 0.15, XPValue: 1100, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Innate Spellcasting, Magic Weapons, Shielded Mind"},
"elephant": {ID: "elephant", Name: "Elephant", CR: 4, HP: 76, AC: 12, Attack: 7, AttackBonus: 8, Speed: 16, BlockRate: 0, XPValue: 1100, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Trampling Charge"},
"ettin": {ID: "ettin", Name: "Ettin", CR: 4, HP: 85, AC: 12, Attack: 7, AttackBonus: 7, Speed: 16, BlockRate: 0, XPValue: 1100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The ettin makes two attacks: one with its battleaxe and one with its morningstar. Traits: Two Heads, Wakeful"},
"ghost": {ID: "ghost", Name: "Ghost", CR: 4, HP: 45, AC: 11, Attack: 7, AttackBonus: 5, Speed: 16, BlockRate: 0, XPValue: 1100, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Ethereal Sight, Incorporeal Movement"},
"lamia": {ID: "lamia", Name: "Lamia", CR: 4, HP: 97, AC: 13, Attack: 7, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 1100, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The lamia makes two attacks: one with its claws and one with its dagger or Intoxicating Touch. Traits: Innate Spellcasting"},
"red_dragon_wyrmling": {ID: "red_dragon_wyrmling", Name: "Red Dragon Wyrmling", CR: 4, HP: 75, AC: 17, Attack: 7, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 1100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat).", FireAttacker: true},
"succubusincubus": {ID: "succubusincubus", Name: "Succubus/Incubus", CR: 4, HP: 66, AC: 15, Attack: 7, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 1100, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Telepathic Bond, Shapechanger"},
"wereboar": {ID: "wereboar", Name: "Wereboar", CR: 4, HP: 78, AC: 10, Attack: 7, AttackBonus: 5, Speed: 12, BlockRate: 0, XPValue: 1100, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Shapechanger, Charge (Boar or Hybrid Form Only), Relentless (Recharges after a Short or Long Rest)"},
"weretiger": {ID: "weretiger", Name: "Weretiger", CR: 4, HP: 120, AC: 12, Attack: 7, AttackBonus: 5, Speed: 12, BlockRate: 0, XPValue: 1100, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Shapechanger, Keen Hearing and Smell, Pounce (Tiger or Hybrid Form Only)"},
"air_elemental": {ID: "air_elemental", Name: "Air Elemental", CR: 5, HP: 90, AC: 15, Attack: 8, AttackBonus: 8, Speed: 18, BlockRate: 0.1, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The elemental makes two slam attacks. Traits: Air Form"},
"barbed_devil": {ID: "barbed_devil", Name: "Barbed Devil", CR: 5, HP: 110, AC: 15, Attack: 8, AttackBonus: 6, Speed: 12, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The devil makes three melee attacks: one with its tail and two with its claws. Traits: Barbed Hide, Devil's Sight, Magic Resistance"},
"bulette": {ID: "bulette", Name: "Bulette", CR: 5, HP: 94, AC: 17, Attack: 8, AttackBonus: 7, Speed: 16, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Standing Leap"},
"earth_elemental": {ID: "earth_elemental", Name: "Earth Elemental", CR: 5, HP: 126, AC: 17, Attack: 8, AttackBonus: 8, Speed: 12, BlockRate: 0.1, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The elemental makes two slam attacks. Traits: Earth Glide, Siege Monster"},
"fire_elemental": {ID: "fire_elemental", Name: "Fire Elemental", CR: 5, HP: 102, AC: 13, Attack: 8, AttackBonus: 6, Speed: 18, BlockRate: 0.05, XPValue: 1800, Ability: &MonsterAbility{Name: "Damaging Aura", Phase: "any", ProcChance: 0.4, Effect: "retaliate"}, Notes: "SRD-tuned baseline — Damaging Aura (retaliate) wired from traits. Multiattack: The elemental makes two touch attacks. Traits: Fire Form, Illumination, Water Susceptibility", FireAttacker: true},
"flesh_golem": {ID: "flesh_golem", Name: "Flesh Golem", CR: 5, HP: 93, AC: 10, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0, XPValue: 1800, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The golem makes two slam attacks. Traits: Berserk, Aversion of Fire, Immutable Form, Lightning Absorption, Magic Resistance, Magic Weapons"},
"giant_crocodile": {ID: "giant_crocodile", Name: "Giant Crocodile", CR: 5, HP: 85, AC: 14, Attack: 8, AttackBonus: 8, Speed: 12, BlockRate: 0.05, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The crocodile makes two attacks: one with its bite and one with its tail. Traits: Hold Breath"},
"giant_shark": {ID: "giant_shark", Name: "Giant Shark", CR: 5, HP: 126, AC: 13, Attack: 8, AttackBonus: 9, Speed: 18, BlockRate: 0.05, XPValue: 1800, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Blood Frenzy, Water Breathing"},
"gladiator": {ID: "gladiator", Name: "Gladiator", CR: 5, HP: 112, AC: 16, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Multiattack: The gladiator makes three melee attacks or two ranged attacks. Traits: Brave, Brute"},
"gorgon": {ID: "gorgon", Name: "Gorgon", CR: 5, HP: 114, AC: 19, Attack: 8, AttackBonus: 8, Speed: 16, BlockRate: 0.15, XPValue: 1800, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Trampling Charge"},
"half_red_dragon_veteran": {ID: "half_red_dragon_veteran", Name: "Half-Red Dragon Veteran", CR: 5, HP: 65, AC: 18, Attack: 8, AttackBonus: 0, Speed: 12, BlockRate: 0.15, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The veteran makes two longsword attacks.", FireAttacker: true},
"hill_giant": {ID: "hill_giant", Name: "Hill Giant", CR: 5, HP: 105, AC: 13, Attack: 8, AttackBonus: 8, Speed: 16, BlockRate: 0.05, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The giant makes two greatclub attacks."},
"night_hag": {ID: "night_hag", Name: "Night Hag", CR: 5, HP: 112, AC: 17, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Innate Spellcasting, Magic Resistance, Night Hag Items, Hag Coven, Shared Spellcasting (Coven Only), Hag Eye (Coven Only)"},
"otyugh": {ID: "otyugh", Name: "Otyugh", CR: 5, HP: 114, AC: 14, Attack: 8, AttackBonus: 6, Speed: 12, BlockRate: 0.05, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The otyugh makes three attacks: one with its bite and two with its tentacles. Traits: Limited Telepathy"},
"roper": {ID: "roper", Name: "Roper", CR: 5, HP: 93, AC: 20, Attack: 8, AttackBonus: 7, Speed: 6, BlockRate: 0.15, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The roper makes four attacks with its tendrils, uses Reel, and makes one attack with its bite. Traits: False Appearance, Grasping Tendrils, Spider Climb"},
"salamander": {ID: "salamander", Name: "Salamander", CR: 5, HP: 90, AC: 15, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Damaging Aura", Phase: "any", ProcChance: 0.4, Effect: "retaliate"}, Notes: "SRD-tuned baseline — Damaging Aura (retaliate) wired from traits. Multiattack: The salamander makes two attacks: one with its spear and one with its tail. Traits: Heated Body, Heated Weapons"},
"shambling_mound": {ID: "shambling_mound", Name: "Shambling Mound", CR: 5, HP: 136, AC: 15, Attack: 8, AttackBonus: 7, Speed: 8, BlockRate: 0.1, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The shambling mound makes two slam attacks. Traits: Lightning Absorption"},
"triceratops": {ID: "triceratops", Name: "Triceratops", CR: 5, HP: 95, AC: 13, Attack: 8, AttackBonus: 9, Speed: 18, BlockRate: 0.05, XPValue: 1800, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Trampling Charge"},
"troll": {ID: "troll", Name: "Troll", CR: 5, HP: 84, AC: 15, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Regeneration", Phase: "any", ProcChance: 0.5, Effect: "regenerate"}, Notes: "SRD-tuned baseline — Regeneration (regenerate) wired from traits. Multiattack: The troll makes three attacks: one with its bite and two with its claws. Traits: Keen Smell, Regeneration, Variant: Loathsome Limbs"},
"unicorn": {ID: "unicorn", Name: "Unicorn", CR: 5, HP: 67, AC: 12, Attack: 8, AttackBonus: 7, Speed: 18, BlockRate: 0, XPValue: 1800, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The unicorn makes two attacks: one with its hooves and one with its horn. Traits: Charge, Innate Spellcasting, Magic Resistance, Magic Weapons"},
"vampire_spawn": {ID: "vampire_spawn", Name: "Vampire Spawn", CR: 5, HP: 82, AC: 15, Attack: 8, AttackBonus: 6, Speed: 12, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Regeneration", Phase: "any", ProcChance: 0.5, Effect: "regenerate"}, Notes: "SRD-tuned baseline — Regeneration (regenerate) wired from traits. Multiattack: The vampire makes two attacks, only one of which can be a bite attack. Traits: Regeneration, Spider Climb, Vampire Weaknesses"},
"water_elemental": {ID: "water_elemental", Name: "Water Elemental", CR: 5, HP: 114, AC: 14, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.05, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The elemental makes two slam attacks. Traits: Water Form, Freeze"},
"werebear": {ID: "werebear", Name: "Werebear", CR: 5, HP: 135, AC: 10, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0, XPValue: 1800, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Multiattack: In bear form, the werebear makes two claw attacks. Traits: Shapechanger, Keen Smell"},
"wraith": {ID: "wraith", Name: "Wraith", CR: 5, HP: 67, AC: 13, Attack: 8, AttackBonus: 6, Speed: 18, BlockRate: 0.05, XPValue: 1800, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Incorporeal Movement, Sunlight Sensitivity"},
"xorn": {ID: "xorn", Name: "Xorn", CR: 5, HP: 73, AC: 19, Attack: 8, AttackBonus: 6, Speed: 8, BlockRate: 0.15, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The xorn makes three claw attacks and one bite attack. Traits: Earth Glide, Stone Camouflage, Treasure Sense"},
"chimera": {ID: "chimera", Name: "Chimera", CR: 6, HP: 114, AC: 14, Attack: 10, AttackBonus: 0, Speed: 12, BlockRate: 0.05, XPValue: 2300, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The chimera makes three attacks: one with its bite, one with its horns, and one with its claws.", FireAttacker: true},
"drider": {ID: "drider", Name: "Drider", CR: 6, HP: 123, AC: 19, Attack: 10, AttackBonus: 6, Speed: 12, BlockRate: 0.15, XPValue: 2300, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The drider makes three attacks, either with its longsword or its longbow. Traits: Fey Ancestry, Innate Spellcasting, Spider Climb, Sunlight Sensitivity, Web Walker"},
"invisible_stalker": {ID: "invisible_stalker", Name: "Invisible Stalker", CR: 6, HP: 104, AC: 14, Attack: 10, AttackBonus: 6, Speed: 18, BlockRate: 0.05, XPValue: 2300, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The stalker makes two slam attacks. Traits: Invisibility, Faultless Tracker"},
"mage": {ID: "mage", Name: "Mage", CR: 6, HP: 40, AC: 12, Attack: 10, AttackBonus: 5, Speed: 12, BlockRate: 0, XPValue: 2300, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Spellcasting"},
"mammoth": {ID: "mammoth", Name: "Mammoth", CR: 6, HP: 126, AC: 13, Attack: 10, AttackBonus: 10, Speed: 16, BlockRate: 0.05, XPValue: 2300, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Trampling Charge"},
"medusa": {ID: "medusa", Name: "Medusa", CR: 6, HP: 127, AC: 15, Attack: 10, AttackBonus: 5, Speed: 12, BlockRate: 0.1, XPValue: 2300, Ability: &MonsterAbility{Name: "Frightful Presence", Phase: "opening", ProcChance: 0.4, Effect: "stun"}, Notes: "SRD-tuned baseline — Frightful Presence (stun) wired from traits. Multiattack: The medusa makes either three melee attacks - one with its snake hair and two with its shortsword - or two ranged attacks with its longbow. Traits: Petrifying Gaze"},
"vrock": {ID: "vrock", Name: "Vrock", CR: 6, HP: 104, AC: 15, Attack: 10, AttackBonus: 6, Speed: 16, BlockRate: 0.1, XPValue: 2300, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The vrock makes two attacks: one with its beak and one with its talons. Traits: Magic Resistance"},
"wyvern": {ID: "wyvern", Name: "Wyvern", CR: 6, HP: 110, AC: 13, Attack: 10, AttackBonus: 7, Speed: 8, BlockRate: 0.05, XPValue: 2300, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The wyvern makes two attacks: one with its bite and one with its stinger."},
"young_brass_dragon": {ID: "young_brass_dragon", Name: "Young Brass Dragon", CR: 6, HP: 110, AC: 17, Attack: 10, AttackBonus: 0, Speed: 16, BlockRate: 0.1, XPValue: 2300, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws.", FireAttacker: true},
"young_white_dragon": {ID: "young_white_dragon", Name: "Young White Dragon", CR: 6, HP: 133, AC: 17, Attack: 10, AttackBonus: 0, Speed: 16, BlockRate: 0.1, XPValue: 2300, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws. Traits: Ice Walk"},
"giant_ape": {ID: "giant_ape", Name: "Giant Ape", CR: 7, HP: 157, AC: 12, Attack: 12, AttackBonus: 9, Speed: 16, BlockRate: 0, XPValue: 2900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The ape makes two fist attacks."},
"oni": {ID: "oni", Name: "Oni", CR: 7, HP: 110, AC: 16, Attack: 12, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 2900, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The oni makes two attacks, either with its claws or its glaive. Traits: Innate Spellcasting, Magic Weapons, Regeneration"},
"shield_guardian": {ID: "shield_guardian", Name: "Shield Guardian", CR: 7, HP: 142, AC: 17, Attack: 12, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 2900, Ability: &MonsterAbility{Name: "Regeneration", Phase: "any", ProcChance: 0.5, Effect: "regenerate"}, Notes: "SRD-tuned baseline — Regeneration (regenerate) wired from traits. Multiattack: The guardian makes two fist attacks. Traits: Bound, Regeneration, Spell Storing"},
"stone_giant": {ID: "stone_giant", Name: "Stone Giant", CR: 7, HP: 126, AC: 17, Attack: 12, AttackBonus: 9, Speed: 16, BlockRate: 0.1, XPValue: 2900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The giant makes two greatclub attacks. Traits: Stone Camouflage"},
"young_black_dragon": {ID: "young_black_dragon", Name: "Young Black Dragon", CR: 7, HP: 127, AC: 18, Attack: 12, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 2900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws. Traits: Amphibious"},
"young_copper_dragon": {ID: "young_copper_dragon", Name: "Young Copper Dragon", CR: 7, HP: 119, AC: 17, Attack: 12, AttackBonus: 0, Speed: 16, BlockRate: 0.1, XPValue: 2900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws."},
"assassin": {ID: "assassin", Name: "Assassin", CR: 8, HP: 78, AC: 15, Attack: 13, AttackBonus: 6, Speed: 12, BlockRate: 0.1, XPValue: 3900, Ability: &MonsterAbility{Name: "Surprise Attack", Phase: "opening", ProcChance: 0.8, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Surprise Attack (bonus_damage) wired from traits. Multiattack: The assassin makes two shortsword attacks. Traits: Assassinate, Evasion, Sneak Attack (1/Turn)"},
"cloaker": {ID: "cloaker", Name: "Cloaker", CR: 8, HP: 78, AC: 14, Attack: 13, AttackBonus: 6, Speed: 6, BlockRate: 0.05, XPValue: 3900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The cloaker makes two attacks: one with its bite and one with its tail. Traits: Damage Transfer, False Appearance, Light Sensitivity"},
"frost_giant": {ID: "frost_giant", Name: "Frost Giant", CR: 8, HP: 138, AC: 15, Attack: 13, AttackBonus: 9, Speed: 16, BlockRate: 0.1, XPValue: 3900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The giant makes two greataxe attacks."},
"hezrou": {ID: "hezrou", Name: "Hezrou", CR: 8, HP: 136, AC: 16, Attack: 13, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 3900, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The hezrou makes three attacks: one with its bite and two with its claws. Traits: Magic Resistance, Stench"},
"hydra": {ID: "hydra", Name: "Hydra", CR: 8, HP: 172, AC: 15, Attack: 13, AttackBonus: 8, Speed: 12, BlockRate: 0.1, XPValue: 3900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The hydra makes as many bite attacks as it has heads. Traits: Hold Breath, Multiple Heads, Reactive Heads, Wakeful"},
"spirit_naga": {ID: "spirit_naga", Name: "Spirit Naga", CR: 8, HP: 75, AC: 15, Attack: 13, AttackBonus: 7, Speed: 16, BlockRate: 0.1, XPValue: 3900, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Rejuvenation, Spellcasting"},
"tyrannosaurus_rex": {ID: "tyrannosaurus_rex", Name: "Tyrannosaurus Rex", CR: 8, HP: 136, AC: 13, Attack: 13, AttackBonus: 10, Speed: 18, BlockRate: 0.05, XPValue: 3900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The tyrannosaurus makes two attacks: one with its bite and one with its tail."},
"young_bronze_dragon": {ID: "young_bronze_dragon", Name: "Young Bronze Dragon", CR: 8, HP: 142, AC: 18, Attack: 13, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 3900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws. Traits: Amphibious"},
"young_green_dragon": {ID: "young_green_dragon", Name: "Young Green Dragon", CR: 8, HP: 136, AC: 18, Attack: 13, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 3900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws. Traits: Amphibious"},
"bone_devil": {ID: "bone_devil", Name: "Bone Devil", CR: 9, HP: 142, AC: 19, Attack: 14, AttackBonus: 8, Speed: 16, BlockRate: 0.15, XPValue: 5000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The devil makes three attacks: two with its claws and one with its sting. Traits: Devil's Sight, Magic Resistance"},
"clay_golem": {ID: "clay_golem", Name: "Clay Golem", CR: 9, HP: 133, AC: 14, Attack: 14, AttackBonus: 8, Speed: 8, BlockRate: 0.05, XPValue: 5000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The golem makes two slam attacks. Traits: Acid Absorption, Berserk, Immutable Form, Magic Resistance, Magic Weapons"},
"cloud_giant": {ID: "cloud_giant", Name: "Cloud Giant", CR: 9, HP: 200, AC: 14, Attack: 14, AttackBonus: 12, Speed: 16, BlockRate: 0.05, XPValue: 5000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The giant makes two morningstar attacks. Traits: Keen Smell, Innate Spellcasting"},
"fire_giant": {ID: "fire_giant", Name: "Fire Giant", CR: 9, HP: 162, AC: 18, Attack: 14, AttackBonus: 11, Speed: 12, BlockRate: 0.15, XPValue: 5000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The giant makes two greatsword attacks."},
"glabrezu": {ID: "glabrezu", Name: "Glabrezu", CR: 9, HP: 157, AC: 17, Attack: 14, AttackBonus: 9, Speed: 16, BlockRate: 0.1, XPValue: 5000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The glabrezu makes four attacks: two with its pincers and two with its fists. Traits: Innate Spellcasting, Magic Resistance"},
"treant": {ID: "treant", Name: "Treant", CR: 9, HP: 138, AC: 16, Attack: 14, AttackBonus: 10, Speed: 12, BlockRate: 0.1, XPValue: 5000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The treant makes two slam attacks. Traits: False Appearance, Siege Monster"},
"young_blue_dragon": {ID: "young_blue_dragon", Name: "Young Blue Dragon", CR: 9, HP: 152, AC: 18, Attack: 14, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 5000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws."},
"young_silver_dragon": {ID: "young_silver_dragon", Name: "Young Silver Dragon", CR: 9, HP: 168, AC: 18, Attack: 14, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 5000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws."},
"aboleth": {ID: "aboleth", Name: "Aboleth", CR: 10, HP: 135, AC: 17, Attack: 16, AttackBonus: 9, Speed: 6, BlockRate: 0.1, XPValue: 5900, Ability: &MonsterAbility{Name: "Damaging Aura", Phase: "any", ProcChance: 0.4, Effect: "retaliate"}, Notes: "SRD-tuned baseline — Damaging Aura (retaliate) wired from traits. Multiattack: The aboleth makes three tentacle attacks. Traits: Amphibious, Mucous Cloud, Probing Telepathy"},
"deva": {ID: "deva", Name: "Deva", CR: 10, HP: 136, AC: 17, Attack: 16, AttackBonus: 8, Speed: 12, BlockRate: 0.1, XPValue: 5900, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The deva makes two melee attacks. Traits: Angelic Weapons, Innate Spellcasting, Magic Resistance"},
"guardian_naga": {ID: "guardian_naga", Name: "Guardian Naga", CR: 10, HP: 127, AC: 18, Attack: 16, AttackBonus: 8, Speed: 16, BlockRate: 0.15, XPValue: 5900, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Rejuvenation, Spellcasting"},
"stone_golem": {ID: "stone_golem", Name: "Stone Golem", CR: 10, HP: 178, AC: 17, Attack: 16, AttackBonus: 10, Speed: 12, BlockRate: 0.1, XPValue: 5900, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The golem makes two slam attacks. Traits: Immutable Form, Magic Resistance, Magic Weapons"},
"young_gold_dragon": {ID: "young_gold_dragon", Name: "Young Gold Dragon", CR: 10, HP: 178, AC: 18, Attack: 16, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 5900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws. Traits: Amphibious", FireAttacker: true},
"young_red_dragon": {ID: "young_red_dragon", Name: "Young Red Dragon", CR: 10, HP: 178, AC: 18, Attack: 16, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 5900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws.", FireAttacker: true},
"behir": {ID: "behir", Name: "Behir", CR: 11, HP: 168, AC: 17, Attack: 18, AttackBonus: 0, Speed: 18, BlockRate: 0.1, XPValue: 7200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The behir makes two attacks: one with its bite and one to constrict."},
"chain_devil": {ID: "chain_devil", Name: "Chain Devil", CR: 11, HP: 85, AC: 16, Attack: 18, AttackBonus: 8, Speed: 12, BlockRate: 0.1, XPValue: 7200, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The devil makes two attacks with its chains. Traits: Devil's Sight, Magic Resistance"},
"djinni": {ID: "djinni", Name: "Djinni", CR: 11, HP: 161, AC: 17, Attack: 18, AttackBonus: 9, Speed: 12, BlockRate: 0.1, XPValue: 7200, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Multiattack: The djinni makes three scimitar attacks. Traits: Elemental Demise, Innate Spellcasting, Variant: Genie Powers"},
"efreeti": {ID: "efreeti", Name: "Efreeti", CR: 11, HP: 200, AC: 17, Attack: 18, AttackBonus: 7, Speed: 16, BlockRate: 0.1, XPValue: 7200, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Multiattack: The efreeti makes two scimitar attacks or uses its Hurl Flame twice. Traits: Elemental Demise, Innate Spellcasting, Variant: Genie Powers", FireAttacker: true},
"gynosphinx": {ID: "gynosphinx", Name: "Gynosphinx", CR: 11, HP: 136, AC: 17, Attack: 18, AttackBonus: 9, Speed: 16, BlockRate: 0.1, XPValue: 7200, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The sphinx makes two claw attacks. Traits: Inscrutable, Magic Weapons, Spellcasting"},
"horned_devil": {ID: "horned_devil", Name: "Horned Devil", CR: 11, HP: 178, AC: 18, Attack: 18, AttackBonus: 10, Speed: 8, BlockRate: 0.15, XPValue: 7200, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The devil makes three melee attacks: two with its fork and one with its tail. Traits: Devil's Sight, Magic Resistance"},
"remorhaz": {ID: "remorhaz", Name: "Remorhaz", CR: 11, HP: 195, AC: 17, Attack: 18, AttackBonus: 11, Speed: 12, BlockRate: 0.1, XPValue: 7200, Ability: &MonsterAbility{Name: "Damaging Aura", Phase: "any", ProcChance: 0.4, Effect: "retaliate"}, Notes: "SRD-tuned baseline — Damaging Aura (retaliate) wired from traits. Traits: Heated Body"},
"roc": {ID: "roc", Name: "Roc", CR: 11, HP: 248, AC: 15, Attack: 18, AttackBonus: 13, Speed: 8, BlockRate: 0.1, XPValue: 7200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The roc makes two attacks: one with its beak and one with its talons. Traits: Keen Sight"},
"archmage": {ID: "archmage", Name: "Archmage", CR: 12, HP: 99, AC: 12, Attack: 19, AttackBonus: 6, Speed: 12, BlockRate: 0, XPValue: 8400, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Magic Resistance, Spellcasting"},
"erinyes": {ID: "erinyes", Name: "Erinyes", CR: 12, HP: 153, AC: 18, Attack: 19, AttackBonus: 8, Speed: 12, BlockRate: 0.15, XPValue: 8400, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The erinyes makes three attacks Traits: Hellish Weapons, Magic Resistance"},
"adult_brass_dragon": {ID: "adult_brass_dragon", Name: "Adult Brass Dragon", CR: 13, HP: 172, AC: 18, Attack: 21, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 10000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)", FireAttacker: true},
"adult_white_dragon": {ID: "adult_white_dragon", Name: "Adult White Dragon", CR: 13, HP: 200, AC: 18, Attack: 21, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 10000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Ice Walk, Legendary Resistance (3/Day)"},
"nalfeshnee": {ID: "nalfeshnee", Name: "Nalfeshnee", CR: 13, HP: 184, AC: 18, Attack: 21, AttackBonus: 10, Speed: 8, BlockRate: 0.15, XPValue: 10000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The nalfeshnee uses Horror Nimbus if it can. Traits: Magic Resistance"},
"rakshasa": {ID: "rakshasa", Name: "Rakshasa", CR: 13, HP: 110, AC: 16, Attack: 21, AttackBonus: 7, Speed: 16, BlockRate: 0.1, XPValue: 10000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The rakshasa makes two claw attacks Traits: Limited Magic Immunity, Innate Spellcasting"},
"storm_giant": {ID: "storm_giant", Name: "Storm Giant", CR: 13, HP: 230, AC: 16, Attack: 21, AttackBonus: 0, Speed: 18, BlockRate: 0.1, XPValue: 10000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The giant makes two greatsword attacks. Traits: Amphibious, Innate Spellcasting"},
"vampire": {ID: "vampire", Name: "Vampire", CR: 13, HP: 144, AC: 16, Attack: 21, AttackBonus: 9, Speed: 12, BlockRate: 0.1, XPValue: 10000, Ability: &MonsterAbility{Name: "Regeneration", Phase: "any", ProcChance: 0.5, Effect: "regenerate"}, Notes: "SRD-tuned baseline — Regeneration (regenerate) wired from traits. Traits: Shapechanger, Legendary Resistance (3/Day), Misty Escape, Regeneration, Spider Climb, Vampire Weaknesses"},
"adult_black_dragon": {ID: "adult_black_dragon", Name: "Adult Black Dragon", CR: 14, HP: 195, AC: 19, Attack: 23, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 11500, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)"},
"adult_copper_dragon": {ID: "adult_copper_dragon", Name: "Adult Copper Dragon", CR: 14, HP: 184, AC: 18, Attack: 23, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 11500, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)"},
"ice_devil": {ID: "ice_devil", Name: "Ice Devil", CR: 14, HP: 180, AC: 18, Attack: 23, AttackBonus: 10, Speed: 16, BlockRate: 0.15, XPValue: 11500, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The devil makes three attacks: one with its bite, one with its claws, and one with its tail. Traits: Devil's Sight, Magic Resistance"},
"adult_bronze_dragon": {ID: "adult_bronze_dragon", Name: "Adult Bronze Dragon", CR: 15, HP: 212, AC: 19, Attack: 24, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 13000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)"},
"adult_green_dragon": {ID: "adult_green_dragon", Name: "Adult Green Dragon", CR: 15, HP: 207, AC: 19, Attack: 24, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 13000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)"},
"mummy_lord": {ID: "mummy_lord", Name: "Mummy Lord", CR: 15, HP: 97, AC: 17, Attack: 24, AttackBonus: 9, Speed: 8, BlockRate: 0.1, XPValue: 13000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The mummy can use its Dreadful Glare and makes one attack with its rotting fist. Traits: Magic Resistance, Rejuvenation, Spellcasting"},
"purple_worm": {ID: "purple_worm", Name: "Purple Worm", CR: 15, HP: 247, AC: 18, Attack: 24, AttackBonus: 9, Speed: 18, BlockRate: 0.15, XPValue: 13000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The worm makes two attacks: one with its bite and one with its stinger. Traits: Tunneler"},
"adult_blue_dragon": {ID: "adult_blue_dragon", Name: "Adult Blue Dragon", CR: 16, HP: 225, AC: 19, Attack: 26, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 15000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)"},
"adult_silver_dragon": {ID: "adult_silver_dragon", Name: "Adult Silver Dragon", CR: 16, HP: 243, AC: 19, Attack: 26, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 15000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)"},
"iron_golem": {ID: "iron_golem", Name: "Iron Golem", CR: 16, HP: 210, AC: 20, Attack: 26, AttackBonus: 0, Speed: 12, BlockRate: 0.15, XPValue: 15000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The golem makes two melee attacks. Traits: Fire Absorption, Immutable Form, Magic Resistance, Magic Weapons"},
"marilith": {ID: "marilith", Name: "Marilith", CR: 16, HP: 189, AC: 18, Attack: 26, AttackBonus: 9, Speed: 16, BlockRate: 0.15, XPValue: 15000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The marilith can make seven attacks: six with its longswords and one with its tail. Traits: Magic Resistance, Magic Weapons, Reactive"},
"planetar": {ID: "planetar", Name: "Planetar", CR: 16, HP: 200, AC: 19, Attack: 26, AttackBonus: 12, Speed: 16, BlockRate: 0.15, XPValue: 15000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The planetar makes two melee attacks. Traits: Angelic Weapons, Divine Awareness, Innate Spellcasting, Magic Resistance"},
"adult_gold_dragon": {ID: "adult_gold_dragon", Name: "Adult Gold Dragon", CR: 17, HP: 256, AC: 19, Attack: 28, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 18000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)", FireAttacker: true},
"adult_red_dragon": {ID: "adult_red_dragon", Name: "Adult Red Dragon", CR: 17, HP: 256, AC: 19, Attack: 28, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 18000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)", FireAttacker: true},
"androsphinx": {ID: "androsphinx", Name: "Androsphinx", CR: 17, HP: 199, AC: 17, Attack: 28, AttackBonus: 12, Speed: 16, BlockRate: 0.1, XPValue: 18000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The sphinx makes two claw attacks. Traits: Inscrutable, Magic Weapons, Spellcasting"},
"dragon_turtle": {ID: "dragon_turtle", Name: "Dragon Turtle", CR: 17, HP: 341, AC: 20, Attack: 28, AttackBonus: 0, Speed: 8, BlockRate: 0.15, XPValue: 18000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon turtle makes three attacks: one with its bite and two with its claws. Traits: Amphibious", FireAttacker: true},
"balor": {ID: "balor", Name: "Balor", CR: 19, HP: 262, AC: 19, Attack: 31, AttackBonus: 14, Speed: 16, BlockRate: 0.15, XPValue: 22000, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Multiattack: The balor makes two attacks: one with its longsword and one with its whip. Traits: Death Throes, Fire Aura, Magic Resistance, Magic Weapons"},
"ancient_brass_dragon": {ID: "ancient_brass_dragon", Name: "Ancient Brass Dragon", CR: 20, HP: 297, AC: 20, Attack: 33, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 25000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)", FireAttacker: true},
"ancient_white_dragon": {ID: "ancient_white_dragon", Name: "Ancient White Dragon", CR: 20, HP: 333, AC: 20, Attack: 33, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 25000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Ice Walk, Legendary Resistance (3/Day)"},
"pit_fiend": {ID: "pit_fiend", Name: "Pit Fiend", CR: 20, HP: 300, AC: 19, Attack: 33, AttackBonus: 14, Speed: 12, BlockRate: 0.15, XPValue: 25000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The pit fiend makes four attacks: one with its bite, one with its claw, one with its mace, and one with its tail. Traits: Fear Aura, Magic Resistance, Magic Weapons, Innate Spellcasting"},
"ancient_black_dragon": {ID: "ancient_black_dragon", Name: "Ancient Black Dragon", CR: 21, HP: 367, AC: 22, Attack: 34, AttackBonus: 15, Speed: 16, BlockRate: 0.15, XPValue: 33000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)"},
"ancient_copper_dragon": {ID: "ancient_copper_dragon", Name: "Ancient Copper Dragon", CR: 21, HP: 350, AC: 21, Attack: 34, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 33000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)"},
"lich": {ID: "lich", Name: "Lich", CR: 21, HP: 135, AC: 17, Attack: 34, AttackBonus: 12, Speed: 12, BlockRate: 0.1, XPValue: 33000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Legendary Resistance (3/Day), Rejuvenation, Spellcasting, Turn Resistance"},
"solar": {ID: "solar", Name: "Solar", CR: 21, HP: 243, AC: 21, Attack: 34, AttackBonus: 15, Speed: 18, BlockRate: 0.15, XPValue: 33000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The solar makes two greatsword attacks. Traits: Angelic Weapons, Divine Awareness, Innate Spellcasting, Magic Resistance"},
"ancient_bronze_dragon": {ID: "ancient_bronze_dragon", Name: "Ancient Bronze Dragon", CR: 22, HP: 444, AC: 22, Attack: 36, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 41000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)"},
"ancient_green_dragon": {ID: "ancient_green_dragon", Name: "Ancient Green Dragon", CR: 22, HP: 385, AC: 21, Attack: 36, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 41000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)"},
"ancient_blue_dragon": {ID: "ancient_blue_dragon", Name: "Ancient Blue Dragon", CR: 23, HP: 481, AC: 22, Attack: 37, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 50000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)"},
"ancient_silver_dragon": {ID: "ancient_silver_dragon", Name: "Ancient Silver Dragon", CR: 23, HP: 487, AC: 22, Attack: 37, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 50000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)"},
"kraken": {ID: "kraken", Name: "Kraken", CR: 23, HP: 472, AC: 18, Attack: 37, AttackBonus: 7, Speed: 8, BlockRate: 0.15, XPValue: 50000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The kraken makes three tentacle attacks, each of which it can replace with one use of Fling. Traits: Amphibious, Freedom of Movement, Siege Monster"},
"ancient_gold_dragon": {ID: "ancient_gold_dragon", Name: "Ancient Gold Dragon", CR: 24, HP: 546, AC: 22, Attack: 38, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 62000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)", FireAttacker: true},
"ancient_red_dragon": {ID: "ancient_red_dragon", Name: "Ancient Red Dragon", CR: 24, HP: 546, AC: 22, Attack: 38, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 62000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)", FireAttacker: true},
"tarrasque": {ID: "tarrasque", Name: "Tarrasque", CR: 30, HP: 676, AC: 25, Attack: 46, AttackBonus: 19, Speed: 16, BlockRate: 0.15, XPValue: 155000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The tarrasque can use its Frightful Presence. Traits: Legendary Resistance (3/Day), Magic Resistance, Reflective Carapace, Siege Monster"},
"wererat": {ID: "wererat", Name: "Wererat", CR: 2, HP: 33, AC: 12, Attack: 4, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Shapechanger, Keen Smell"},
"white_dragon_wyrmling": {ID: "white_dragon_wyrmling", Name: "White Dragon Wyrmling", CR: 2, HP: 32, AC: 16, Attack: 4, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"will_o_wisp": {ID: "will_o_wisp", Name: "Will-o'-Wisp", CR: 2, HP: 22, AC: 19, Attack: 4, AttackBonus: 4, Speed: 18, BlockRate: 0.15, XPValue: 450, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Consume Life, Ephemeral, Incorporeal Movement, Variable Illumination"},
"basilisk": {ID: "basilisk", Name: "Basilisk", CR: 3, HP: 52, AC: 12, Attack: 6, AttackBonus: 5, Speed: 8, BlockRate: 0, XPValue: 700, Ability: &MonsterAbility{Name: "Frightful Presence", Phase: "opening", ProcChance: 0.4, Effect: "stun"}, Notes: "SRD-tuned baseline — Frightful Presence (stun) wired from traits. Traits: Petrifying Gaze"},
"bearded_devil": {ID: "bearded_devil", Name: "Bearded Devil", CR: 3, HP: 52, AC: 13, Attack: 6, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 700, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The devil makes two attacks: one with its beard and one with its glaive. Traits: Devil's Sight, Magic Resistance, Steadfast"},
"blue_dragon_wyrmling": {ID: "blue_dragon_wyrmling", Name: "Blue Dragon Wyrmling", CR: 3, HP: 52, AC: 17, Attack: 6, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
"doppelganger": {ID: "doppelganger", Name: "Doppelganger", CR: 3, HP: 52, AC: 14, Attack: 6, AttackBonus: 6, Speed: 12, BlockRate: 0.05, XPValue: 700, Ability: &MonsterAbility{Name: "Surprise Attack", Phase: "opening", ProcChance: 0.8, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Surprise Attack (bonus_damage) wired from traits. Multiattack: The doppelganger makes two melee attacks. Traits: Shapechanger, Ambusher, Surprise Attack"},
"giant_scorpion": {ID: "giant_scorpion", Name: "Giant Scorpion", CR: 3, HP: 52, AC: 15, Attack: 6, AttackBonus: 4, Speed: 16, BlockRate: 0.1, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The scorpion makes three attacks: two with its claws and one with its sting."},
"gold_dragon_wyrmling": {ID: "gold_dragon_wyrmling", Name: "Gold Dragon Wyrmling", CR: 3, HP: 60, AC: 17, Attack: 6, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious", FireAttacker: true},
"green_hag": {ID: "green_hag", Name: "Green Hag", CR: 3, HP: 82, AC: 17, Attack: 6, AttackBonus: 6, Speed: 12, BlockRate: 0.1, XPValue: 700, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Amphibious, Innate Spellcasting, Mimicry, Hag Coven, Shared Spellcasting (Coven Only), Hag Eye (Coven Only)"},
"hell_hound": {ID: "hell_hound", Name: "Hell Hound", CR: 3, HP: 45, AC: 15, Attack: 6, AttackBonus: 0, Speed: 18, BlockRate: 0.1, XPValue: 700, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Hearing and Smell, Pack Tactics", FireAttacker: true},
"killer_whale": {ID: "killer_whale", Name: "Killer Whale", CR: 3, HP: 90, AC: 12, Attack: 6, AttackBonus: 0, Speed: 18, BlockRate: 0, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Echolocation, Hold Breath, Keen Hearing"},
"knight": {ID: "knight", Name: "Knight", CR: 3, HP: 52, AC: 18, Attack: 6, AttackBonus: 5, Speed: 12, BlockRate: 0.15, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The knight makes two melee attacks. Traits: Brave"},
"manticore": {ID: "manticore", Name: "Manticore", CR: 3, HP: 68, AC: 14, Attack: 6, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The manticore makes three attacks: one with its bite and two with its claws or three with its tail spikes. Traits: Tail Spike Regrowth"},
"minotaur": {ID: "minotaur", Name: "Minotaur", CR: 3, HP: 76, AC: 14, Attack: 6, AttackBonus: 6, Speed: 16, BlockRate: 0.05, XPValue: 700, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Charge, Labyrinthine Recall, Reckless"},
"mummy": {ID: "mummy", Name: "Mummy", CR: 3, HP: 58, AC: 11, Attack: 6, AttackBonus: 5, Speed: 8, BlockRate: 0, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The mummy can use its Dreadful Glare and makes one attack with its rotting fist."},
"nightmare": {ID: "nightmare", Name: "Nightmare", CR: 3, HP: 68, AC: 13, Attack: 6, AttackBonus: 6, Speed: 18, BlockRate: 0.05, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Confer Fire Resistance, Illumination"},
"owlbear": {ID: "owlbear", Name: "Owlbear", CR: 3, HP: 59, AC: 13, Attack: 6, AttackBonus: 7, Speed: 16, BlockRate: 0.05, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The owlbear makes two attacks: one with its beak and one with its claws. Traits: Keen Sight and Smell"},
"phase_spider": {ID: "phase_spider", Name: "Phase Spider", CR: 3, HP: 32, AC: 13, Attack: 6, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 700, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Ethereal Jaunt, Spider Climb, Web Walker"},
"veteran": {ID: "veteran", Name: "Veteran", CR: 3, HP: 58, AC: 17, Attack: 6, AttackBonus: 5, Speed: 12, BlockRate: 0.1, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The veteran makes two longsword attacks."},
"werewolf": {ID: "werewolf", Name: "Werewolf", CR: 3, HP: 58, AC: 11, Attack: 6, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 700, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Shapechanger, Keen Hearing and Smell"},
"wight": {ID: "wight", Name: "Wight", CR: 3, HP: 45, AC: 14, Attack: 6, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The wight makes two longsword attacks or two longbow attacks. Traits: Sunlight Sensitivity"},
"winter_wolf": {ID: "winter_wolf", Name: "Winter Wolf", CR: 3, HP: 75, AC: 13, Attack: 6, AttackBonus: 0, Speed: 18, BlockRate: 0.05, XPValue: 700, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Hearing and Smell, Pack Tactics, Snow Camouflage"},
"black_pudding": {ID: "black_pudding", Name: "Black Pudding", CR: 4, HP: 85, AC: 10, Attack: 7, AttackBonus: 5, Speed: 8, BlockRate: 0, XPValue: 1100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amorphous, Corrosive Form, Spider Climb"},
"chuul": {ID: "chuul", Name: "Chuul", CR: 4, HP: 93, AC: 16, Attack: 7, AttackBonus: 6, Speed: 12, BlockRate: 0.1, XPValue: 1100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The chuul makes two pincer attacks. Traits: Amphibious, Sense Magic"},
"couatl": {ID: "couatl", Name: "Couatl", CR: 4, HP: 97, AC: 19, Attack: 7, AttackBonus: 6, Speed: 12, BlockRate: 0.15, XPValue: 1100, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Innate Spellcasting, Magic Weapons, Shielded Mind"},
"elephant": {ID: "elephant", Name: "Elephant", CR: 4, HP: 76, AC: 12, Attack: 7, AttackBonus: 8, Speed: 16, BlockRate: 0, XPValue: 1100, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Trampling Charge"},
"ettin": {ID: "ettin", Name: "Ettin", CR: 4, HP: 85, AC: 12, Attack: 7, AttackBonus: 7, Speed: 16, BlockRate: 0, XPValue: 1100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The ettin makes two attacks: one with its battleaxe and one with its morningstar. Traits: Two Heads, Wakeful"},
"ghost": {ID: "ghost", Name: "Ghost", CR: 4, HP: 45, AC: 11, Attack: 7, AttackBonus: 5, Speed: 16, BlockRate: 0, XPValue: 1100, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Ethereal Sight, Incorporeal Movement"},
"lamia": {ID: "lamia", Name: "Lamia", CR: 4, HP: 97, AC: 13, Attack: 7, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 1100, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The lamia makes two attacks: one with its claws and one with its dagger or Intoxicating Touch. Traits: Innate Spellcasting"},
"red_dragon_wyrmling": {ID: "red_dragon_wyrmling", Name: "Red Dragon Wyrmling", CR: 4, HP: 75, AC: 17, Attack: 7, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 1100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat).", FireAttacker: true},
"succubusincubus": {ID: "succubusincubus", Name: "Succubus/Incubus", CR: 4, HP: 66, AC: 15, Attack: 7, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 1100, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Telepathic Bond, Shapechanger"},
"wereboar": {ID: "wereboar", Name: "Wereboar", CR: 4, HP: 78, AC: 10, Attack: 7, AttackBonus: 5, Speed: 12, BlockRate: 0, XPValue: 1100, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Shapechanger, Charge (Boar or Hybrid Form Only), Relentless (Recharges after a Short or Long Rest)"},
"weretiger": {ID: "weretiger", Name: "Weretiger", CR: 4, HP: 120, AC: 12, Attack: 7, AttackBonus: 5, Speed: 12, BlockRate: 0, XPValue: 1100, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Shapechanger, Keen Hearing and Smell, Pounce (Tiger or Hybrid Form Only)"},
"air_elemental": {ID: "air_elemental", Name: "Air Elemental", CR: 5, HP: 90, AC: 15, Attack: 8, AttackBonus: 8, Speed: 18, BlockRate: 0.1, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The elemental makes two slam attacks. Traits: Air Form"},
"barbed_devil": {ID: "barbed_devil", Name: "Barbed Devil", CR: 5, HP: 110, AC: 15, Attack: 8, AttackBonus: 6, Speed: 12, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The devil makes three melee attacks: one with its tail and two with its claws. Traits: Barbed Hide, Devil's Sight, Magic Resistance"},
"bulette": {ID: "bulette", Name: "Bulette", CR: 5, HP: 94, AC: 17, Attack: 8, AttackBonus: 7, Speed: 16, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Standing Leap"},
"earth_elemental": {ID: "earth_elemental", Name: "Earth Elemental", CR: 5, HP: 126, AC: 17, Attack: 8, AttackBonus: 8, Speed: 12, BlockRate: 0.1, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The elemental makes two slam attacks. Traits: Earth Glide, Siege Monster"},
"fire_elemental": {ID: "fire_elemental", Name: "Fire Elemental", CR: 5, HP: 102, AC: 13, Attack: 8, AttackBonus: 6, Speed: 18, BlockRate: 0.05, XPValue: 1800, Ability: &MonsterAbility{Name: "Damaging Aura", Phase: "any", ProcChance: 0.4, Effect: "retaliate"}, Notes: "SRD-tuned baseline — Damaging Aura (retaliate) wired from traits. Multiattack: The elemental makes two touch attacks. Traits: Fire Form, Illumination, Water Susceptibility", FireAttacker: true},
"flesh_golem": {ID: "flesh_golem", Name: "Flesh Golem", CR: 5, HP: 93, AC: 10, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0, XPValue: 1800, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The golem makes two slam attacks. Traits: Berserk, Aversion of Fire, Immutable Form, Lightning Absorption, Magic Resistance, Magic Weapons"},
"giant_crocodile": {ID: "giant_crocodile", Name: "Giant Crocodile", CR: 5, HP: 85, AC: 14, Attack: 8, AttackBonus: 8, Speed: 12, BlockRate: 0.05, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The crocodile makes two attacks: one with its bite and one with its tail. Traits: Hold Breath"},
"giant_shark": {ID: "giant_shark", Name: "Giant Shark", CR: 5, HP: 126, AC: 13, Attack: 8, AttackBonus: 9, Speed: 18, BlockRate: 0.05, XPValue: 1800, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Blood Frenzy, Water Breathing"},
"gladiator": {ID: "gladiator", Name: "Gladiator", CR: 5, HP: 112, AC: 16, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Multiattack: The gladiator makes three melee attacks or two ranged attacks. Traits: Brave, Brute"},
"gorgon": {ID: "gorgon", Name: "Gorgon", CR: 5, HP: 114, AC: 19, Attack: 8, AttackBonus: 8, Speed: 16, BlockRate: 0.15, XPValue: 1800, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Trampling Charge"},
"half_red_dragon_veteran": {ID: "half_red_dragon_veteran", Name: "Half-Red Dragon Veteran", CR: 5, HP: 65, AC: 18, Attack: 8, AttackBonus: 0, Speed: 12, BlockRate: 0.15, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The veteran makes two longsword attacks.", FireAttacker: true},
"hill_giant": {ID: "hill_giant", Name: "Hill Giant", CR: 5, HP: 105, AC: 13, Attack: 8, AttackBonus: 8, Speed: 16, BlockRate: 0.05, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The giant makes two greatclub attacks."},
"night_hag": {ID: "night_hag", Name: "Night Hag", CR: 5, HP: 112, AC: 17, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Innate Spellcasting, Magic Resistance, Night Hag Items, Hag Coven, Shared Spellcasting (Coven Only), Hag Eye (Coven Only)"},
"otyugh": {ID: "otyugh", Name: "Otyugh", CR: 5, HP: 114, AC: 14, Attack: 8, AttackBonus: 6, Speed: 12, BlockRate: 0.05, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The otyugh makes three attacks: one with its bite and two with its tentacles. Traits: Limited Telepathy"},
"roper": {ID: "roper", Name: "Roper", CR: 5, HP: 93, AC: 20, Attack: 8, AttackBonus: 7, Speed: 6, BlockRate: 0.15, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The roper makes four attacks with its tendrils, uses Reel, and makes one attack with its bite. Traits: False Appearance, Grasping Tendrils, Spider Climb"},
"salamander": {ID: "salamander", Name: "Salamander", CR: 5, HP: 90, AC: 15, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Damaging Aura", Phase: "any", ProcChance: 0.4, Effect: "retaliate"}, Notes: "SRD-tuned baseline — Damaging Aura (retaliate) wired from traits. Multiattack: The salamander makes two attacks: one with its spear and one with its tail. Traits: Heated Body, Heated Weapons"},
"shambling_mound": {ID: "shambling_mound", Name: "Shambling Mound", CR: 5, HP: 136, AC: 15, Attack: 8, AttackBonus: 7, Speed: 8, BlockRate: 0.1, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The shambling mound makes two slam attacks. Traits: Lightning Absorption"},
"triceratops": {ID: "triceratops", Name: "Triceratops", CR: 5, HP: 95, AC: 13, Attack: 8, AttackBonus: 9, Speed: 18, BlockRate: 0.05, XPValue: 1800, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Trampling Charge"},
"troll": {ID: "troll", Name: "Troll", CR: 5, HP: 84, AC: 15, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Regeneration", Phase: "any", ProcChance: 0.5, Effect: "regenerate"}, Notes: "SRD-tuned baseline — Regeneration (regenerate) wired from traits. Multiattack: The troll makes three attacks: one with its bite and two with its claws. Traits: Keen Smell, Regeneration, Variant: Loathsome Limbs"},
"unicorn": {ID: "unicorn", Name: "Unicorn", CR: 5, HP: 67, AC: 12, Attack: 8, AttackBonus: 7, Speed: 18, BlockRate: 0, XPValue: 1800, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The unicorn makes two attacks: one with its hooves and one with its horn. Traits: Charge, Innate Spellcasting, Magic Resistance, Magic Weapons"},
"vampire_spawn": {ID: "vampire_spawn", Name: "Vampire Spawn", CR: 5, HP: 82, AC: 15, Attack: 8, AttackBonus: 6, Speed: 12, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Regeneration", Phase: "any", ProcChance: 0.5, Effect: "regenerate"}, Notes: "SRD-tuned baseline — Regeneration (regenerate) wired from traits. Multiattack: The vampire makes two attacks, only one of which can be a bite attack. Traits: Regeneration, Spider Climb, Vampire Weaknesses"},
"water_elemental": {ID: "water_elemental", Name: "Water Elemental", CR: 5, HP: 114, AC: 14, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.05, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The elemental makes two slam attacks. Traits: Water Form, Freeze"},
"werebear": {ID: "werebear", Name: "Werebear", CR: 5, HP: 135, AC: 10, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0, XPValue: 1800, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Multiattack: In bear form, the werebear makes two claw attacks. Traits: Shapechanger, Keen Smell"},
"wraith": {ID: "wraith", Name: "Wraith", CR: 5, HP: 67, AC: 13, Attack: 8, AttackBonus: 6, Speed: 18, BlockRate: 0.05, XPValue: 1800, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Incorporeal Movement, Sunlight Sensitivity"},
"xorn": {ID: "xorn", Name: "Xorn", CR: 5, HP: 73, AC: 19, Attack: 8, AttackBonus: 6, Speed: 8, BlockRate: 0.15, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The xorn makes three claw attacks and one bite attack. Traits: Earth Glide, Stone Camouflage, Treasure Sense"},
"chimera": {ID: "chimera", Name: "Chimera", CR: 6, HP: 114, AC: 14, Attack: 10, AttackBonus: 0, Speed: 12, BlockRate: 0.05, XPValue: 2300, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The chimera makes three attacks: one with its bite, one with its horns, and one with its claws.", FireAttacker: true},
"drider": {ID: "drider", Name: "Drider", CR: 6, HP: 123, AC: 19, Attack: 10, AttackBonus: 6, Speed: 12, BlockRate: 0.15, XPValue: 2300, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The drider makes three attacks, either with its longsword or its longbow. Traits: Fey Ancestry, Innate Spellcasting, Spider Climb, Sunlight Sensitivity, Web Walker"},
"invisible_stalker": {ID: "invisible_stalker", Name: "Invisible Stalker", CR: 6, HP: 104, AC: 14, Attack: 10, AttackBonus: 6, Speed: 18, BlockRate: 0.05, XPValue: 2300, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The stalker makes two slam attacks. Traits: Invisibility, Faultless Tracker"},
"mage": {ID: "mage", Name: "Mage", CR: 6, HP: 40, AC: 12, Attack: 10, AttackBonus: 5, Speed: 12, BlockRate: 0, XPValue: 2300, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Spellcasting"},
"mammoth": {ID: "mammoth", Name: "Mammoth", CR: 6, HP: 126, AC: 13, Attack: 10, AttackBonus: 10, Speed: 16, BlockRate: 0.05, XPValue: 2300, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Trampling Charge"},
"medusa": {ID: "medusa", Name: "Medusa", CR: 6, HP: 127, AC: 15, Attack: 10, AttackBonus: 5, Speed: 12, BlockRate: 0.1, XPValue: 2300, Ability: &MonsterAbility{Name: "Frightful Presence", Phase: "opening", ProcChance: 0.4, Effect: "stun"}, Notes: "SRD-tuned baseline — Frightful Presence (stun) wired from traits. Multiattack: The medusa makes either three melee attacks - one with its snake hair and two with its shortsword - or two ranged attacks with its longbow. Traits: Petrifying Gaze"},
"vrock": {ID: "vrock", Name: "Vrock", CR: 6, HP: 104, AC: 15, Attack: 10, AttackBonus: 6, Speed: 16, BlockRate: 0.1, XPValue: 2300, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The vrock makes two attacks: one with its beak and one with its talons. Traits: Magic Resistance"},
"wyvern": {ID: "wyvern", Name: "Wyvern", CR: 6, HP: 110, AC: 13, Attack: 10, AttackBonus: 7, Speed: 8, BlockRate: 0.05, XPValue: 2300, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The wyvern makes two attacks: one with its bite and one with its stinger."},
"young_brass_dragon": {ID: "young_brass_dragon", Name: "Young Brass Dragon", CR: 6, HP: 110, AC: 17, Attack: 10, AttackBonus: 0, Speed: 16, BlockRate: 0.1, XPValue: 2300, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws.", FireAttacker: true},
"young_white_dragon": {ID: "young_white_dragon", Name: "Young White Dragon", CR: 6, HP: 133, AC: 17, Attack: 10, AttackBonus: 0, Speed: 16, BlockRate: 0.1, XPValue: 2300, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws. Traits: Ice Walk"},
"giant_ape": {ID: "giant_ape", Name: "Giant Ape", CR: 7, HP: 157, AC: 12, Attack: 12, AttackBonus: 9, Speed: 16, BlockRate: 0, XPValue: 2900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The ape makes two fist attacks."},
"oni": {ID: "oni", Name: "Oni", CR: 7, HP: 110, AC: 16, Attack: 12, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 2900, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The oni makes two attacks, either with its claws or its glaive. Traits: Innate Spellcasting, Magic Weapons, Regeneration"},
"shield_guardian": {ID: "shield_guardian", Name: "Shield Guardian", CR: 7, HP: 142, AC: 17, Attack: 12, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 2900, Ability: &MonsterAbility{Name: "Regeneration", Phase: "any", ProcChance: 0.5, Effect: "regenerate"}, Notes: "SRD-tuned baseline — Regeneration (regenerate) wired from traits. Multiattack: The guardian makes two fist attacks. Traits: Bound, Regeneration, Spell Storing"},
"stone_giant": {ID: "stone_giant", Name: "Stone Giant", CR: 7, HP: 126, AC: 17, Attack: 12, AttackBonus: 9, Speed: 16, BlockRate: 0.1, XPValue: 2900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The giant makes two greatclub attacks. Traits: Stone Camouflage"},
"young_black_dragon": {ID: "young_black_dragon", Name: "Young Black Dragon", CR: 7, HP: 127, AC: 18, Attack: 12, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 2900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws. Traits: Amphibious"},
"young_copper_dragon": {ID: "young_copper_dragon", Name: "Young Copper Dragon", CR: 7, HP: 119, AC: 17, Attack: 12, AttackBonus: 0, Speed: 16, BlockRate: 0.1, XPValue: 2900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws."},
"assassin": {ID: "assassin", Name: "Assassin", CR: 8, HP: 78, AC: 15, Attack: 13, AttackBonus: 6, Speed: 12, BlockRate: 0.1, XPValue: 3900, Ability: &MonsterAbility{Name: "Surprise Attack", Phase: "opening", ProcChance: 0.8, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Surprise Attack (bonus_damage) wired from traits. Multiattack: The assassin makes two shortsword attacks. Traits: Assassinate, Evasion, Sneak Attack (1/Turn)"},
"cloaker": {ID: "cloaker", Name: "Cloaker", CR: 8, HP: 78, AC: 14, Attack: 13, AttackBonus: 6, Speed: 6, BlockRate: 0.05, XPValue: 3900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The cloaker makes two attacks: one with its bite and one with its tail. Traits: Damage Transfer, False Appearance, Light Sensitivity"},
"frost_giant": {ID: "frost_giant", Name: "Frost Giant", CR: 8, HP: 138, AC: 15, Attack: 13, AttackBonus: 9, Speed: 16, BlockRate: 0.1, XPValue: 3900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The giant makes two greataxe attacks."},
"hezrou": {ID: "hezrou", Name: "Hezrou", CR: 8, HP: 136, AC: 16, Attack: 13, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 3900, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The hezrou makes three attacks: one with its bite and two with its claws. Traits: Magic Resistance, Stench"},
"hydra": {ID: "hydra", Name: "Hydra", CR: 8, HP: 172, AC: 15, Attack: 13, AttackBonus: 8, Speed: 12, BlockRate: 0.1, XPValue: 3900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The hydra makes as many bite attacks as it has heads. Traits: Hold Breath, Multiple Heads, Reactive Heads, Wakeful"},
"spirit_naga": {ID: "spirit_naga", Name: "Spirit Naga", CR: 8, HP: 75, AC: 15, Attack: 13, AttackBonus: 7, Speed: 16, BlockRate: 0.1, XPValue: 3900, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Rejuvenation, Spellcasting"},
"tyrannosaurus_rex": {ID: "tyrannosaurus_rex", Name: "Tyrannosaurus Rex", CR: 8, HP: 136, AC: 13, Attack: 13, AttackBonus: 10, Speed: 18, BlockRate: 0.05, XPValue: 3900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The tyrannosaurus makes two attacks: one with its bite and one with its tail."},
"young_bronze_dragon": {ID: "young_bronze_dragon", Name: "Young Bronze Dragon", CR: 8, HP: 142, AC: 18, Attack: 13, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 3900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws. Traits: Amphibious"},
"young_green_dragon": {ID: "young_green_dragon", Name: "Young Green Dragon", CR: 8, HP: 136, AC: 18, Attack: 13, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 3900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws. Traits: Amphibious"},
"bone_devil": {ID: "bone_devil", Name: "Bone Devil", CR: 9, HP: 142, AC: 19, Attack: 14, AttackBonus: 8, Speed: 16, BlockRate: 0.15, XPValue: 5000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The devil makes three attacks: two with its claws and one with its sting. Traits: Devil's Sight, Magic Resistance"},
"clay_golem": {ID: "clay_golem", Name: "Clay Golem", CR: 9, HP: 133, AC: 14, Attack: 14, AttackBonus: 8, Speed: 8, BlockRate: 0.05, XPValue: 5000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The golem makes two slam attacks. Traits: Acid Absorption, Berserk, Immutable Form, Magic Resistance, Magic Weapons"},
"cloud_giant": {ID: "cloud_giant", Name: "Cloud Giant", CR: 9, HP: 200, AC: 14, Attack: 14, AttackBonus: 12, Speed: 16, BlockRate: 0.05, XPValue: 5000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The giant makes two morningstar attacks. Traits: Keen Smell, Innate Spellcasting"},
"fire_giant": {ID: "fire_giant", Name: "Fire Giant", CR: 9, HP: 162, AC: 18, Attack: 14, AttackBonus: 11, Speed: 12, BlockRate: 0.15, XPValue: 5000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The giant makes two greatsword attacks."},
"glabrezu": {ID: "glabrezu", Name: "Glabrezu", CR: 9, HP: 157, AC: 17, Attack: 14, AttackBonus: 9, Speed: 16, BlockRate: 0.1, XPValue: 5000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The glabrezu makes four attacks: two with its pincers and two with its fists. Traits: Innate Spellcasting, Magic Resistance"},
"treant": {ID: "treant", Name: "Treant", CR: 9, HP: 138, AC: 16, Attack: 14, AttackBonus: 10, Speed: 12, BlockRate: 0.1, XPValue: 5000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The treant makes two slam attacks. Traits: False Appearance, Siege Monster"},
"young_blue_dragon": {ID: "young_blue_dragon", Name: "Young Blue Dragon", CR: 9, HP: 152, AC: 18, Attack: 14, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 5000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws."},
"young_silver_dragon": {ID: "young_silver_dragon", Name: "Young Silver Dragon", CR: 9, HP: 168, AC: 18, Attack: 14, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 5000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws."},
"aboleth": {ID: "aboleth", Name: "Aboleth", CR: 10, HP: 135, AC: 17, Attack: 16, AttackBonus: 9, Speed: 6, BlockRate: 0.1, XPValue: 5900, Ability: &MonsterAbility{Name: "Damaging Aura", Phase: "any", ProcChance: 0.4, Effect: "retaliate"}, Notes: "SRD-tuned baseline — Damaging Aura (retaliate) wired from traits. Multiattack: The aboleth makes three tentacle attacks. Traits: Amphibious, Mucous Cloud, Probing Telepathy"},
"deva": {ID: "deva", Name: "Deva", CR: 10, HP: 136, AC: 17, Attack: 16, AttackBonus: 8, Speed: 12, BlockRate: 0.1, XPValue: 5900, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The deva makes two melee attacks. Traits: Angelic Weapons, Innate Spellcasting, Magic Resistance"},
"guardian_naga": {ID: "guardian_naga", Name: "Guardian Naga", CR: 10, HP: 127, AC: 18, Attack: 16, AttackBonus: 8, Speed: 16, BlockRate: 0.15, XPValue: 5900, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Rejuvenation, Spellcasting"},
"stone_golem": {ID: "stone_golem", Name: "Stone Golem", CR: 10, HP: 178, AC: 17, Attack: 16, AttackBonus: 10, Speed: 12, BlockRate: 0.1, XPValue: 5900, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The golem makes two slam attacks. Traits: Immutable Form, Magic Resistance, Magic Weapons"},
"young_gold_dragon": {ID: "young_gold_dragon", Name: "Young Gold Dragon", CR: 10, HP: 178, AC: 18, Attack: 16, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 5900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws. Traits: Amphibious", FireAttacker: true},
"young_red_dragon": {ID: "young_red_dragon", Name: "Young Red Dragon", CR: 10, HP: 178, AC: 18, Attack: 16, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 5900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws.", FireAttacker: true},
"behir": {ID: "behir", Name: "Behir", CR: 11, HP: 168, AC: 17, Attack: 18, AttackBonus: 0, Speed: 18, BlockRate: 0.1, XPValue: 7200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The behir makes two attacks: one with its bite and one to constrict."},
"chain_devil": {ID: "chain_devil", Name: "Chain Devil", CR: 11, HP: 85, AC: 16, Attack: 18, AttackBonus: 8, Speed: 12, BlockRate: 0.1, XPValue: 7200, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The devil makes two attacks with its chains. Traits: Devil's Sight, Magic Resistance"},
"djinni": {ID: "djinni", Name: "Djinni", CR: 11, HP: 161, AC: 17, Attack: 18, AttackBonus: 9, Speed: 12, BlockRate: 0.1, XPValue: 7200, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Multiattack: The djinni makes three scimitar attacks. Traits: Elemental Demise, Innate Spellcasting, Variant: Genie Powers"},
"efreeti": {ID: "efreeti", Name: "Efreeti", CR: 11, HP: 200, AC: 17, Attack: 18, AttackBonus: 7, Speed: 16, BlockRate: 0.1, XPValue: 7200, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Multiattack: The efreeti makes two scimitar attacks or uses its Hurl Flame twice. Traits: Elemental Demise, Innate Spellcasting, Variant: Genie Powers", FireAttacker: true},
"gynosphinx": {ID: "gynosphinx", Name: "Gynosphinx", CR: 11, HP: 136, AC: 17, Attack: 18, AttackBonus: 9, Speed: 16, BlockRate: 0.1, XPValue: 7200, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The sphinx makes two claw attacks. Traits: Inscrutable, Magic Weapons, Spellcasting"},
"horned_devil": {ID: "horned_devil", Name: "Horned Devil", CR: 11, HP: 178, AC: 18, Attack: 18, AttackBonus: 10, Speed: 8, BlockRate: 0.15, XPValue: 7200, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The devil makes three melee attacks: two with its fork and one with its tail. Traits: Devil's Sight, Magic Resistance"},
"remorhaz": {ID: "remorhaz", Name: "Remorhaz", CR: 11, HP: 195, AC: 17, Attack: 18, AttackBonus: 11, Speed: 12, BlockRate: 0.1, XPValue: 7200, Ability: &MonsterAbility{Name: "Damaging Aura", Phase: "any", ProcChance: 0.4, Effect: "retaliate"}, Notes: "SRD-tuned baseline — Damaging Aura (retaliate) wired from traits. Traits: Heated Body"},
"roc": {ID: "roc", Name: "Roc", CR: 11, HP: 248, AC: 15, Attack: 18, AttackBonus: 13, Speed: 8, BlockRate: 0.1, XPValue: 7200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The roc makes two attacks: one with its beak and one with its talons. Traits: Keen Sight"},
"archmage": {ID: "archmage", Name: "Archmage", CR: 12, HP: 99, AC: 12, Attack: 19, AttackBonus: 6, Speed: 12, BlockRate: 0, XPValue: 8400, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Magic Resistance, Spellcasting"},
"erinyes": {ID: "erinyes", Name: "Erinyes", CR: 12, HP: 153, AC: 18, Attack: 19, AttackBonus: 8, Speed: 12, BlockRate: 0.15, XPValue: 8400, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The erinyes makes three attacks Traits: Hellish Weapons, Magic Resistance"},
"adult_brass_dragon": {ID: "adult_brass_dragon", Name: "Adult Brass Dragon", CR: 13, HP: 172, AC: 18, Attack: 21, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 10000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)", FireAttacker: true},
"adult_white_dragon": {ID: "adult_white_dragon", Name: "Adult White Dragon", CR: 13, HP: 200, AC: 18, Attack: 21, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 10000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Ice Walk, Legendary Resistance (3/Day)"},
"nalfeshnee": {ID: "nalfeshnee", Name: "Nalfeshnee", CR: 13, HP: 184, AC: 18, Attack: 21, AttackBonus: 10, Speed: 8, BlockRate: 0.15, XPValue: 10000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The nalfeshnee uses Horror Nimbus if it can. Traits: Magic Resistance"},
"rakshasa": {ID: "rakshasa", Name: "Rakshasa", CR: 13, HP: 110, AC: 16, Attack: 21, AttackBonus: 7, Speed: 16, BlockRate: 0.1, XPValue: 10000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The rakshasa makes two claw attacks Traits: Limited Magic Immunity, Innate Spellcasting"},
"storm_giant": {ID: "storm_giant", Name: "Storm Giant", CR: 13, HP: 230, AC: 16, Attack: 21, AttackBonus: 0, Speed: 18, BlockRate: 0.1, XPValue: 10000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The giant makes two greatsword attacks. Traits: Amphibious, Innate Spellcasting"},
"vampire": {ID: "vampire", Name: "Vampire", CR: 13, HP: 144, AC: 16, Attack: 21, AttackBonus: 9, Speed: 12, BlockRate: 0.1, XPValue: 10000, Ability: &MonsterAbility{Name: "Regeneration", Phase: "any", ProcChance: 0.5, Effect: "regenerate"}, Notes: "SRD-tuned baseline — Regeneration (regenerate) wired from traits. Traits: Shapechanger, Legendary Resistance (3/Day), Misty Escape, Regeneration, Spider Climb, Vampire Weaknesses"},
"adult_black_dragon": {ID: "adult_black_dragon", Name: "Adult Black Dragon", CR: 14, HP: 195, AC: 19, Attack: 23, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 11500, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)"},
"adult_copper_dragon": {ID: "adult_copper_dragon", Name: "Adult Copper Dragon", CR: 14, HP: 184, AC: 18, Attack: 23, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 11500, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)"},
"ice_devil": {ID: "ice_devil", Name: "Ice Devil", CR: 14, HP: 180, AC: 18, Attack: 23, AttackBonus: 10, Speed: 16, BlockRate: 0.15, XPValue: 11500, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The devil makes three attacks: one with its bite, one with its claws, and one with its tail. Traits: Devil's Sight, Magic Resistance"},
"adult_bronze_dragon": {ID: "adult_bronze_dragon", Name: "Adult Bronze Dragon", CR: 15, HP: 212, AC: 19, Attack: 24, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 13000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)"},
"adult_green_dragon": {ID: "adult_green_dragon", Name: "Adult Green Dragon", CR: 15, HP: 207, AC: 19, Attack: 24, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 13000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)"},
"mummy_lord": {ID: "mummy_lord", Name: "Mummy Lord", CR: 15, HP: 97, AC: 17, Attack: 24, AttackBonus: 9, Speed: 8, BlockRate: 0.1, XPValue: 13000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The mummy can use its Dreadful Glare and makes one attack with its rotting fist. Traits: Magic Resistance, Rejuvenation, Spellcasting"},
"purple_worm": {ID: "purple_worm", Name: "Purple Worm", CR: 15, HP: 247, AC: 18, Attack: 24, AttackBonus: 9, Speed: 18, BlockRate: 0.15, XPValue: 13000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The worm makes two attacks: one with its bite and one with its stinger. Traits: Tunneler"},
"adult_blue_dragon": {ID: "adult_blue_dragon", Name: "Adult Blue Dragon", CR: 16, HP: 225, AC: 19, Attack: 26, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 15000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)"},
"adult_silver_dragon": {ID: "adult_silver_dragon", Name: "Adult Silver Dragon", CR: 16, HP: 243, AC: 19, Attack: 26, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 15000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)"},
"iron_golem": {ID: "iron_golem", Name: "Iron Golem", CR: 16, HP: 210, AC: 20, Attack: 26, AttackBonus: 0, Speed: 12, BlockRate: 0.15, XPValue: 15000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The golem makes two melee attacks. Traits: Fire Absorption, Immutable Form, Magic Resistance, Magic Weapons"},
"marilith": {ID: "marilith", Name: "Marilith", CR: 16, HP: 189, AC: 18, Attack: 26, AttackBonus: 9, Speed: 16, BlockRate: 0.15, XPValue: 15000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The marilith can make seven attacks: six with its longswords and one with its tail. Traits: Magic Resistance, Magic Weapons, Reactive"},
"planetar": {ID: "planetar", Name: "Planetar", CR: 16, HP: 200, AC: 19, Attack: 26, AttackBonus: 12, Speed: 16, BlockRate: 0.15, XPValue: 15000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The planetar makes two melee attacks. Traits: Angelic Weapons, Divine Awareness, Innate Spellcasting, Magic Resistance"},
"adult_gold_dragon": {ID: "adult_gold_dragon", Name: "Adult Gold Dragon", CR: 17, HP: 256, AC: 19, Attack: 28, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 18000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)", FireAttacker: true},
"adult_red_dragon": {ID: "adult_red_dragon", Name: "Adult Red Dragon", CR: 17, HP: 256, AC: 19, Attack: 28, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 18000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)", FireAttacker: true},
"androsphinx": {ID: "androsphinx", Name: "Androsphinx", CR: 17, HP: 199, AC: 17, Attack: 28, AttackBonus: 12, Speed: 16, BlockRate: 0.1, XPValue: 18000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The sphinx makes two claw attacks. Traits: Inscrutable, Magic Weapons, Spellcasting"},
"dragon_turtle": {ID: "dragon_turtle", Name: "Dragon Turtle", CR: 17, HP: 341, AC: 20, Attack: 28, AttackBonus: 0, Speed: 8, BlockRate: 0.15, XPValue: 18000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon turtle makes three attacks: one with its bite and two with its claws. Traits: Amphibious", FireAttacker: true},
"balor": {ID: "balor", Name: "Balor", CR: 19, HP: 262, AC: 19, Attack: 31, AttackBonus: 14, Speed: 16, BlockRate: 0.15, XPValue: 22000, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Multiattack: The balor makes two attacks: one with its longsword and one with its whip. Traits: Death Throes, Fire Aura, Magic Resistance, Magic Weapons"},
"ancient_brass_dragon": {ID: "ancient_brass_dragon", Name: "Ancient Brass Dragon", CR: 20, HP: 297, AC: 20, Attack: 33, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 25000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)", FireAttacker: true},
"ancient_white_dragon": {ID: "ancient_white_dragon", Name: "Ancient White Dragon", CR: 20, HP: 333, AC: 20, Attack: 33, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 25000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Ice Walk, Legendary Resistance (3/Day)"},
"pit_fiend": {ID: "pit_fiend", Name: "Pit Fiend", CR: 20, HP: 300, AC: 19, Attack: 33, AttackBonus: 14, Speed: 12, BlockRate: 0.15, XPValue: 25000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The pit fiend makes four attacks: one with its bite, one with its claw, one with its mace, and one with its tail. Traits: Fear Aura, Magic Resistance, Magic Weapons, Innate Spellcasting"},
"ancient_black_dragon": {ID: "ancient_black_dragon", Name: "Ancient Black Dragon", CR: 21, HP: 367, AC: 22, Attack: 34, AttackBonus: 15, Speed: 16, BlockRate: 0.15, XPValue: 33000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)"},
"ancient_copper_dragon": {ID: "ancient_copper_dragon", Name: "Ancient Copper Dragon", CR: 21, HP: 350, AC: 21, Attack: 34, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 33000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)"},
"lich": {ID: "lich", Name: "Lich", CR: 21, HP: 135, AC: 17, Attack: 34, AttackBonus: 12, Speed: 12, BlockRate: 0.1, XPValue: 33000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Legendary Resistance (3/Day), Rejuvenation, Spellcasting, Turn Resistance"},
"solar": {ID: "solar", Name: "Solar", CR: 21, HP: 243, AC: 21, Attack: 34, AttackBonus: 15, Speed: 18, BlockRate: 0.15, XPValue: 33000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The solar makes two greatsword attacks. Traits: Angelic Weapons, Divine Awareness, Innate Spellcasting, Magic Resistance"},
"ancient_bronze_dragon": {ID: "ancient_bronze_dragon", Name: "Ancient Bronze Dragon", CR: 22, HP: 444, AC: 22, Attack: 36, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 41000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)"},
"ancient_green_dragon": {ID: "ancient_green_dragon", Name: "Ancient Green Dragon", CR: 22, HP: 385, AC: 21, Attack: 36, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 41000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)"},
"ancient_blue_dragon": {ID: "ancient_blue_dragon", Name: "Ancient Blue Dragon", CR: 23, HP: 481, AC: 22, Attack: 37, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 50000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)"},
"ancient_silver_dragon": {ID: "ancient_silver_dragon", Name: "Ancient Silver Dragon", CR: 23, HP: 487, AC: 22, Attack: 37, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 50000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)"},
"kraken": {ID: "kraken", Name: "Kraken", CR: 23, HP: 472, AC: 18, Attack: 37, AttackBonus: 7, Speed: 8, BlockRate: 0.15, XPValue: 50000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The kraken makes three tentacle attacks, each of which it can replace with one use of Fling. Traits: Amphibious, Freedom of Movement, Siege Monster"},
"ancient_gold_dragon": {ID: "ancient_gold_dragon", Name: "Ancient Gold Dragon", CR: 24, HP: 546, AC: 22, Attack: 38, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 62000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)", FireAttacker: true},
"ancient_red_dragon": {ID: "ancient_red_dragon", Name: "Ancient Red Dragon", CR: 24, HP: 546, AC: 22, Attack: 38, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 62000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)", FireAttacker: true},
"tarrasque": {ID: "tarrasque", Name: "Tarrasque", CR: 30, HP: 676, AC: 25, Attack: 46, AttackBonus: 19, Speed: 16, BlockRate: 0.15, XPValue: 155000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The tarrasque can use its Frightful Presence. Traits: Legendary Resistance (3/Day), Magic Resistance, Reflective Carapace, Siege Monster"},
}
}

View File

@@ -141,11 +141,11 @@ type bjTable struct {
// ---------------------------------------------------------------------------
type bjConfig struct {
TimeoutSeconds int
AutoplayThreshold int
MinBet float64
MaxBet float64
DebtLimit float64
TimeoutSeconds int
AutoplayThreshold int
MinBet float64
MaxBet float64
DebtLimit float64
}
func loadBJConfig() bjConfig {
@@ -877,5 +877,3 @@ func (p *BlackjackPlugin) recordBJScore(userID id.UserID, net float64) {
recordBotDefeat(userID, "blackjack")
}
}

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
package plugin
import (
"log/slog"
"gogobee/internal/db"
)
// bootstrapRoomsTraversed backfills dnd_zone_run.rooms_traversed for rows
// that predate the revisit R1 column. Before R1 the step counter was implicit
// — len(visited_nodes) — because navigation was forward-only, so replaying
// that identity is an exact reconstruction, not an estimate.
//
// The ALTER lands DEFAULT 0, which would tell an in-flight run it has walked
// nowhere: ambient narration cadence would reset to the entry-room line and
// R2's revisit preflight would read a fresh run. Hence the backfill.
//
// Idempotent, and safe to run against live rows: every row written after R1
// inserts rooms_traversed >= 1 (the entry node counts as one traversal), so
// `rooms_traversed = 0` uniquely identifies a row this has not yet touched.
// Kept in-tree permanently — a fresh deploy restoring an old backup needs it
// (feedback_loader_rewire_needs_bootstrap).
func bootstrapRoomsTraversed() {
res, err := db.Get().Exec(`
UPDATE dnd_zone_run
SET rooms_traversed = MAX(json_array_length(visited_nodes), 1)
WHERE rooms_traversed = 0`)
if err != nil {
slog.Error("bootstrap: rooms_traversed backfill failed", "err", err)
return
}
if n, _ := res.RowsAffected(); n > 0 {
slog.Warn("bootstrap: rooms_traversed backfilled from visited_nodes", "rows", n)
}
}

View File

@@ -592,4 +592,3 @@ func (p *AdventurePlugin) resolveDungeonAction(
return result
}

View File

@@ -27,34 +27,53 @@ 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 {
// An empty body means the caller already answered the player another way —
// a party fan-out, say. Sending it would post a blank DM.
if ctx.Silent || text == "" {
return nil
}
return p.SendDM(ctx.Sender, text)
}
// ── !fight ──────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
// Resolve the roster before locking — a member's `!fight` opens the leader's
// fight, under the leader's lock, and seat 0 names that leader. fightRoster
// deliberately does not touch getActiveZoneRun: that lookup carries the §4.3
// idle reap, and it must only ever fire under the lock, on its owner's behalf.
roster := fightRoster(ctx.Sender)
release := p.lockCombatFight(roster[0], ctx.Sender)
defer release()
run, err := getActiveZoneRun(ctx.Sender)
run, _, err := activeZoneRunFor(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,36 +86,33 @@ 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())
}
playerHP, playerMax := dndHPSnapshot(ctx.Sender)
if playerHP <= 0 {
return p.SendDM(ctx.Sender, "You're in no shape to fight. `!rest` first.")
// Seat the whole party, leader first. A solo player is a one-seat roster and
// takes the path they always took: one build, one INSERT, no participant rows.
seats, enemy, senderSkip, refusal := p.buildFightSeats(ctx.Sender, roster, monster, int(zone.Tier), run.DMMood)
if refusal != "" {
return p.replyDM(ctx, refusal)
}
enemyHP := enemy.Stats.MaxHP
sess, err := startCombatSession(ctx.Sender, run.RunID, encID, monster.ID, playerHP, playerMax, enemyHP, enemyHP)
// Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded
// per seat onto the session and its participant rows, so they survive the
// turn engine's resume/commit cycle. The pet rolls per-turn inside the
// engine, so there's no fight-start roll.
sess, err := p.startPartyCombatSession(run.RunID, encID, monster.ID, enemy, seats)
if err != nil {
if err == ErrCombatSessionAlreadyActive {
return p.SendDM(ctx.Sender, "You're already in a fight. Finish it with `!attack` / `!flee`.")
}
return p.SendDM(ctx.Sender, "Couldn't start the fight: "+err.Error())
}
// Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto
// the session so they survive the turn engine's resume/commit cycle. The
// pet now rolls per-turn inside the engine, so there's no fight-start roll.
if seedCombatSessionOneShots(sess, player.Mods) {
if err := saveCombatSession(sess); err != nil {
slog.Error("combat: seed session one-shots", "user", ctx.Sender, "err", err)
return p.replyDM(ctx, "You're already in a fight. Finish it with `!attack` / `!flee`.")
}
return p.replyDM(ctx, "Couldn't start the fight: "+err.Error())
}
var b strings.Builder
@@ -111,14 +127,52 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
}
b.WriteString(fmt.Sprintf("⚔️ **Elite — %s** (HP %d, AC %d)\n", monster.Name, enemyHP, enemy.Stats.AC))
}
b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", playerHP, playerMax))
if curios := activeMagicItemsLine(ctx.Sender); curios != "" {
if sess.IsParty() {
players := seatCombatants(seats)
// The enemy may have won initiative. Resolve everything the round owes
// before anyone is asked to act, so the opening block narrates the hit
// rather than showing its damage with no explanation.
opening, serr := settleCombatSession(sess, players, enemy)
if serr != nil {
return p.replyDM(ctx, "Couldn't resolve the round: "+serr.Error())
}
ct := &combatTurn{sess: sess, players: players, enemy: enemy, seat: 0, uid: roster[0]}
outcomes := p.closePartyRound(ct)
if !ctx.Silent {
// The opening block is per-reader for the same reason a round's
// narration is: "You: 40/40 HP" has to be the reader's own pool.
p.announcePartyFightStart(sess, players, enemy, b.String(), opening, outcomes)
}
// A member the roster left behind is owed an answer of their own: nothing
// above was addressed to them, because they are not seated.
if senderSkip != "" {
return p.replyDM(ctx, senderSkip)
}
return nil
}
// One seat. Usually that is a solo player fighting their own fight, and this
// is the block they have always been sent. It can also be a leader whose only
// companion was left behind — in which case the block is the leader's and the
// sender is owed the reason they are not in it.
owner := id.UserID(sess.UserID)
b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", sess.PlayerHP, sess.PlayerHPMax))
if curios := activeMagicItemsLine(owner); curios != "" {
b.WriteString(curios)
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(combatTurnPrompt(sess))
return p.SendDM(ctx.Sender, b.String())
if senderSkip != "" {
if !ctx.Silent {
if err := p.SendDM(owner, b.String()); err != nil {
slog.Error("combat: fight-start DM to leader failed", "user", owner, "err", err)
}
}
return p.replyDM(ctx, senderSkip)
}
return p.replyDM(ctx, b.String())
}
// ── !attack / !flee ─────────────────────────────────────────────────────────
@@ -131,29 +185,43 @@ func (p *AdventurePlugin) handleFleeCmd(ctx MessageContext) error {
return p.handleCombatActionCmd(ctx, PlayerAction{Kind: ActionFlee})
}
const noFightMsg = "You're not in a fight. `!fight` at an Elite or Boss room to start one."
func (p *AdventurePlugin) handleCombatActionCmd(ctx MessageContext, action PlayerAction) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
sess, err := getActiveCombatSession(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error())
ct, release, msg := p.beginCombatTurn(ctx.Sender, noFightMsg)
if ct == nil {
return p.replyDM(ctx, msg)
}
if sess == nil {
return p.SendDM(ctx.Sender, "You're not in a fight. `!fight` at an Elite or Boss room to start one.")
defer release()
// Fleeing ends the run for the whole party, so it is the leader's call —
// the same reasoning that makes `!extract` leader-only.
if action.Kind == ActionFlee && ct.isParty() && ct.seat != 0 {
return p.replyDM(ctx, "Only your party leader can break off a fight.")
}
player, enemy, err := p.combatantsForSession(sess)
events, err := p.driveCombatRound(ct, action)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
}
return p.replyCombatRound(ctx, ct, events)
}
events, err := runCombatRound(sess, &player, &enemy, action)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error())
// replyCombatRound narrates a resolved round. A solo fight answers the one
// player who typed, exactly as it always has. A party fight fans out: every
// member gets the play-by-play with the right names on it, and their own footer
// or close-out. Terminal side effects run either way — a silent autopilot round
// still owes the party its XP and loot.
func (p *AdventurePlugin) replyCombatRound(ctx MessageContext, ct *combatTurn, events []CombatEvent) error {
if !ct.isParty() {
return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, ct.sess, events, ct.players[0].Name, *ct.enemy))
}
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
outcomes := p.closePartyRound(ct)
if ctx.Silent {
return nil
}
p.announcePartyRound(ct, events, "", outcomes)
return nil
}
// renderRoundResult turns a resolved round into the player-facing block: the
@@ -174,24 +242,64 @@ func (p *AdventurePlugin) renderRoundResult(userID id.UserID, sess *CombatSessio
return b.String()
}
// runCombatRound resolves one full round: the player's chosen action, then the
// enemy turn and the round-end status tick, advancing the session until it is
// back at a player_turn or has reached a terminal status. Returns every event
// the round produced. Each advanceCombatSession call persists the session, so
// runCombatRound resolves one full round of a solo fight: the player's chosen
// action, then the enemy turn and the round-end status tick, advancing the
// session until it is back at a player_turn or has reached a terminal status.
// Returns every event the round produced. Each advance persists the session, so
// a crash mid-round resumes cleanly from the last phase.
func runCombatRound(sess *CombatSession, player, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
events, err := advanceCombatSession(sess, player, enemy, action)
return runPartyCombatRound(sess, []*Combatant{player}, enemy, action)
}
// partyRoundStepCap bounds the drain loop below. A round is at most one step per
// seat plus the enemy turn and the round-end tick, so a 3-player party settles
// in 5; the cap only turns a hypothetical non-advancing phase into a loud error
// instead of a hung goroutine holding the fight's lock.
const partyRoundStepCap = 64
// runPartyCombatRound resolves the acting seat's action and then drains every
// phase after it that needs no human: the enemy turn, the round-end status tick,
// and any seat that is down (which forfeits its turn silently). It comes to rest
// on a standing player's turn, or on a terminal status.
//
// A latched-onto-autopilot seat is *not* drained here — resolving its turn means
// running the picker, which needs the plugin to reach the character's spells and
// inventory. driveCombatRound layers that on top.
//
// For a solo roster this is exactly the old loop: a solo player_turn always
// belongs to a standing player, since a downed one has already ended the fight.
func runPartyCombatRound(sess *CombatSession, players []*Combatant, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
events, err := advancePartyCombatSession(sess, players, enemy, action)
if err != nil {
return events, err
}
for sess.IsActive() && sess.Phase != CombatPhasePlayerTurn {
more, merr := advanceCombatSession(sess, player, enemy, PlayerAction{})
more, err := settleCombatSession(sess, players, enemy)
return append(events, more...), err
}
// settleCombatSession drains every phase the engine can resolve without a human:
// the enemy turn, the round-end status tick, and any seat that is down. It comes
// to rest on a standing player's turn, or on a terminal status.
//
// It is a no-op on a session already parked on a standing player's turn, which
// is where every solo fight sits between commands.
func settleCombatSession(sess *CombatSession, players []*Combatant, enemy *Combatant) ([]CombatEvent, error) {
var events []CombatEvent
for i := 0; i < partyRoundStepCap; i++ {
if !sess.IsActive() {
return events, nil
}
if seat, waiting := actingSeat(sess, players, enemy); waiting && sess.seatAlive(seat) {
return events, nil
}
more, merr := advancePartyCombatSession(sess, players, enemy, PlayerAction{})
if merr != nil {
return events, merr
}
events = append(events, more...)
}
return events, nil
return events, fmt.Errorf("combat session %s: round did not settle within %d steps",
sess.SessionID, partyRoundStepCap)
}
// combatTurnPrompt is the "your move" footer shown after every non-terminal
@@ -208,10 +316,14 @@ func combatTurnPrompt(sess *CombatSession) string {
// wrong surface — point them at `!expedition run` instead. Standalone
// zone runs still advance with `!zone advance`.
func continueHint(userID id.UserID) string {
if exp, err := getActiveExpedition(userID); err == nil && exp != nil {
return "`!expedition run` to keep going."
exp, isLeader, err := activeExpeditionFor(userID)
switch {
case err != nil || exp == nil:
return "`!zone advance` to move on."
case !isLeader:
return "Your leader marches the party on."
}
return "`!zone advance` to move on."
return "`!expedition run` to keep going."
}
// finishCombatSession runs the post-fight side effects once a CombatSession
@@ -269,7 +381,7 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
}
b.WriteString(fmt.Sprintf("%s **%s** down. You finished at **%d/%d HP**.\n",
emoji, enemy.Name, sess.PlayerHP, sess.PlayerHPMax))
if drop := p.dropZoneLoot(userID, zone.ID, monster, !elite); drop != "" {
if drop := p.dropZoneLoot(userID, zone.ID, monster, !elite, elite); drop != "" {
b.WriteString(drop + "\n")
}
if bossOnExpedition {
@@ -387,65 +499,87 @@ func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefin
}
func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
// "not in a fight anymore" — this handler is only routed to when the caller
// already saw an active session, so a miss here means it closed under them.
ct, release, msg := p.beginCombatTurn(ctx.Sender, "You're not in a fight anymore.")
if ct == nil {
return p.replyDM(ctx, msg)
}
defer release()
sess, err := getActiveCombatSession(ctx.Sender)
action, settle, errMsg := p.castActionForSeat(ct, ct.seat, args)
if errMsg != "" {
return p.replyDM(ctx, errMsg)
}
events, err := p.driveCombatRound(ct, action)
settle(err == nil)
if err != nil {
return p.SendDM(ctx.Sender, "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, "Couldn't resolve the round: "+err.Error())
}
return p.replyCombatRound(ctx, ct, events)
}
advChar, _ := loadAdvCharacter(ctx.Sender)
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
// castActionForSeat resolves a `!cast` for one seat into a PlayerAction, having
// already spent the slot and any material component. It is shared by the command
// handler and by the autopilot that plays an absent member's turn, so both spend
// resources through exactly one code path.
//
// The returned settle(ok) must be called once the round has been attempted: on
// failure it refunds the slot. On refusal (non-empty msg) nothing was spent.
//
// A buff spell folds its delta into *this seat's* persisted statuses and rebuilds
// the roster so the buff is live for the round's enemy turn. Before P5 that
// delta went to the session's embedded copy — seat 0 — so a party member
// buffing themselves would have buffed the leader.
func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args string) (PlayerAction, func(bool), string) {
noop := func(bool) {}
uid := id.UserID(ct.sess.seatUserID(seat))
advChar, _ := loadAdvCharacter(uid)
c, err := p.ensureCharForDnDCmd(uid, advChar)
if err != nil || c == nil {
return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.")
return PlayerAction{}, noop, "Couldn't load your Adv 2.0 sheet."
}
if !isSpellcaster(c) {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class)))
return PlayerAction{}, noop, 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))
spell, slotLevel, errMsg := parseCombatCast(uid, c, strings.TrimSpace(args))
if errMsg != "" {
return p.SendDM(ctx.Sender, errMsg)
return PlayerAction{}, noop, errMsg
}
if spell.Effect == EffectReaction {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"%s is a reaction spell — those still aren't usable. Pick a damage, healing, or control spell.", spell.Name))
return PlayerAction{}, noop, 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())
refund := func(ok bool) {
if !ok && spell.Level > 0 {
_ = refundSpellSlot(uid, slotLevel)
}
}
var eff *turnActionEffect
if spell.Effect == EffectBuffSelf || spell.Effect == EffectBuffAlly {
// Buff path — resolve the buff against a throwaway combatant, fold the
// marginal effect into the session's persisted state, then rebuild the
// pair so the buff is live for this round's enemy turn.
// marginal effect into that seat's persisted state, then rebuild the
// roster so the buff is live for this round's enemy turn.
player := ct.players[seat]
as, am := player.Stats, player.Mods
applySpellBuff(spell, c, &as, &am, slotLevel)
d := diffTurnBuff(player.Stats, as, player.Mods, am)
if !d.any() {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"%s has no effect the turn-based engine can apply yet.", spell.Name))
return PlayerAction{}, noop, 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)
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
return PlayerAction{}, noop, msg
}
sess.Statuses.applyBuffDelta(d)
player, enemy, err = p.combatantsForSession(sess)
if err != nil {
if spell.Level > 0 {
_ = refundSpellSlot(ctx.Sender, slotLevel)
}
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
ct.sess.actorStatusesPtr(seat).applyBuffDelta(d)
if rerr := p.rebuildRoster(ct); rerr != nil {
refund(false)
return PlayerAction{}, noop, "Couldn't rebuild the fight: " + rerr.Error()
}
label := spell.Name + " — active"
if d.heal > 0 {
@@ -456,13 +590,13 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
PlayerHeal: d.heal, EnemySkip: d.enemySkip,
}
} else {
out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats)
out, supported := resolveTurnSpell(c, spell, slotLevel, &ct.enemy.Stats)
if !supported {
return p.SendDM(ctx.Sender, 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))
return PlayerAction{}, noop, 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)
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
return PlayerAction{}, noop, msg
}
eff = &turnActionEffect{
Label: out.Desc,
@@ -471,16 +605,27 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
PlayerHeal: out.PlayerHeal,
EnemySkip: out.EnemySkip,
}
}
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff})
if err != nil {
if spell.Level > 0 {
_ = refundSpellSlot(ctx.Sender, slotLevel)
// 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
}
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error())
}
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
return PlayerAction{Kind: ActionCast, Effect: eff}, refund, ""
}
// rebuildRoster re-derives the seated combatants after a mid-fight buff changed
// a seat's persisted statuses, so the buff is live for the rest of the round.
func (p *AdventurePlugin) rebuildRoster(ct *combatTurn) error {
players, enemy, err := p.partyCombatantsForSession(ct.sess)
if err != nil {
return err
}
ct.players, ct.enemy = players, enemy
return nil
}
// chargeSpellCost debits a spell's material component and leveled slot for a
@@ -545,45 +690,70 @@ func matchConsumable(inv []ConsumableItem, query string) (*ConsumableItem, strin
}
func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
const notFighting = "`!consume <item>` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically."
sess, err := getActiveCombatSession(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "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.")
}
inv := p.loadConsumableInventory(ctx.Sender)
args = strings.TrimSpace(args)
if args == "" {
// Listing the pack reads no turn state, so it neither takes the fight's
// lock nor settles a phase — a player peeking at their options between
// rounds must not advance the fight.
probe, err := activeCombatSessionFor(ctx.Sender)
if err != nil {
return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
}
if probe == nil {
return p.replyDM(ctx, notFighting)
}
inv := p.loadConsumableInventory(ctx.Sender)
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, ", ")+".")
}
ct, release, msg := p.beginCombatTurn(ctx.Sender, notFighting)
if ct == nil {
return p.replyDM(ctx, msg)
}
defer release()
action, settle, errMsg := p.consumeActionForSeat(ct, ct.seat, args)
if errMsg != "" {
return p.replyDM(ctx, errMsg)
}
events, err := p.driveCombatRound(ct, action)
settle(err == nil)
if err != nil {
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
}
return p.replyCombatRound(ctx, ct, events)
}
// consumeActionForSeat resolves a `!consume` for one seat into a PlayerAction.
// Shared by the command handler and by the autopilot that plays an absent
// member's turn.
//
// The returned settle(ok) burns the item only once the round has resolved: a
// removal failure leaves the player a free use (logged, rare), which beats
// charging them for a round that errored out.
func (p *AdventurePlugin) consumeActionForSeat(ct *combatTurn, seat int, args string) (PlayerAction, func(bool), string) {
noop := func(bool) {}
uid := id.UserID(ct.sess.seatUserID(seat))
inv := p.loadConsumableInventory(uid)
item, ambig := matchConsumable(inv, args)
if ambig != "" {
return p.SendDM(ctx.Sender, ambig)
return PlayerAction{}, noop, ambig
}
if item == nil {
return p.SendDM(ctx.Sender, fmt.Sprintf("No consumable matching %q in your inventory.", args))
return PlayerAction{}, noop, 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())
}
eff := &turnActionEffect{Action: "use_consumable"}
switch def.Effect {
case EffectHeal:
@@ -594,34 +764,32 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
eff.Label = fmt.Sprintf("%s — %d damage", def.Name, eff.EnemyDamage)
default:
// Buff-type consumable — resolve the marginal effect against a
// throwaway combatant, fold it into the session's persisted state, and
// rebuild the pair so the buff is live for this round's enemy turn.
// throwaway combatant, fold it into that seat's persisted state, and
// rebuild the roster so the buff is live for this round's enemy turn.
player := ct.players[seat]
as, am := player.Stats, player.Mods
ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}})
d := diffTurnBuff(player.Stats, as, player.Mods, am)
if !d.any() {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"**%s** has no effect the turn-based engine can apply yet.", def.Name))
return PlayerAction{}, noop, 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())
ct.sess.actorStatusesPtr(seat).applyBuffDelta(d)
if rerr := p.rebuildRoster(ct); rerr != nil {
return PlayerAction{}, noop, "Couldn't rebuild the fight: " + rerr.Error()
}
eff.Label = def.Name + " — active"
eff.PlayerHeal = d.heal
eff.EnemySkip = d.enemySkip
}
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())
burn := func(ok bool) {
if !ok {
return
}
if rerr := removeAdvInventoryItem(item.InventoryID); rerr != nil {
slog.Error("combat: consume remove inventory item failed", "user", uid, "item", def.Name, "err", rerr)
}
}
// Round resolved and persisted — now burn the item. A removal failure here
// leaves the player a free use (logged, rare); better than charging them
// for a round that errored out.
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 PlayerAction{Kind: ActionConsume, Effect: eff}, burn, ""
}

View File

@@ -46,13 +46,13 @@ type CombatStats struct {
}
type CombatModifiers struct {
DamageBonus float64 // additive: 0 = neutral, 0.25 = +25% damage. Applied as (1 + DamageBonus).
DamageReduct float64 // multiplicative damage-taken reduction, 1.0 = neutral
DeathSave bool // Sovereign reprieve — survive one lethal hit
PetAttackProc float64
PetAttackDmg int
PetDeflectProc float64
PetWhiffProc float64 // pet distracts enemy → guaranteed miss
DamageBonus float64 // additive: 0 = neutral, 0.25 = +25% damage. Applied as (1 + DamageBonus).
DamageReduct float64 // multiplicative damage-taken reduction, 1.0 = neutral
DeathSave bool // Sovereign reprieve — survive one lethal hit
PetAttackProc float64
PetAttackDmg int
PetDeflectProc float64
PetWhiffProc float64 // pet distracts enemy → guaranteed miss
// Spiritual Weapon — separate channel from the pet so the spectral mace
// gets its own narration when a cleric without a companion casts it.
// Damage formula mirrors PetAttack (Dmg + d5), proc rolls per round.
@@ -211,6 +211,15 @@ type CombatEvent struct {
// Roll is the raw d20 (1..20); RollAgainst is the target AC.
Roll int
RollAgainst int
// Seat is the roster index of the character this event is about: the one
// who swung, whose pet struck, who the enemy hit, whose poison ticked. The
// turn engine stamps it (turnEngine.stampSeat); the auto-resolve engine
// leaves it 0, which is correct for its one and only combatant.
//
// It exists so a party's play-by-play can name the right person. Solo events
// are all seat 0, and the omitempty tag keeps the field out of every solo
// turn_log_json — rows written before N3/P5 decode unchanged.
Seat int `json:"Seat,omitempty"`
}
type CombatResult struct {
@@ -292,10 +301,28 @@ var bossCombatPhases = []CombatPhase{
// ── Simulation ───────────────────────────────────────────────────────────────
// combatState tracks mutable state during the simulation.
type combatState struct {
// actor is the per-combatant half of the simulation state — everything that
// belongs to one player character rather than to the fight as a whole.
//
// It is embedded into combatState as a *pointer*, which promotes its fields:
// `st.playerHP` still resolves, now to `st.actor.playerHP`. The embedded
// pointer is a cursor naming whoever is currently resolving. Solo combat has a
// one-element roster and never moves the cursor, so every read and every RNG
// draw is bit-identical to the pre-roster engine — which is what keeps the
// solo balance corpus (sim_results/d8prereq_corpus.jsonl lineage) valid.
//
// Field names are deliberately unchanged from when they lived on combatState.
type actor struct {
// c is the Combatant this state belongs to. Nil only in tests that drive
// a primitive directly without a roster.
c *Combatant
playerHP int
enemyHP int
// hpMax is this actor's HP ceiling for in-fight healing. It tracks
// c.Stats.MaxHP except in the turn-based engine, where seat 0 restores the
// session's persisted player_hp_max — that snapshot comes from
// dndHPSnapshot and can differ from the rebuilt combatant's MaxHP.
hpMax int
// Consumable one-shots
healChargesLeft int // remaining heal-at-<50% triggers
@@ -304,13 +331,10 @@ type combatState struct {
reflectFrac float64
autoCrit bool
// Monster ability effects
poisonTicks int
poisonDmg int
stunPlayer bool
enraged bool
armorBroken bool
armorBreakAmt float64
// Monster ability effects landing on this actor
poisonTicks int
poisonDmg int
stunPlayer bool
// Sovereign reprieve
deathSaveUsed bool
@@ -320,10 +344,6 @@ type combatState struct {
raged bool // Orc Rage already triggered this fight
pendingRageAttack bool // next player attack gets +50% damage
// Phase 9 spell — enemy skip-first-attack (consumed on the first round
// the enemy would otherwise attack).
enemySkipFirst bool
// Phase 10 SUB2a-ii first-attack one-shots.
firstAttackBonusUsed bool
assassinateRerollUsed bool
@@ -332,19 +352,56 @@ type combatState struct {
// Phase 10 SUB2b — Abjuration Arcane Ward HP buffer.
arcaneWardHP int
// Debuffs the enemy has stacked onto this actor specifically.
playerAtkDrain int // stat_drain: flat reduction to this actor's hit damage
playerACDebuff int // debuff: flat reduction to this actor's effective AC
maxHPDrain int // max_hp_drain: reduction to this actor's effective MaxHP
// concentrationDmg — per-round damage of an active concentration AOE
// (Spirit Guardians et al.). Concentration is per-caster, so it lives
// here rather than on the fight.
concentrationDmg int
}
// combatState tracks mutable state during the simulation. The embedded *actor
// is the cursor: the player character currently resolving. Everything declared
// directly on combatState is shared by the whole fight — the enemy, the round
// counter, the event log, and the RNG stream.
type combatState struct {
*actor // cursor into actors; promotes the per-actor fields
actors []*actor // the player roster, in seating order. len == 1 for solo.
// seatIdx is the roster index the cursor points at. Kept in step with the
// embedded *actor by seat(); read only by the turn engine, to attribute the
// events a phase emitted to the character they happened to.
seatIdx int
enemyHP int
// Monster ability effects (enemy-side stance — shared across the roster)
enraged bool
armorBroken bool
armorBreakAmt float64
// Phase 9 spell — enemy skip-first-attack (consumed on the first round
// the enemy would otherwise attack). Holding the enemy holds it for
// everyone, so this is fight-scoped, not per-caster.
enemySkipFirst bool
// 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
// them through CombatStatuses so they survive a suspend/resume.
//
// These describe the *enemy's* stance, so they are fight-scoped: an enemy
// holding a parry stance parries the next swing from anyone. The debuffs
// it stacks onto a specific character (stat_drain / debuff / max_hp_drain)
// live on actor instead.
enemyEvadeNext bool // evade: next player weapon attack auto-misses
enemyBlockUp bool // block: enemy holds a parry stance (~50% block on player hits)
enemyAdvantage bool // advantage: enemy rolls its attacks with advantage
enemyRetaliateFrac float64 // retaliate: fraction of a player hit reflected back
enemyRegen int // regenerate: enemy heals this much each round end
enemySurviveArmed bool // survive_at_1: enemy cheats death once, dropping to 1 HP
playerAtkDrain int // stat_drain: flat reduction to the player's hit damage
playerACDebuff int // debuff: flat reduction to the player's effective AC
maxHPDrain int // max_hp_drain: reduction to the player's effective MaxHP
// Phase 13 bestiary slice 4 — the former flavor-only placeholders, now
// backed by real state.
@@ -369,6 +426,50 @@ type combatState struct {
// Auto-resolve leaves st.rng nil — behaviorally identical to the
// pre-injection code; the turn-based engine and the timeout reaper seed
// it per session so a fight can be resumed and replayed reproducibly.
// newActor seats one player character, deriving its opening per-fight state
// from that character's modifiers.
func newActor(c *Combatant) *actor {
startHP := c.Stats.MaxHP
if c.Stats.StartHP > 0 && c.Stats.StartHP < c.Stats.MaxHP {
startHP = c.Stats.StartHP
}
a := &actor{
c: c,
playerHP: startHP,
hpMax: c.Stats.MaxHP,
wardCharges: c.Mods.WardCharges,
sporeRounds: c.Mods.SporeCloud,
reflectFrac: c.Mods.ReflectNext,
autoCrit: c.Mods.AutoCritFirst,
arcaneWardHP: c.Mods.ArcaneWardHP,
}
// HealItemCharges: explicit count overrides legacy one-shot. Backfill
// to 1 charge if the caller set a HealItem amount but no count.
a.healChargesLeft = c.Mods.HealItemCharges
if a.healChargesLeft == 0 && c.Mods.HealItem > 0 {
a.healChargesLeft = 1
}
return a
}
// seat points the cursor at roster index i. Every per-actor read in the
// resolution primitives (st.playerHP, st.wardCharges, …) follows the cursor.
// Solo combat seats index 0 once and never moves it.
// seat moves the cursor to a roster index. seatIdx trails it so the turn engine
// can tag the events a phase emits with the character they are about.
func (st *combatState) seat(i int) { st.actor, st.seatIdx = st.actors[i], i }
// anyAlive reports whether at least one seated character is still standing.
// Solo fights read this as "the player is alive".
func (st *combatState) anyAlive() bool {
for _, a := range st.actors {
if a.playerHP > 0 {
return true
}
}
return false
}
func (st *combatState) roll(n int) int { return rngIntN(st.rng, n) }
func (st *combatState) randFloat() float64 { return rngFloat(st.rng) }
@@ -381,338 +482,12 @@ func SimulateCombat(player, enemy Combatant, phases []CombatPhase) CombatResult
// The characterization test and the turn-based engine pass a seeded *rand.Rand
// so a fight is fully reproducible. Passing nil is behaviorally identical to
// the pre-injection code.
//
// This is the one-seat case of the N-body engine in combat_engine_party.go.
// Every roster short-circuit in there collapses for a single player, so the
// draw order — and therefore TestCombatCharacterization — is unchanged.
func simulateCombatWithRNG(player, enemy Combatant, phases []CombatPhase, rng *rand.Rand) CombatResult {
playerStart := player.Stats.MaxHP
if player.Stats.StartHP > 0 && player.Stats.StartHP < player.Stats.MaxHP {
playerStart = player.Stats.StartHP
}
enemyStart := enemy.Stats.MaxHP
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP {
enemyStart = enemy.Stats.StartHP
}
st := &combatState{
playerHP: playerStart,
enemyHP: enemyStart,
wardCharges: player.Mods.WardCharges,
sporeRounds: player.Mods.SporeCloud,
reflectFrac: player.Mods.ReflectNext,
autoCrit: player.Mods.AutoCritFirst,
enemySkipFirst: player.Mods.SpellEnemySkipFirst,
arcaneWardHP: player.Mods.ArcaneWardHP,
rng: rng,
}
// HealItemCharges: explicit count overrides legacy one-shot. Backfill
// to 1 charge if the caller set a HealItem amount but no count.
st.healChargesLeft = player.Mods.HealItemCharges
if st.healChargesLeft == 0 && player.Mods.HealItem > 0 {
st.healChargesLeft = 1
}
result := CombatResult{
PlayerStartHP: player.Stats.MaxHP,
PlayerEntryHP: playerStart,
EnemyStartHP: enemy.Stats.MaxHP,
EnemyEntryHP: enemyStart,
}
// Pre-combat: Arina sniper check
if player.Mods.SniperKillProc > 0 && st.randFloat() < player.Mods.SniperKillProc {
st.enemyHP = 0
st.events = append(st.events, CombatEvent{
Round: 0, Phase: "pre_combat", Actor: "npc", Action: "sniper_kill",
Damage: enemy.Stats.MaxHP, PlayerHP: st.playerHP, EnemyHP: 0,
Desc: "Arina",
})
result.SniperKilled = true
return finalize(result, st, player, enemy)
}
// Pre-combat: Coal Bomb / flat start damage
if player.Mods.FlatDmgStart > 0 {
dmg := player.Mods.FlatDmgStart
st.enemyHP = max(0, st.enemyHP-dmg)
st.events = append(st.events, CombatEvent{
Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "flat_damage",
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if st.enemyHP <= 0 {
return finalize(result, st, player, enemy)
}
}
// Pre-combat: queued spell. Resolved by applyPendingCast() before this
// runs — the modifiers carry the resolved damage and narrative hook.
if player.Mods.SpellPreDamageDesc != "" {
dmg := player.Mods.SpellPreDamage
resisted := dmg > 0 && enemyResistsSpells(&enemy, st)
if resisted {
dmg = max(1, dmg/2)
}
if dmg > 0 {
st.enemyHP = max(0, st.enemyHP-dmg)
}
st.events = append(st.events, CombatEvent{
Round: 0, Phase: "pre_combat", Actor: "player", Action: "spell_cast",
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Desc: player.Mods.SpellPreDamageDesc,
})
if resisted {
st.events = append(st.events, CombatEvent{
Round: 0, Phase: "pre_combat", Actor: "enemy", Action: "spell_fizzle",
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
if st.enemyHP <= 0 {
return finalize(result, st, player, enemy)
}
}
// Pre-combat: Misty gourmet is now a per-round heal, no pre-combat damage
// Main simulation loop
for _, phase := range phases {
roundsThisPhase := phase.Rounds
// Add slight variance: ±1 round for non-Decisive phases
if phase.Name != "Decisive" && roundsThisPhase > 1 {
roundsThisPhase += st.roll(2) // 0 or +1
}
for r := 0; r < roundsThisPhase; r++ {
st.round++
if simulateRound(st, &player, &enemy, &phase, &result) {
return finalize(result, st, player, enemy)
}
}
}
// If we exhaust all phases without a kill, tiebreak by HP percentage
// to decide the *outcome* (PlayerWon flag) — but DO NOT zero out HP
// on the loser. Timeout = retreat, not lethal blow. Caller treats a
// timeout loss as "fight ended, no character death".
//
// Absolute-HP tiebreak (briefly used on the legacy ~120 HP scale) was
// wrong post HP-unification: monster pools (~30175) are 2-3× player
// pools (~1383), so absolute always favored the larger combatant
// even when the player took less proportional damage. Slight bias
// toward the player on exact ties (frac >=).
result.TimedOut = true
playerFrac := float64(st.playerHP) / float64(max(1, player.Stats.MaxHP))
enemyFrac := float64(st.enemyHP) / float64(max(1, enemy.Stats.MaxHP))
playerWonTiebreak := playerFrac >= enemyFrac
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: "exhaust", Actor: "system", Action: "timeout",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
out := finalize(result, st, player, enemy)
out.PlayerWon = playerWonTiebreak
return out
}
// simulateRound runs one round. Returns true if combat is over.
func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool {
phaseName := phase.Name
// Phase 9 spell: control effect (Hold Person, Sleep, Command) skips the
// enemy's attack for one round. Logged as a dedicated event so narrative
// can read it as a held/stunned beat rather than a generic miss.
enemyHeldThisRound := false
if st.enemySkipFirst {
st.enemySkipFirst = false
if enemyImmuneToControl(enemy, st) {
// fear_immune: the control spell can't take hold — the enemy acts
// as normal this round.
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "fear_resist",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
} else {
enemyHeldThisRound = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "spell_held",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
}
// Monster ability: check at round start
abilityDealtDamage := enemyHeldThisRound
if enemy.Ability != nil {
if abilityFires(enemy.Ability, phaseName, st) {
if applyAbility(st, player, enemy, phase, result) {
return true
}
// Cleave and lifesteal deal damage — skip normal enemy attack this round
switch enemy.Ability.Effect {
case "cleave", "lifesteal":
abilityDealtDamage = true
}
}
}
// Poison tick from previous round
if st.poisonTicks > 0 {
st.playerHP = max(0, st.playerHP-st.poisonDmg)
st.poisonTicks--
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "poison_tick",
Damage: st.poisonDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if st.playerHP <= 0 {
if trySave(st, player, phaseName) {
// survived
} else {
return true
}
}
}
// Pet whiff: if proc'd, enemy's attack this round is a guaranteed miss
petWhiff := player.Mods.PetWhiffProc > 0 && st.randFloat() < player.Mods.PetWhiffProc
// Pet deflect: halves incoming damage to player this round
petDeflect := player.Mods.PetDeflectProc > 0 && st.randFloat() < player.Mods.PetDeflectProc
if petDeflect {
result.PetDeflected = true
}
// Spore cloud: enemy has 15% miss chance (decremented when enemy actually attacks)
sporeMiss := st.sporeRounds > 0 && st.randFloat() < 0.15
// Determine initiative. DM mood (Effusive/Hostile) biases the player's
// roll via player.Mods.InitiativeBias — +X means player goes first
// more often, -X means the enemy does.
playerSpeed := float64(player.Stats.Speed) * phase.SpeedWeight
enemySpeed := float64(enemy.Stats.Speed) * phase.SpeedWeight
playerInit := playerSpeed + st.randFloat()*10 + player.Mods.InitiativeBias
enemyInit := enemySpeed + st.randFloat()*10
playerFirst := playerInit >= enemyInit
if playerFirst {
if resolvePlayerSwings(st, player, enemy, phase, result) {
return true
}
if !abilityDealtDamage {
if resolveEnemyAttack(st, player, enemy, phase, result, petWhiff, petDeflect, sporeMiss) {
return true
}
}
} else {
if !abilityDealtDamage {
if resolveEnemyAttack(st, player, enemy, phase, result, petWhiff, petDeflect, sporeMiss) {
return true
}
}
if resolvePlayerSwings(st, player, enemy, phase, result) {
return true
}
}
// Environmental hazard
if phase.EnvironmentProc > 0 && st.randFloat() < phase.EnvironmentProc {
envDmg := 2 + st.roll(5)
st.playerHP = max(0, st.playerHP-envDmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "environment", Action: "environmental",
Damage: envDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if st.playerHP <= 0 {
if !trySave(st, player, phaseName) {
return true
}
}
}
// Misty crowd revenge (debuff for declining Misty)
if player.Mods.CrowdRevengeProc > 0 && st.randFloat() < player.Mods.CrowdRevengeProc {
dmg := player.Mods.CrowdRevengeDmg
st.playerHP = max(0, st.playerHP-dmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "environment", Action: "crowd_revenge",
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Desc: "Misty's crowd",
})
if st.playerHP <= 0 {
if !trySave(st, player, phaseName) {
return true
}
}
}
// Pet attack
if player.Mods.PetAttackProc > 0 && st.randFloat() < player.Mods.PetAttackProc {
petDmg := player.Mods.PetAttackDmg + st.roll(5)
st.enemyHP = max(0, st.enemyHP-petDmg)
result.PetAttacked = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "pet", Action: "pet_attack",
Damage: petDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if enemyDown(st, phaseName) {
return true
}
}
// Spiritual Weapon strike
if player.Mods.SpiritWeaponProc > 0 && st.randFloat() < player.Mods.SpiritWeaponProc {
swDmg := player.Mods.SpiritWeaponDmg + st.roll(5)
st.enemyHP = max(0, st.enemyHP-swDmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "spirit_weapon", Action: "spirit_weapon_strike",
Damage: swDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if enemyDown(st, phaseName) {
return true
}
}
// Misty heal
if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc {
healAmt := player.Mods.MistyHealAmt
st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt)
result.MistyHealed = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal",
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Desc: "Misty",
})
}
// Consumable heal: triggers when player drops below 60% HP. Fires up
// to HealItemCharges times per fight (1 = legacy one-shot; bosses can
// stack from inventory).
//
// Threshold is 60% rather than 50% to give low-HP classes (cleric,
// mage) more breathing room — at 50% a cleric was bleeding into the
// danger zone before the heal fired.
if st.healChargesLeft > 0 && player.Mods.HealItem > 0 &&
st.playerHP > 0 && st.playerHP*5 < player.Stats.MaxHP*3 {
st.healChargesLeft--
healAmt := player.Mods.HealItem
st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "consumable", Action: "heal_item",
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
// Regenerate (monster ability): the enemy knits its wounds at the close of
// every round once the ability has armed st.enemyRegen.
if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < enemy.Stats.MaxHP {
st.enemyHP = min(enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "regen_tick",
Damage: st.enemyRegen, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
// End-of-round Orc Rage backstop. The primary trigger sits at the
// top of resolvePlayerAttack so rage fires same-round when the player
// swings after taking the threshold-crossing hit. But if the enemy
// goes first this round AND next, the player can be two-shot without
// ever getting back to that check. Re-checking here ensures the rage
// event always fires while HP > 0 and below 50%, even if the buff
// goes unused.
maybeTriggerOrcRage(st, player, phaseName)
return false
return simulatePartyWithRNG([]Combatant{player}, enemy, phases, rng).Seats[0]
}
// maybeTriggerOrcRage emits the "rage" event and arms pendingRageAttack
@@ -1523,25 +1298,3 @@ func trySave(st *combatState, player *Combatant, phaseName string) bool {
}
return false
}
func finalize(result CombatResult, st *combatState, player, enemy Combatant) CombatResult {
result.Events = st.events
result.PlayerEndHP = st.playerHP
result.EnemyEndHP = st.enemyHP
result.TotalRounds = st.round
result.PlayerWon = st.enemyHP <= 0
playerMax := max(1, player.Stats.MaxHP)
enemyMax := max(1, enemy.Stats.MaxHP)
if result.PlayerWon && st.playerHP > 0 {
result.NearDeath = float64(st.playerHP) < float64(playerMax)*0.15
winnerRemaining := float64(st.playerHP) / float64(playerMax)
result.Closeness = 1.0 - winnerRemaining
} else if !result.PlayerWon {
enemyRemaining := float64(st.enemyHP) / float64(enemyMax)
result.NearDeath = enemyRemaining < 0.15
result.Closeness = 1.0 - enemyRemaining
}
return result
}

View File

@@ -202,9 +202,138 @@ func characterizationScenarios() []charScenario {
return e
}(), defaultCombatPhases},
{"weapon_profile", weaponPlayer(), baseEnemy(), defaultCombatPhases},
// --- Class-identity mods (2026-05-16 audit + J1). ExtraAttacks is the
// lever J1 moved, so it needs a pin before the roster refactor.
{"extra_attacks_1", modPlayer(func(m *CombatModifiers) { m.ExtraAttacks = 1 }), tankyEnemy(), defaultCombatPhases},
{"extra_attacks_3", modPlayer(func(m *CombatModifiers) { m.ExtraAttacks = 3 }), tankyEnemy(), defaultCombatPhases},
{"sneak_attack", modPlayer(func(m *CombatModifiers) { m.SneakAttackDie = 4 }), tankyEnemy(), defaultCombatPhases},
{"hunters_mark", modPlayer(func(m *CombatModifiers) { m.HuntersMarkDie = 3 }), tankyEnemy(), defaultCombatPhases},
{"divine_strike", weaponMods(func(m *CombatModifiers) { m.DivineStrikePerHit = 4 }), tankyEnemy(), defaultCombatPhases},
{"thorn_lash", modPlayer(func(m *CombatModifiers) { m.ThornLashDmg = 3 }), hardHitEnemy(), defaultCombatPhases},
{"crit_threshold_19", modPlayer(func(m *CombatModifiers) { m.CritThreshold = 19 }), tankyEnemy(), defaultCombatPhases},
{"first_attack_bonus", modPlayer(func(m *CombatModifiers) { m.FirstAttackBonus = 8 }), tankyEnemy(), defaultCombatPhases},
{"assassinate", modPlayer(func(m *CombatModifiers) {
m.AssassinateAdvantage = true
m.AssassinateBonusDmg = 12
}), tankyEnemy(), defaultCombatPhases},
{"berserker_rage", modPlayer(func(m *CombatModifiers) {
m.BerserkerRage = true
m.RageMeleeDmg = 2
m.PhysicalResistRage = true
m.FrenzyDmgBonus = 0.25
}), hardHitEnemy(), defaultCombatPhases},
{"spirit_weapon", modPlayer(func(m *CombatModifiers) {
m.SpiritWeaponProc = 1.0
m.SpiritWeaponDmg = 8
}), tankyEnemy(), defaultCombatPhases},
{"arcane_ward", modPlayer(func(m *CombatModifiers) { m.ArcaneWardHP = 25 }), hardHitEnemy(), defaultCombatPhases},
{"heal_charges_3", modPlayer(func(m *CombatModifiers) {
m.HealItem = 20
m.HealItemCharges = 3
}), hardHitEnemy(), defaultCombatPhases},
{"crowd_revenge", modPlayer(func(m *CombatModifiers) {
m.CrowdRevengeProc = 1.0
m.CrowdRevengeDmg = 4
}), baseEnemy(), defaultCombatPhases},
// --- Race passives.
{"lucky_reroll", modPlayer(func(m *CombatModifiers) { m.LuckyReroll = true }), tankyEnemy(), defaultCombatPhases},
{"orc_rage", modPlayer(func(m *CombatModifiers) { m.RageReady = true }), hardHitEnemy(), defaultCombatPhases},
{"poison_resist", modPlayer(func(m *CombatModifiers) { m.PoisonResist = true }), abilityEnemy("Venom", "poison", "any"), defaultCombatPhases},
{"fire_resist_aoe", modPlayer(func(m *CombatModifiers) { m.FireResist = true }), abilityEnemy("Flame Breath", "aoe_fire", "any"), defaultCombatPhases},
// --- Phase 13 bestiary slices 3 + 4. None of these were pinned.
{"ability_bonus_damage", basePlayer(), abilityEnemy("Smash", "bonus_damage", "any"), defaultCombatPhases},
{"ability_aoe", basePlayer(), abilityEnemy("Blast", "aoe", "any"), defaultCombatPhases},
{"ability_death_aoe", basePlayer(), abilityEnemy("Last Breath", "death_aoe", "any"), defaultCombatPhases},
{"ability_execute", func() Combatant {
p := basePlayer()
p.Stats.MaxHP = 40
return p
}(), abilityEnemy("Finisher", "execute", "any"), defaultCombatPhases},
{"ability_self_heal", basePlayer(), abilityEnemy("Mend", "self_heal", "any"), defaultCombatPhases},
{"ability_evade", basePlayer(), abilityEnemy("Blink", "evade", "any"), defaultCombatPhases},
{"ability_block", basePlayer(), abilityEnemy("Parry", "block", "any"), defaultCombatPhases},
{"ability_advantage", basePlayer(), abilityEnemy("Focus", "advantage", "any"), defaultCombatPhases},
{"ability_retaliate", basePlayer(), abilityEnemy("Spines", "retaliate", "any"), defaultCombatPhases},
{"ability_regenerate", basePlayer(), abilityEnemy("Regrow", "regenerate", "any"), defaultCombatPhases},
{"ability_survive_at_1", basePlayer(), func() Combatant {
e := abilityEnemy("Undying", "survive_at_1", "any")
e.Stats.MaxHP = 40
return e
}(), defaultCombatPhases},
{"ability_stat_drain", basePlayer(), abilityEnemy("Sap", "stat_drain", "any"), defaultCombatPhases},
{"ability_debuff", basePlayer(), abilityEnemy("Curse", "debuff", "any"), defaultCombatPhases},
{"ability_max_hp_drain", basePlayer(), abilityEnemy("Wither", "max_hp_drain", "any"), defaultCombatPhases},
{"ability_spell_resist", func() Combatant {
p := basePlayer()
p.Mods.SpellPreDamage = 30
p.Mods.SpellPreDamageDesc = "Fireball"
return p
}(), abilityEnemy("Warded", "spell_resist", "any"), defaultCombatPhases},
{"ability_reveal_action", basePlayer(), abilityEnemy("Expose", "reveal_action", "any"), defaultCombatPhases},
{"ability_fear_immune", func() Combatant {
p := basePlayer()
p.Mods.SpellPreDamage = 5
p.Mods.SpellPreDamageDesc = "Hold Person"
p.Mods.SpellEnemySkipFirst = true
return p
}(), abilityEnemy("Fearless", "fear_immune", "any"), defaultCombatPhases},
{"ability_ally_buff", basePlayer(), abilityEnemy("Warcry", "ally_buff", "any"), defaultCombatPhases},
// --- Phase-scoped ability gating + environment hazard.
{"ability_opening_phase", basePlayer(), abilityEnemy("Ambush", "bonus_damage", "opening"), defaultCombatPhases},
{"environment_heavy", basePlayer(), baseEnemy(), []CombatPhase{
{"Opening", 2, 0.6, 0.8, 1.5, 1.0},
{"Clash", 3, 1.2, 1.0, 0.8, 1.0},
}},
}
}
// modPlayer returns basePlayer with a mutation applied to its modifiers.
func modPlayer(f func(*CombatModifiers)) Combatant {
p := basePlayer()
f(&p.Mods)
return p
}
// weaponMods is modPlayer for effects that only fire on the weapon-dice
// damage path (Divine Strike has no effect without a Weapon).
func weaponMods(f func(*CombatModifiers)) Combatant {
p := basePlayer()
p.Stats.Weapon = weaponByID("wpn_longsword")
f(&p.Mods)
return p
}
// tankyEnemy survives long enough for per-hit and per-swing riders to
// accumulate across several rounds instead of dying in the opening.
func tankyEnemy() Combatant {
e := baseEnemy()
e.Stats.MaxHP = 300
e.Stats.Defense = 10
return e
}
// hardHitEnemy hits hard enough to exercise damage-taken paths (wards,
// resists, retaliation, heal triggers) before the fight resolves.
func hardHitEnemy() Combatant {
e := baseEnemy()
e.Stats.MaxHP = 150
e.Stats.Attack = 28
return e
}
// abilityEnemy is baseEnemy carrying an always-proccing ability, so the
// golden pins the effect rather than the proc roll.
func abilityEnemy(name, effect, phase string) Combatant {
e := baseEnemy()
e.Stats.MaxHP = 120
e.Ability = &MonsterAbility{Name: name, Phase: phase, ProcChance: 1.0, Effect: effect}
return e
}
// charSeeds drives each scenario. Multiple seeds widen RNG-branch coverage
// without exploding the golden file.
var charSeeds = []uint64{1, 2, 3, 7, 42}

View File

@@ -0,0 +1,599 @@
package plugin
import (
"math/rand/v2"
"sort"
)
// ── N-body auto-resolve ──────────────────────────────────────────────────────
//
// The auto-resolve engine seats a roster, exactly as the turn-based engine has
// since P3. `SimulateCombat` is the one-seat case and nothing more: for a solo
// roster every short-circuit below collapses to the pre-roster code path and
// the engine draws from the RNG in precisely the pre-roster order. That is what
// keeps `TestCombatCharacterization` byte-identical and the d8prereq_corpus
// baselines comparable. If the golden moves, solo balance moved — stop.
//
// The invariants the solo path rests on, all of them mirrored from P3:
//
// - enemyTargetSeat draws nothing for a one-seat roster (there is only one
// target), so the enemy's choice costs no randomness.
// - the initiative loop draws one player roll then one enemy roll, which is
// the pre-roster order; ties go to the player, as `playerInit >= enemyInit`
// always did.
// - the per-seat loops run exactly once.
//
// Per-actor state (poison, charges, rage, wards) follows the `combatState`
// cursor, so the resolution primitives need no changes: they already read
// `st.c` — the turn engine has called them that way since P3.
// PartyCombatResult is one fight, seen from every seat at once. The fight-scoped
// fields (the enemy, the round count, the event log) are shared; `Seats` holds
// the per-character view, and `Seats[i]` is a complete `CombatResult` so a solo
// caller can take `Seats[0]` and be handed exactly what `SimulateCombat` always
// returned.
type PartyCombatResult struct {
PlayerWon bool
TimedOut bool
TotalRounds int
Events []CombatEvent
EnemyStartHP int
EnemyEntryHP int
EnemyEndHP int
Seats []CombatResult
}
// AnySurvivor reports whether at least one seat is still standing. A party can
// win a fight it did not all walk away from.
func (r PartyCombatResult) AnySurvivor() bool {
for i := range r.Seats {
if r.Seats[i].PlayerEndHP > 0 {
return true
}
}
return false
}
// simulateParty auto-resolves one enemy against a roster of N player characters.
// Production auto-resolve passes a nil rng (package global); the sim harness and
// the characterization test seed it.
func simulateParty(players []Combatant, enemy Combatant, phases []CombatPhase) PartyCombatResult {
return simulatePartyWithRNG(players, enemy, phases, nil)
}
func simulatePartyWithRNG(players []Combatant, enemy Combatant, phases []CombatPhase, rng *rand.Rand) PartyCombatResult {
enemyStart := enemy.Stats.MaxHP
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP {
enemyStart = enemy.Stats.StartHP
}
actors := make([]*actor, len(players))
seats := make([]CombatResult, len(players))
for i := range players {
actors[i] = newActor(&players[i])
seats[i] = CombatResult{
PlayerStartHP: players[i].Stats.MaxHP,
PlayerEntryHP: actors[i].playerHP,
EnemyStartHP: enemy.Stats.MaxHP,
EnemyEntryHP: enemyStart,
}
}
st := &combatState{
actor: actors[0],
actors: actors,
enemyHP: enemyStart,
rng: rng,
}
// Holding the enemy holds it for everyone, so the control effect is
// fight-scoped: any caster who queued one arms it.
for i := range players {
if players[i].Mods.SpellEnemySkipFirst {
st.enemySkipFirst = true
}
}
// Pre-combat one-shots, grouped by kind rather than by seat. A one-seat
// roster walks these in the pre-roster order.
for i := range actors {
st.seat(i)
if st.c.Mods.SniperKillProc > 0 && st.randFloat() < st.c.Mods.SniperKillProc {
st.enemyHP = 0
st.events = append(st.events, CombatEvent{
Round: 0, Phase: "pre_combat", Actor: "npc", Action: "sniper_kill",
Damage: enemy.Stats.MaxHP, PlayerHP: st.playerHP, EnemyHP: 0,
Seat: i, Desc: "Arina",
})
seats[i].SniperKilled = true
return finalizeParty(seats, st, players, enemy)
}
}
for i := range actors {
st.seat(i)
if st.c.Mods.FlatDmgStart > 0 {
dmg := st.c.Mods.FlatDmgStart
st.enemyHP = max(0, st.enemyHP-dmg)
st.events = append(st.events, CombatEvent{
Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "flat_damage",
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
})
if st.enemyHP <= 0 {
return finalizeParty(seats, st, players, enemy)
}
}
}
// Queued spells. Resolved by applyPendingCast() before this runs — the
// modifiers carry the resolved damage and narrative hook.
for i := range actors {
st.seat(i)
if st.c.Mods.SpellPreDamageDesc == "" {
continue
}
dmg := st.c.Mods.SpellPreDamage
resisted := dmg > 0 && enemyResistsSpells(&enemy, st)
if resisted {
dmg = max(1, dmg/2)
}
if dmg > 0 {
st.enemyHP = max(0, st.enemyHP-dmg)
}
st.events = append(st.events, CombatEvent{
Round: 0, Phase: "pre_combat", Actor: "player", Action: "spell_cast",
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
Desc: st.c.Mods.SpellPreDamageDesc,
})
if resisted {
st.events = append(st.events, CombatEvent{
Round: 0, Phase: "pre_combat", Actor: "enemy", Action: "spell_fizzle",
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
})
}
if st.enemyHP <= 0 {
return finalizeParty(seats, st, players, enemy)
}
}
// Pre-combat: Misty gourmet is now a per-round heal, no pre-combat damage
for _, phase := range phases {
roundsThisPhase := phase.Rounds
// Add slight variance: ±1 round for non-Decisive phases
if phase.Name != "Decisive" && roundsThisPhase > 1 {
roundsThisPhase += st.roll(2) // 0 or +1
}
for r := 0; r < roundsThisPhase; r++ {
st.round++
if simulatePartyRound(st, &enemy, &phase, seats) {
return finalizeParty(seats, st, players, enemy)
}
}
}
// Exhausted the phase clock without a kill. Tiebreak on HP percentage to
// decide the outcome — but DO NOT zero out HP on the loser. Timeout =
// retreat, not a lethal blow, so no character-death side effects fire.
//
// The party reads its fraction off the pooled roster, which for one seat is
// that seat's own fraction, i.e. the pre-roster comparison. Slight bias to
// the players on exact ties (frac >=).
var partyHP, partyMax int
for i := range st.actors {
partyHP += st.actors[i].playerHP
partyMax += players[i].Stats.MaxHP
}
playerFrac := float64(partyHP) / float64(max(1, partyMax))
enemyFrac := float64(st.enemyHP) / float64(max(1, enemy.Stats.MaxHP))
playerWonTiebreak := playerFrac >= enemyFrac
st.seat(0)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: "exhaust", Actor: "system", Action: "timeout",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
for i := range seats {
seats[i].TimedOut = true
}
out := finalizeParty(seats, st, players, enemy)
out.TimedOut = true
out.PlayerWon = playerWonTiebreak
for i := range out.Seats {
out.Seats[i].PlayerWon = playerWonTiebreak
}
return out
}
// combatOver reads the fight's terminal condition off HP rather than off a
// primitive's bool. resolvePlayerAttack documents why: a retaliate aura can
// drop the swinger with the enemy still standing, so `true` means "something
// decisive happened", not "the players won". For a one-seat roster this is the
// same answer the old `return true` gave.
func combatOver(st *combatState) bool {
return st.enemyHP <= 0 || !st.anyAlive()
}
// enemyTargetSeat picks who the enemy swings at. A one-seat roster draws no
// randomness — there is only one target — which is what keeps the solo RNG
// stream identical. Shared with the turn engine.
func enemyTargetSeat(st *combatState) (int, bool) {
if len(st.actors) == 1 {
return 0, st.actors[0].playerHP > 0
}
standing := make([]int, 0, len(st.actors))
for i, a := range st.actors {
if a.playerHP > 0 {
standing = append(standing, i)
}
}
if len(standing) == 0 {
return 0, false
}
return standing[st.roll(len(standing))], true
}
// eventsForSeat is the sub-log a single character is responsible for. Events the
// engine never stamped — the enemy regenerating, the phase clock running out —
// carry seat 0, so they read as the leader's. Only ever called for a party: a
// solo seat is handed the whole log by identity.
func eventsForSeat(events []CombatEvent, seat int) []CombatEvent {
out := make([]CombatEvent, 0, len(events))
for _, e := range events {
if e.Seat == seat {
out = append(out, e)
}
}
return out
}
// stampEventSeats attributes every event appended since `mark` to a seat, so
// party narration can say who did what. Seat 0 stamps a zero, which is
// `omitempty` — a solo fight's event log is byte-identical.
func stampEventSeats(st *combatState, mark, seat int) {
for i := mark; i < len(st.events); i++ {
st.events[i].Seat = seat
}
}
// roundInitiative rolls the round's turn order: every seat, then the enemy, in
// seating order — the pre-roster draw order when there is one seat. Ties favour
// the players, then the lower seat.
func roundInitiative(st *combatState, enemy *Combatant, phase *CombatPhase) []int {
type entry struct {
seat int
init float64
}
entries := make([]entry, 0, len(st.actors)+1)
for i, a := range st.actors {
speed := float64(a.c.Stats.Speed) * phase.SpeedWeight
entries = append(entries, entry{i, speed + st.randFloat()*10 + a.c.Mods.InitiativeBias})
}
enemySpeed := float64(enemy.Stats.Speed) * phase.SpeedWeight
entries = append(entries, entry{enemySeat, enemySpeed + st.randFloat()*10})
sort.SliceStable(entries, func(i, j int) bool {
a, b := entries[i], entries[j]
if a.init != b.init {
return a.init > b.init
}
if (a.seat == enemySeat) != (b.seat == enemySeat) {
return b.seat == enemySeat
}
return a.seat < b.seat
})
order := make([]int, len(entries))
for i, e := range entries {
order[i] = e.seat
}
return order
}
// simulatePartyRound runs one round for the whole roster. Returns true if the
// fight is over.
func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, seats []CombatResult) bool {
phaseName := phase.Name
// Whoever the enemy is looking at this round. Chosen before the ability
// fires, because the ability lands on its target. Costs no RNG when solo.
target, alive := enemyTargetSeat(st)
if !alive {
return true
}
st.seat(target)
// Phase 9 spell: control effect (Hold Person, Sleep, Command) skips the
// enemy's attack for one round. Fight-scoped — holding it holds it for all.
enemyHeldThisRound := false
if st.enemySkipFirst {
st.enemySkipFirst = false
if enemyImmuneToControl(enemy, st) {
// fear_immune: the control spell can't take hold — the enemy acts
// as normal this round.
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "fear_resist",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
} else {
enemyHeldThisRound = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "spell_held",
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
}
// Monster ability: check at round start. It resolves against the enemy's
// target, which the cursor already points at.
abilityDealtDamage := enemyHeldThisRound
if enemy.Ability != nil {
if abilityFires(enemy.Ability, phaseName, st) {
mark := len(st.events)
over := applyAbility(st, st.c, enemy, phase, &seats[target])
stampEventSeats(st, mark, target)
if over && combatOver(st) {
return true
}
// Cleave and lifesteal deal damage — skip normal enemy attack this round
switch enemy.Ability.Effect {
case "cleave", "lifesteal":
abilityDealtDamage = true
}
}
}
// Poison tick from previous round. Stacked per character, so every seat
// carrying it bleeds.
for i := range st.actors {
st.seat(i)
if st.poisonTicks <= 0 {
continue
}
st.playerHP = max(0, st.playerHP-st.poisonDmg)
st.poisonTicks--
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "poison_tick",
Damage: st.poisonDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
})
if st.playerHP <= 0 && !trySave(st, st.c, phaseName) && !st.anyAlive() {
return true
}
}
// The poison may have dropped the seat the enemy had picked. Re-target
// before the swing procs, which read the target's own modifiers.
if st.actors[target].playerHP <= 0 {
if target, alive = enemyTargetSeat(st); !alive {
return true
}
}
st.seat(target)
// Pet whiff: if proc'd, enemy's attack this round is a guaranteed miss
petWhiff := st.c.Mods.PetWhiffProc > 0 && st.randFloat() < st.c.Mods.PetWhiffProc
// Pet deflect: halves incoming damage to the target this round
petDeflect := st.c.Mods.PetDeflectProc > 0 && st.randFloat() < st.c.Mods.PetDeflectProc
if petDeflect {
seats[target].PetDeflected = true
}
// Spore cloud: enemy has 15% miss chance (decremented when enemy actually attacks)
sporeMiss := st.sporeRounds > 0 && st.randFloat() < 0.15
// Determine initiative. DM mood (Effusive/Hostile) biases a player's roll
// via Mods.InitiativeBias — +X means they go first more often.
for _, s := range roundInitiative(st, enemy, phase) {
if s == enemySeat {
if abilityDealtDamage {
continue
}
st.seat(target)
mark := len(st.events)
over := resolveEnemyAttack(st, st.c, enemy, phase, &seats[target], petWhiff, petDeflect, sporeMiss)
stampEventSeats(st, mark, target)
if over && combatOver(st) {
return true
}
continue
}
if st.actors[s].playerHP <= 0 {
// A seat that went down earlier this round forfeits its swing.
continue
}
st.seat(s)
mark := len(st.events)
over := resolvePlayerSwings(st, st.c, enemy, phase, &seats[s])
stampEventSeats(st, mark, s)
if over && combatOver(st) {
return true
}
}
// End-of-round, per surviving character.
for i := range st.actors {
st.seat(i)
if st.playerHP <= 0 {
continue
}
mark := len(st.events)
if over := endOfRoundForSeat(st, phase, &seats[i]); over {
stampEventSeats(st, mark, i)
return true
}
stampEventSeats(st, mark, i)
}
// Regenerate (monster ability): the enemy knits its wounds at the close of
// every round once the ability has armed st.enemyRegen. Fight-scoped.
if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < enemy.Stats.MaxHP {
st.enemyHP = min(enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "regen_tick",
Damage: st.enemyRegen, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
// End-of-round Orc Rage backstop. The primary trigger sits at the top of
// resolvePlayerAttack so rage fires same-round when the character swings
// after taking the threshold-crossing hit. But if the enemy goes first this
// round AND next, they can be two-shot without ever getting back to that
// check. Re-checking here ensures the rage event always fires while HP > 0
// and below 50%, even if the buff goes unused.
for i := range st.actors {
st.seat(i)
mark := len(st.events)
maybeTriggerOrcRage(st, st.c, phaseName)
stampEventSeats(st, mark, i)
}
return false
}
// endOfRoundForSeat runs the per-character close of a round against the seat the
// cursor already points at: environment, Misty's crowd, the pet, the spiritual
// weapon, Misty's heal, and the consumable auto-heal — in that order, which is
// the pre-roster order. Returns true if the fight is over.
func endOfRoundForSeat(st *combatState, phase *CombatPhase, result *CombatResult) bool {
phaseName := phase.Name
player := st.c
// Environmental hazard
if phase.EnvironmentProc > 0 && st.randFloat() < phase.EnvironmentProc {
envDmg := 2 + st.roll(5)
st.playerHP = max(0, st.playerHP-envDmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "environment", Action: "environmental",
Damage: envDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive() {
return true
}
}
// Misty crowd revenge (debuff for declining Misty)
if player.Mods.CrowdRevengeProc > 0 && st.randFloat() < player.Mods.CrowdRevengeProc {
dmg := player.Mods.CrowdRevengeDmg
st.playerHP = max(0, st.playerHP-dmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "environment", Action: "crowd_revenge",
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Desc: "Misty's crowd",
})
if st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive() {
return true
}
}
// A character the round just killed stops acting, but the fight goes on if
// anyone else is standing.
if st.playerHP <= 0 {
return false
}
// Pet attack
if player.Mods.PetAttackProc > 0 && st.randFloat() < player.Mods.PetAttackProc {
petDmg := player.Mods.PetAttackDmg + st.roll(5)
st.enemyHP = max(0, st.enemyHP-petDmg)
result.PetAttacked = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "pet", Action: "pet_attack",
Damage: petDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if enemyDown(st, phaseName) {
return true
}
}
// Spiritual Weapon strike
if player.Mods.SpiritWeaponProc > 0 && st.randFloat() < player.Mods.SpiritWeaponProc {
swDmg := player.Mods.SpiritWeaponDmg + st.roll(5)
st.enemyHP = max(0, st.enemyHP-swDmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "spirit_weapon", Action: "spirit_weapon_strike",
Damage: swDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if enemyDown(st, phaseName) {
return true
}
}
// Misty heal
if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc {
healAmt := player.Mods.MistyHealAmt
st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt)
result.MistyHealed = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal",
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Desc: "Misty",
})
}
// Consumable heal: triggers when the character drops below 60% HP. Fires up
// to HealItemCharges times per fight (1 = legacy one-shot; bosses can
// stack from inventory).
//
// Threshold is 60% rather than 50% to give low-HP classes (cleric, mage)
// more breathing room — at 50% a cleric was bleeding into the danger zone
// before the heal fired.
if st.healChargesLeft > 0 && player.Mods.HealItem > 0 &&
st.playerHP > 0 && st.playerHP*5 < player.Stats.MaxHP*3 {
st.healChargesLeft--
healAmt := player.Mods.HealItem
st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "consumable", Action: "heal_item",
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
}
return false
}
// finalizeParty closes the fight, giving every seat its own view. The enemy's
// numbers and the event log are shared; HP, closeness and near-death are read
// per character. For one seat this is byte-for-byte the old `finalize`.
func finalizeParty(seats []CombatResult, st *combatState, players []Combatant, enemy Combatant) PartyCombatResult {
won := st.enemyHP <= 0
enemyMax := max(1, enemy.Stats.MaxHP)
// A solo fight's seat holds the whole log, which is the same slice header the
// pre-roster engine returned. A party's seats each hold their own events, so
// the per-seat close-out (heal items burned, combat achievements) counts what
// that character actually did rather than what the party did.
solo := len(seats) == 1
for i := range seats {
a := st.actors[i]
r := &seats[i]
if solo {
r.Events = st.events
} else {
r.Events = eventsForSeat(st.events, i)
}
r.PlayerEndHP = a.playerHP
r.EnemyEndHP = st.enemyHP
r.TotalRounds = st.round
r.PlayerWon = won
playerMax := max(1, players[i].Stats.MaxHP)
if won && a.playerHP > 0 {
r.NearDeath = float64(a.playerHP) < float64(playerMax)*0.15
winnerRemaining := float64(a.playerHP) / float64(playerMax)
r.Closeness = 1.0 - winnerRemaining
} else if !won {
enemyRemaining := float64(st.enemyHP) / float64(enemyMax)
r.NearDeath = enemyRemaining < 0.15
r.Closeness = 1.0 - enemyRemaining
}
}
return PartyCombatResult{
PlayerWon: won,
TotalRounds: st.round,
Events: st.events,
EnemyStartHP: enemy.Stats.MaxHP,
EnemyEntryHP: seats[0].EnemyEntryHP,
EnemyEndHP: st.enemyHP,
Seats: seats,
}
}

View File

@@ -0,0 +1,217 @@
package plugin
import (
"math/rand/v2"
"testing"
)
// seededRNG gives each test its own deterministic stream, so none of these can
// join the flaky set that rode the package global before P4 seeded them.
func seededRNG(seed uint64) *rand.Rand {
return rand.New(rand.NewPCG(seed, seed^0x9e3779b9))
}
// The load-bearing claim of P6e: a one-seat roster draws from the RNG in exactly
// the pre-roster order, so SimulateCombat is the degenerate case of the N-body
// engine and the d8prereq_corpus baselines still compare. TestCombatCharacterization
// is the real proof (57 scenarios); this pins the delegation itself.
func TestSimulateCombat_IsTheOneSeatPartyCase(t *testing.T) {
for seed := uint64(1); seed <= 40; seed++ {
solo := simulateCombatWithRNG(basePlayer(), baseEnemy(), dungeonCombatPhases, seededRNG(seed))
party := simulatePartyWithRNG([]Combatant{basePlayer()}, baseEnemy(), dungeonCombatPhases, seededRNG(seed))
if len(party.Seats) != 1 {
t.Fatalf("seed %d: one player seated %d seats", seed, len(party.Seats))
}
got := party.Seats[0]
if got.PlayerWon != solo.PlayerWon || got.PlayerEndHP != solo.PlayerEndHP ||
got.EnemyEndHP != solo.EnemyEndHP || got.TotalRounds != solo.TotalRounds ||
got.TimedOut != solo.TimedOut {
t.Fatalf("seed %d: one-seat party diverged from solo\n solo=%+v\nparty=%+v", seed, solo, got)
}
if len(got.Events) != len(solo.Events) {
t.Fatalf("seed %d: event count %d != solo %d", seed, len(got.Events), len(solo.Events))
}
for i := range solo.Events {
if got.Events[i] != solo.Events[i] {
t.Fatalf("seed %d: event %d differs\n solo=%+v\nparty=%+v", seed, i, solo.Events[i], got.Events[i])
}
}
}
}
// A solo fight never stamps a seat, because seat 0 is the zero value and the
// field is omitempty. If this regresses, the golden file moves.
func TestSimulateCombat_SoloEventsCarryNoSeat(t *testing.T) {
res := simulateCombatWithRNG(basePlayer(), baseEnemy(), dungeonCombatPhases, seededRNG(7))
for i, e := range res.Events {
if e.Seat != 0 {
t.Fatalf("event %d (%s/%s) stamped seat %d in a solo fight", i, e.Phase, e.Action, e.Seat)
}
}
}
// The whole point of P6e. Three characters swinging at one monster must land more
// player attacks per round than one character does — before this, the members
// stood in the doorway while the leader fought.
func TestSimulateParty_EverySeatSwings(t *testing.T) {
tank := baseEnemy()
tank.Stats.MaxHP = 5000 // outlast the phase clock so we can count swings
countSwings := func(events []CombatEvent, seat int) int {
n := 0
for _, e := range events {
if e.Actor == "player" && e.Seat == seat && e.Roll > 0 {
n++
}
}
return n
}
res := simulatePartyWithRNG(
[]Combatant{basePlayer(), basePlayer(), basePlayer()}, tank, dungeonCombatPhases, seededRNG(11))
if len(res.Seats) != 3 {
t.Fatalf("seated %d, want 3", len(res.Seats))
}
for seat := range 3 {
if got := countSwings(res.Events, seat); got == 0 {
t.Fatalf("seat %d never swung — the party is not fighting together", seat)
}
}
}
// A member going down is not the end of the fight. Only an empty roster is.
// This is the bug P7 measured from the outside: every party loss was the leader
// dying alone, because a member was never in the fight to begin with.
func TestSimulateParty_DownedMemberDoesNotEndTheFight(t *testing.T) {
glass := basePlayer()
glass.Stats.MaxHP = 1
glass.Stats.Defense = 0
brute := basePlayer()
brute.Stats.MaxHP = 4000
killer := baseEnemy()
killer.Stats.MaxHP = 3000
killer.Stats.Attack = 40
res := simulatePartyWithRNG([]Combatant{brute, glass}, killer, dungeonCombatPhases, seededRNG(3))
if res.Seats[1].PlayerEndHP > 0 {
t.Skip("the glass cannon survived this seed; nothing to assert")
}
if !res.AnySurvivor() {
t.Fatalf("both seats down — pick a seed where the brute lives")
}
if res.TotalRounds <= 1 {
t.Fatalf("fight ended in %d round(s) when a member fell; the roster should have fought on",
res.TotalRounds)
}
// The brute must have kept swinging after the member fell.
lastMemberEvent, lastBruteEvent := -1, -1
for i, e := range res.Events {
if e.Seat == 1 && e.Actor == "player" {
lastMemberEvent = i
}
if e.Seat == 0 && e.Actor == "player" && e.Roll > 0 {
lastBruteEvent = i
}
}
if lastBruteEvent < lastMemberEvent {
t.Fatalf("the fight stopped when seat 1 fell (last brute swing %d, last member event %d)",
lastBruteEvent, lastMemberEvent)
}
}
// The enemy swings at one seat per round, not at everyone.
func TestSimulateParty_EnemyStrikesOneSeatPerRound(t *testing.T) {
tank := baseEnemy()
tank.Stats.MaxHP = 5000
res := simulatePartyWithRNG(
[]Combatant{basePlayer(), basePlayer(), basePlayer()}, tank, dungeonCombatPhases, seededRNG(23))
perRound := map[int]map[int]bool{}
for _, e := range res.Events {
if e.Actor != "enemy" || e.Roll == 0 {
continue
}
if perRound[e.Round] == nil {
perRound[e.Round] = map[int]bool{}
}
perRound[e.Round][e.Seat] = true
}
if len(perRound) == 0 {
t.Fatal("the enemy never attacked")
}
for round, seats := range perRound {
if len(seats) != 1 {
t.Fatalf("round %d: enemy attack rolls landed on %d seats, want 1", round, len(seats))
}
}
}
// Over many seeds the enemy must spread its attention across the roster, or
// "targeting" is really "always seat 0" and members take no risk.
func TestSimulateParty_EnemySpreadsItsTargetsAcrossTheRoster(t *testing.T) {
hit := map[int]bool{}
for seed := uint64(1); seed <= 30; seed++ {
tank := baseEnemy()
tank.Stats.MaxHP = 5000
res := simulatePartyWithRNG(
[]Combatant{basePlayer(), basePlayer(), basePlayer()}, tank, dungeonCombatPhases, seededRNG(seed))
for _, e := range res.Events {
if e.Actor == "enemy" && e.Roll > 0 {
hit[e.Seat] = true
}
}
}
for seat := range 3 {
if !hit[seat] {
t.Fatalf("seat %d was never targeted across 30 seeds — the enemy is not really choosing", seat)
}
}
}
// Per-seat close-out reads its own events, not the party's. If eventsForSeat
// leaked, a member's potions would be burned for the leader's heals.
func TestEventsForSeat_PartitionsTheLog(t *testing.T) {
events := []CombatEvent{
{Action: "heal_item", Seat: 0},
{Action: "heal_item", Seat: 1},
{Action: "heal_item", Seat: 1},
{Action: "regen_tick"}, // unstamped: reads as the leader's
}
if got := len(eventsForSeat(events, 0)); got != 2 {
t.Fatalf("seat 0 saw %d events, want 2", got)
}
if got := countHealEventsFired(CombatResult{Events: eventsForSeat(events, 1)}); got != 2 {
t.Fatalf("seat 1 fired %d heal items, want 2", got)
}
if got := countHealEventsFired(CombatResult{Events: eventsForSeat(events, 0)}); got != 1 {
t.Fatalf("seat 0 fired %d heal items, want 1", got)
}
}
// A party that wins with a casualty is a win, and the casualty is still a
// casualty: survival is read per seat off HP, never off the fight's outcome.
func TestAnySurvivor_ReadsHPNotOutcome(t *testing.T) {
res := PartyCombatResult{
PlayerWon: true,
Seats: []CombatResult{
{PlayerEndHP: 12},
{PlayerEndHP: 0},
{PlayerEndHP: 3},
},
}
if !res.AnySurvivor() {
t.Fatal("AnySurvivor said nobody lived")
}
// A won fight nobody walked away from is still nobody walking away.
wiped := PartyCombatResult{PlayerWon: true, Seats: []CombatResult{{PlayerEndHP: 0}}}
if wiped.AnySurvivor() {
t.Fatal("AnySurvivor counted a downed seat as standing")
}
}

View File

@@ -239,6 +239,9 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
case "spirit_weapon_strike":
return fmt.Sprintf(pickRand(narrativeSpiritWeapon), e.Damage)
case "concentration_tick":
return fmt.Sprintf(pickRand(narrativeConcentrationTick), e.Damage)
case "pet_deflect":
return pickRand(narrativePetDeflect)
@@ -536,6 +539,13 @@ var narrativeSpiritWeapon = []string{
"✨ The spectral blade flickers, then bites. %d damage. It does not tire. It does not blink.",
}
var narrativeConcentrationTick = []string{
"🌀 The lingering aura grinds the enemy down — %d damage. Still humming. Still hungry.",
"🌀 Your spell hasn't let go: the spirits sweep through again for %d damage.",
"🌀 The radiant field pulses once more — %d damage. Concentration holds.",
"🌀 The enemy steps wrong and the standing magic answers, %d damage. It does not move on.",
}
var narrativePetDeflect = []string{
"🐾 Your pet intercepts the blow. Damage halved. Your pet is now your best piece of equipment.",
"🐾 Your pet pushes you aside at the last second. Impact reduced. You did not ask to be pushed. Results speak for themselves.",
@@ -864,16 +874,46 @@ var narrativeCloseLoss = []string{
// handful of events, and across a long boss fight the picker resetting each
// round reads as variety rather than staleness.
func RenderTurnRound(events []CombatEvent, playerName, enemyName string) string {
return RenderPartyTurnRound(events, []string{playerName}, enemyName, 0)
}
// RenderPartyTurnRound renders a round for one member of a party — the reader at
// viewerSeat.
//
// The turn narration pool is written in the second person: "You score 9 damage",
// "A hit gets through your guard". That is exactly right for the reader's own
// events and exactly wrong for everybody else's, so this splits on the event's
// Seat. The reader's own events go through the same flavor pool a solo fight
// uses, untouched; an ally's are summarised third-person by renderAllySeatEvent.
//
// A solo fight has one seat, every event belongs to it, and the reader is it —
// so it renders the same bytes it always has.
func RenderPartyTurnRound(events []CombatEvent, seatNames []string, enemyName string, viewerSeat int) string {
if len(seatNames) == 0 {
seatNames = []string{"You"}
}
seatName := func(seat int) string {
if seat > 0 && seat < len(seatNames) {
return seatNames[seat]
}
return seatNames[0]
}
picker := newActionPicker()
var lines []string
for _, e := range events {
line := renderTurnEvent(e, playerName, enemyName, picker)
var line string
if e.Seat == viewerSeat {
line = renderTurnEvent(e, seatName(e.Seat), enemyName, picker)
if roll := rollAnnotation(e); line != "" && roll != "" {
line += " " + roll
}
} else {
line = renderAllySeatEvent(e, seatName(e.Seat), enemyName)
}
if line == "" {
continue
}
if roll := rollAnnotation(e); roll != "" {
line += " " + roll
}
lines = append(lines, line)
}
if len(lines) == 0 {
@@ -882,6 +922,80 @@ func RenderTurnRound(events []CombatEvent, playerName, enemyName string) string
return strings.Join(lines, "\n")
}
// renderAllySeatEvent summarises one event belonging to somebody else's
// character, for a party member's DM. Terse on purpose: the reader wants the
// shape of the round, not three sentences of flavor about their friend's pet.
// Anything not worth a line in someone else's DM renders as "".
func renderAllySeatEvent(e CombatEvent, name, enemyName string) string {
who := "**" + name + "**"
down := func(line string) string {
if e.PlayerHP <= 0 {
return line + " " + who + " is down."
}
return line
}
switch e.Action {
case "hit":
if e.Actor == "player" {
return fmt.Sprintf("%s hits %s for %d.", who, enemyName, e.Damage)
}
return down(fmt.Sprintf("%s hits %s for %d.", enemyName, who, e.Damage))
case "crit":
if e.Actor == "player" {
return fmt.Sprintf("%s crits %s for %d!", who, enemyName, e.Damage)
}
return down(fmt.Sprintf("%s crits %s for %d!", enemyName, who, e.Damage))
case "miss":
if e.Actor == "player" {
return fmt.Sprintf("%s misses.", who)
}
return fmt.Sprintf("%s misses %s.", enemyName, who)
case "block":
// renderEvent's convention: Actor "player" means the *enemy* blocked the
// player's swing, and vice versa.
if e.Actor == "player" {
return fmt.Sprintf("%s blocks %s (%d).", enemyName, who, e.Damage)
}
return fmt.Sprintf("%s blocks (%d).", who, e.Damage)
case "spell_cast":
label := e.Desc
if label == "" {
label = "a spell"
}
return fmt.Sprintf("%s casts %s.", who, label)
case "use_consumable":
label := e.Desc
if label == "" {
label = "an item"
}
return fmt.Sprintf("%s uses %s.", who, label)
case "pet_attack":
return fmt.Sprintf("🐾 %s's pet strikes for %d.", who, e.Damage)
case "spirit_weapon_strike":
return fmt.Sprintf("%s's spirit weapon strikes for %d.", who, e.Damage)
case "concentration_tick":
return fmt.Sprintf("%s's aura burns %s for %d.", who, enemyName, e.Damage)
case "poison_tick":
return down(fmt.Sprintf("☠️ %s takes %d from poison.", who, e.Damage))
case "environmental":
return down(fmt.Sprintf("%s takes %d from the room.", who, e.Damage))
case "flat_damage":
return fmt.Sprintf("%s deals %d.", who, e.Damage)
case "heal_item", "misty_heal":
return fmt.Sprintf("%s recovers %d.", who, e.Damage)
case "death_save":
return fmt.Sprintf("%s clings on.", who)
case "stun", "stunned":
return fmt.Sprintf("%s is stunned.", who)
case "flee":
return fmt.Sprintf("%s breaks off.", who)
default:
// Ward absorbs, spore misses, reflects, enrage cues: real, but noise in
// somebody else's DM. The owner sees them in full in their own.
return ""
}
}
func renderTurnEvent(e CombatEvent, playerName, enemyName string, picker *actionPicker) string {
switch e.Action {
case "flee":

View File

@@ -0,0 +1,331 @@
package plugin
import (
"encoding/json"
"math/rand/v2"
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// setupEmptyTestDB opens a fresh schema in a temp dir. Unlike setupZoneRunTestDB
// it copies nothing from data/gogobee.db, so it runs everywhere instead of
// skipping when the prod db is absent — the session/participant/party tables are
// the only ones these tests touch, and none of them need seeded characters.
func setupEmptyTestDB(t *testing.T) {
t.Helper()
db.Close()
if err := db.Init(t.TempDir()); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
}
// ── The wire format ─────────────────────────────────────────────────────────
// The ActorStatuses split moved ~25 fields behind an anonymous embed. That is a
// no-op on the wire only because the embed is untagged: encoding/json flattens
// it into the parent object. If someone ever gives it a json tag, every
// in-flight prod session silently loses its poison, its charges, and its
// once-per-fight one-shots on the next resume. Pin the flattening.
func TestCombatStatuses_JSONStaysFlat(t *testing.T) {
s := CombatStatuses{
ActorStatuses: ActorStatuses{PoisonTicks: 2, PoisonDmg: 5, WardCharges: 3},
Enraged: true,
EnemyRegen: 4,
}
raw, err := json.Marshal(s)
if err != nil {
t.Fatal(err)
}
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
t.Fatal(err)
}
for _, key := range []string{"poison_ticks", "poison_dmg", "ward_charges", "enraged", "enemy_regen"} {
if _, ok := obj[key]; !ok {
t.Errorf("key %q missing — ActorStatuses is nested, not flattened: %s", key, raw)
}
}
if _, nested := obj["ActorStatuses"]; nested {
t.Errorf("ActorStatuses serialized as a nested object: %s", raw)
}
}
// A statuses_json blob written before the split must decode unchanged.
func TestCombatStatuses_DecodesPreSplitRow(t *testing.T) {
const preSplit = `{"poison_ticks":3,"poison_dmg":6,"enraged":true,` +
`"ward_charges":2,"lucky_used":true,"enemy_retaliate_frac":0.25,` +
`"buff_ac_bonus":4,"max_hp_drain":7}`
var s CombatStatuses
if err := json.Unmarshal([]byte(preSplit), &s); err != nil {
t.Fatal(err)
}
if s.PoisonTicks != 3 || s.PoisonDmg != 6 || s.WardCharges != 2 ||
s.LuckyUsed != true || s.BuffACBonus != 4 || s.MaxHPDrain != 7 {
t.Errorf("per-actor fields lost: %+v", s.ActorStatuses)
}
if !s.Enraged || s.EnemyRetaliateFrac != 0.25 {
t.Errorf("fight-scoped fields lost: %+v", s)
}
}
// ── Solo stays solo ─────────────────────────────────────────────────────────
// The whole balance corpus rides the solo path. It must write no participant
// rows, and its roster_size must stay at the DEFAULT so the loader never issues
// the second query (project_combat_session_cache_deferred: don't make it worse).
func TestSoloSessionWritesNoParticipantRows(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@solo-noparts:example.org")
defer cleanupCombatSessions(uid)
s, err := startCombatSession(uid, "run-solo", "node-1", "owlbear", 40, 40, 60, 60)
if err != nil {
t.Fatal(err)
}
if err := saveCombatSession(s); err != nil {
t.Fatal(err)
}
var rows int
if err := db.Get().QueryRow(
`SELECT COUNT(*) FROM combat_participant WHERE session_id = ?`, s.SessionID,
).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 0 {
t.Errorf("solo fight wrote %d participant rows, want 0", rows)
}
var rosterSize int
if err := db.Get().QueryRow(
`SELECT roster_size FROM combat_session WHERE session_id = ?`, s.SessionID,
).Scan(&rosterSize); err != nil {
t.Fatal(err)
}
if rosterSize != 1 {
t.Errorf("roster_size = %d, want 1", rosterSize)
}
got, err := getActiveCombatSession(uid)
if err != nil || got == nil {
t.Fatalf("getActiveCombatSession: %v / %v", got, err)
}
if got.IsParty() || got.RosterSize() != 1 || len(got.Participants) != 0 {
t.Errorf("solo session reads back as a party: %+v", got.Participants)
}
}
// ── Party seats ─────────────────────────────────────────────────────────────
func seatParty(t *testing.T, s *CombatSession, members ...CombatParticipant) {
t.Helper()
if err := insertCombatParticipants(s.SessionID, members); err != nil {
t.Fatalf("insertCombatParticipants: %v", err)
}
s.Participants = members
}
func TestCombatParticipants_RoundTrip(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@party-lead:example.org")
defer cleanupCombatSessions(uid)
s, err := startCombatSession(uid, "run-party", "node-1", "owlbear", 40, 40, 200, 200)
if err != nil {
t.Fatal(err)
}
seatParty(t, s,
CombatParticipant{Seat: 1, UserID: "@b:x", HP: 30, HPMax: 30,
Statuses: ActorStatuses{WardCharges: 2}},
CombatParticipant{Seat: 2, UserID: "@c:x", HP: 25, HPMax: 25,
Statuses: ActorStatuses{ConcentrationDmg: 9, LuckyUsed: true}},
)
got, err := getActiveCombatSession(uid)
if err != nil || got == nil {
t.Fatalf("getActiveCombatSession: %v / %v", got, err)
}
if !got.IsParty() || got.RosterSize() != 3 {
t.Fatalf("roster = %d, want 3", got.RosterSize())
}
if got.Participants[0].UserID != "@b:x" || got.Participants[0].Statuses.WardCharges != 2 {
t.Errorf("seat 1 round-trip wrong: %+v", got.Participants[0])
}
if got.Participants[1].Statuses.ConcentrationDmg != 9 || !got.Participants[1].Statuses.LuckyUsed {
t.Errorf("seat 2 round-trip wrong: %+v", got.Participants[1])
}
if want := []string{string(uid), "@b:x", "@c:x"}; !equalStrings(got.SeatUserIDs(), want) {
t.Errorf("SeatUserIDs = %v, want %v", got.SeatUserIDs(), want)
}
// Mutable half writes back; hp_max and user_id do not move.
got.Participants[0].HP = 11
got.Participants[0].Statuses.WardCharges = 0
got.Participants[0].Statuses.Raged = true
if err := saveCombatSession(got); err != nil {
t.Fatal(err)
}
again, err := getCombatSession(s.SessionID)
if err != nil || again == nil {
t.Fatalf("getCombatSession: %v / %v", again, err)
}
p := again.Participants[0]
if p.HP != 11 || p.HPMax != 30 || p.Statuses.WardCharges != 0 || !p.Statuses.Raged {
t.Errorf("seat 1 save round-trip wrong: %+v", p)
}
}
// A gap in the seat sequence would shift every member down one index, because
// the engine addresses the roster positionally. Fail the load instead.
func TestLoadCombatParticipants_RejectsSeatGap(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@party-gap:example.org")
defer cleanupCombatSessions(uid)
s, err := startCombatSession(uid, "run-gap", "node-1", "owlbear", 40, 40, 200, 200)
if err != nil {
t.Fatal(err)
}
// Seat 2 with no seat 1.
if err := insertCombatParticipants(s.SessionID, []CombatParticipant{
{Seat: 2, UserID: "@c:x", HP: 25, HPMax: 25},
}); err != nil {
t.Fatal(err)
}
if _, err := loadCombatParticipants(s.SessionID); err == nil {
t.Error("loadCombatParticipants accepted a seat gap")
}
}
// roster_size disagreeing with the persisted seats means a half-written fight.
// Reading it as solo would silently drop the party mid-combat.
func TestHydrateCombatParticipants_RejectsRosterMismatch(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@party-mismatch:example.org")
defer cleanupCombatSessions(uid)
s, err := startCombatSession(uid, "run-mm", "node-1", "owlbear", 40, 40, 200, 200)
if err != nil {
t.Fatal(err)
}
seatParty(t, s, CombatParticipant{Seat: 1, UserID: "@b:x", HP: 30, HPMax: 30})
if _, err := db.Get().Exec(
`UPDATE combat_session SET roster_size = 3 WHERE session_id = ?`, s.SessionID,
); err != nil {
t.Fatal(err)
}
if _, err := getActiveCombatSession(uid); err == nil {
t.Error("hydrate accepted roster_size 3 with 2 seats persisted")
}
}
func TestGetActiveCombatSessionForMember(t *testing.T) {
setupEmptyTestDB(t)
lead := id.UserID("@lead-lookup:example.org")
member := id.UserID("@member-lookup:example.org")
defer cleanupCombatSessions(lead)
s, err := startCombatSession(lead, "run-lookup", "node-1", "owlbear", 40, 40, 200, 200)
if err != nil {
t.Fatal(err)
}
seatParty(t, s, CombatParticipant{Seat: 1, UserID: string(member), HP: 30, HPMax: 30})
// The member owns no session row, so the seat-0 lookup must miss them...
if got, err := getActiveCombatSession(member); err != nil || got != nil {
t.Errorf("getActiveCombatSession found a row for a party member: %v / %v", got, err)
}
// ...and the member lookup must find the fight, fully hydrated.
got, err := getActiveCombatSessionForMember(member)
if err != nil || got == nil {
t.Fatalf("getActiveCombatSessionForMember: %v / %v", got, err)
}
if got.SessionID != s.SessionID || got.RosterSize() != 2 {
t.Errorf("wrong session for member: %+v", got)
}
// The leader is not a participant row, so the member lookup must miss them.
if got, err := getActiveCombatSessionForMember(lead); err != nil || got != nil {
t.Errorf("member lookup matched the leader: %v / %v", got, err)
}
}
// ── The engine writes every seat, not just the cursor ───────────────────────
// P3 shipped with seats 1+ opening fresh from their Mods on every resume: a
// party member's once-per-fight one-shots rearmed every single engine step. P4
// is what fixes that, so pin it. A round_end step mutates no one-shot, so an
// exact round-trip is the assertion.
func TestTurnEngine_PartySeatStatusesSurviveAStep(t *testing.T) {
sess := turnSession(CombatPhaseRoundEnd, 50, 50)
sess.Participants = []CombatParticipant{{
Seat: 1, UserID: "@b:x", HP: 30, HPMax: 30,
Statuses: ActorStatuses{
Raged: true, LuckyUsed: true, DeathSaveUsed: true,
WardCharges: 2, ArcaneWardHP: 8,
BuffACBonus: 3, // command-layer owned; commit must not zero it
},
}}
want := sess.Participants[0].Statuses
a, b, enemy := basePlayer(), basePlayer(), baseEnemy()
rng := rand.New(rand.NewPCG(42, phaseOrdinal(sess.Phase)))
te := resumeTurnEngine(sess, []*Combatant{&a, &b}, &enemy, rng)
if _, err := te.step(PlayerAction{}); err != nil {
t.Fatal(err)
}
te.commit()
if got := sess.Participants[0].Statuses; got != want {
t.Errorf("seat 1 statuses moved across a step:\n got %+v\nwant %+v", got, want)
}
if sess.Participants[0].HP != 30 {
t.Errorf("seat 1 HP = %d, want 30", sess.Participants[0].HP)
}
}
// commit reads each seat by index, never off the cursor — round_end parks the
// cursor at seat 0, and the enemy turn parks it on its target. A seat's damage
// must land on that seat's row.
func TestTurnEngine_CommitWritesSeatsByIndex(t *testing.T) {
sess := turnSession(CombatPhaseRoundEnd, 50, 50)
sess.Participants = []CombatParticipant{
{Seat: 1, UserID: "@b:x", HP: 30, HPMax: 30, Statuses: ActorStatuses{PoisonTicks: 1, PoisonDmg: 4}},
{Seat: 2, UserID: "@c:x", HP: 25, HPMax: 25},
}
a, b, c, enemy := basePlayer(), basePlayer(), basePlayer(), baseEnemy()
rng := rand.New(rand.NewPCG(42, phaseOrdinal(sess.Phase)))
te := resumeTurnEngine(sess, []*Combatant{&a, &b, &c}, &enemy, rng)
if _, err := te.step(PlayerAction{}); err != nil {
t.Fatal(err)
}
te.commit()
// Seat 1's poison ticked on seat 1 alone.
if sess.Participants[0].HP != 26 {
t.Errorf("seat 1 HP = %d, want 26 (30 - 4 poison)", sess.Participants[0].HP)
}
if sess.Participants[0].Statuses.PoisonTicks != 0 {
t.Errorf("seat 1 poison ticks = %d, want 0", sess.Participants[0].Statuses.PoisonTicks)
}
if sess.Participants[1].HP != 25 {
t.Errorf("seat 2 HP = %d, want 25 — seat 1's poison leaked", sess.Participants[1].HP)
}
if sess.PlayerHP != 50 {
t.Errorf("seat 0 HP = %d, want 50 — seat 1's poison leaked", sess.PlayerHP)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View File

@@ -0,0 +1,183 @@
package plugin
import (
"fmt"
"log/slog"
"strings"
"maunium.net/go/mautrix/id"
)
// N3/P5 — closing out a party fight.
//
// finishCombatSession (combat_cmd.go) is the solo close-out and stays exactly
// what it was. This is its sibling for a seated roster, and the split it makes
// is the one the data model already forced:
//
// - Run- and expedition-scoped effects fire ONCE, for the owner. Threat, the
// zone-kill record, the boss-defeat threat drop, the run teardown on a loss
// — every one of them resolves through getActiveExpedition(userID) or
// getActiveZoneRun(userID), and a party member owns neither row. Fanning
// them out would be a no-op for members and would triple the threat the
// owner pays for a single kill.
// - Character-scoped effects fan out. HP, XP, loot, and death belong to the
// person, not the expedition, and every seat gets their own.
//
// A member can be dead in a fight the party won: they dropped, the others
// finished the job. So death is decided per seat off HP, not off the session's
// terminal status.
// finishPartyCombatSession runs the terminal side effects for a seated roster
// and returns each seat's own player-facing close-out, indexed by seat. A solo
// session delegates to finishCombatSession untouched.
func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
sess, enemy := ct.sess, *ct.enemy
owner := id.UserID(sess.UserID)
if !ct.isParty() {
return []string{p.finishCombatSession(owner, sess, enemy)}
}
run, _ := getZoneRun(sess.RunID)
var zone ZoneDefinition
elite := true
cadence := sess.Round
if run != nil {
zone = zoneOrFallback(run.ZoneID)
elite = run.CurrentRoomType() != RoomBoss
cadence = narrationCadence(run)
}
monster := dndBestiary[sess.EnemyID]
// nat20/nat1 mood deltas from the whole fight's event log — the run's, so once.
scanMoodEventsFromEvents(sess.RunID, sess.TurnLog)
// Every seat's HP lands on their sheet regardless of how the fight ended.
for seat := range sess.RosterSize() {
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
}
switch sess.Status {
case CombatStatusWon:
return p.finishPartyWin(ct, run, zone, monster, elite, cadence)
case CombatStatusLost:
return p.finishPartyLoss(ct, zone, cadence)
case CombatStatusFled:
_ = abandonZoneRun(owner)
forceExtractExpeditionForRunLoss(owner, "combat flee")
return p.eachSeat(ct, fmt.Sprintf(
"🏃 The party broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name))
default:
return p.eachSeat(ct, "The fight is over.")
}
}
// finishPartyWin pays the party out. The kill is the expedition's, so threat and
// the zone-kill record resolve once through the owner; XP and loot are the
// character's, so every member rolls their own — loot independently, per the C1
// no-split rule, and XP in full, per accessibility over crunch.
func (p *AdventurePlugin) finishPartyWin(
ct *combatTurn, run *DungeonRun, zone ZoneDefinition, monster DnDMonsterTemplate, elite bool, cadence int,
) []string {
sess := ct.sess
owner := id.UserID(sess.UserID)
recordZoneKillForUser(owner, sess.EnemyID)
applyRoomCombatThreatForUser(owner, elite)
bossOnExpedition := false
if !elite {
if exp, eerr := getActiveExpedition(owner); eerr == nil && exp != nil {
_ = applyBossDefeatThreat(exp.ID)
bossOnExpedition = true
}
}
tier := 1
if run != nil {
tier = int(zone.Tier)
}
shared := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence)
emoji := "✅"
if !elite {
emoji = "🏆"
}
out := make([]string, sess.RosterSize())
for seat := range sess.RosterSize() {
uid := id.UserID(sess.seatUserID(seat))
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
// A member who went down before the killing blow still earns the kill —
// but at a fifth of their pool or less, the XP path calls it near-death.
nearDeath := hpMax > 0 && hp*5 < hpMax
if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 {
if _, err := p.grantDnDXP(uid, xp); err != nil {
slog.Error("combat: party grantDnDXP", "user", uid, "err", err)
}
}
var b strings.Builder
if shared != "" && seat == 0 {
b.WriteString(shared + "\n")
}
b.WriteString(fmt.Sprintf("%s **%s** down. The party stands.\n", emoji, ct.enemy.Name))
if hp <= 0 {
// They won it from the floor. Down is down: the hospital takes them.
markAdventureDead(uid, "zone", zone.Display)
b.WriteString("💀 You didn't see it fall — you were already down.\n")
} else {
b.WriteString(fmt.Sprintf("You finished at **%d/%d HP**.\n", hp, hpMax))
if drop := p.dropZoneLoot(uid, zone.ID, monster, !elite, elite); drop != "" {
b.WriteString(drop + "\n")
}
}
switch {
case bossOnExpedition && seat == 0:
b.WriteString("🎉 **Zone cleared — the expedition is won.** `!expedition run` to march out and claim your spoils.")
case bossOnExpedition:
b.WriteString("🎉 **Zone cleared — the expedition is won.** Your leader marches the party out.")
case seat == 0:
b.WriteString(continueHint(owner))
default:
b.WriteString("Waiting on your leader to move the party on.")
}
out[seat] = b.String()
}
return out
}
// finishPartyLoss ends the run for everyone. The whole roster is down — the turn
// engine only reports Lost once anyAlive() goes false — so every member takes
// the death, and the owner's run and expedition are torn down once.
func (p *AdventurePlugin) finishPartyLoss(ct *combatTurn, zone ZoneDefinition, cadence int) []string {
sess := ct.sess
owner := id.UserID(sess.UserID)
if run, _ := getZoneRun(sess.RunID); run != nil {
_, _ = applyMoodEvent(sess.RunID, MoodEventPlayerDeath)
}
_ = abandonZoneRun(owner)
forceExtractExpeditionForRunLoss(owner, "combat death")
line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence)
out := make([]string, sess.RosterSize())
for seat := range sess.RosterSize() {
markAdventureDead(id.UserID(sess.seatUserID(seat)), "zone", zone.Display)
var b strings.Builder
if line != "" && seat == 0 {
b.WriteString(line + "\n")
}
b.WriteString(fmt.Sprintf("💀 The party fell to **%s**. Run ended.", ct.enemy.Name))
out[seat] = b.String()
}
return out
}
// eachSeat gives every seat the same close-out block.
func (p *AdventurePlugin) eachSeat(ct *combatTurn, block string) []string {
out := make([]string, ct.sess.RosterSize())
for i := range out {
out[i] = block
}
return out
}

View File

@@ -0,0 +1,196 @@
package plugin
import (
"fmt"
"log/slog"
"strings"
"maunium.net/go/mautrix/id"
)
// N3/P6c — seating the party at the doorway.
//
// P5 built startPartyCombatSession and left it with no production caller: there
// was no roster to seat it with. P6b filled the roster. This is the join.
//
// The rule the whole file turns on is that **seat 0 is the expedition leader**.
// Every seat-0 invariant P4 and P5 laid down rests on it: combat_session.user_id
// is the fight's lock key, the run- and expedition-scoped close-out effects fire
// once through it, and `!flee` is refused to any seat but zero. A party whose
// seat 0 were merely "whoever typed !fight" would flee the leader's run on a
// member's say-so.
// fightRoster is the seating order for a fight opened on this run: the
// expedition's leader first, then their party in join order. A bare zone run —
// and a solo expedition, whose roster table is empty — resolves to the one
// player who owns it.
//
// It doubles as the answer to "under whose lock is this fight taken", since
// roster[0] is the session's owner. It is resolved *before* the lock, so it
// must not touch getActiveZoneRun — that lookup carries the §4.3 idle reap.
func fightRoster(sender id.UserID) []id.UserID {
e, _, err := activeExpeditionFor(sender)
if err != nil || e == nil {
return []id.UserID{sender}
}
return expeditionAudience(e)
}
// buildFightSeats turns a roster into the seats that will actually sit down, and
// the enemy they face. A member who is down, or somehow already fighting, is left
// out: they sit this one out rather than blocking the party. The leader is not
// optional — if seat 0 cannot fight, nobody does, and `refusal` says why in terms
// of whoever typed `!fight`.
//
// senderSkip is the sender's own reason for being left out, empty when they are
// seated. Without it a downed member's `!fight` opens the party's fight and then
// answers them with silence.
//
// The enemy is built once. Every seat's build derives the identical stat block
// from (monster, tier, dmMood); only the player half varies.
func (p *AdventurePlugin) buildFightSeats(
sender id.UserID, roster []id.UserID, monster DnDMonsterTemplate, tier, dmMood int,
) (seats []CombatSeatSetup, enemy *Combatant, senderSkip, refusal string) {
skip := func(uid id.UserID, why string) {
if uid == sender {
senderSkip = why
}
}
for i, uid := range roster {
leader := i == 0
player, e, _, err := p.buildZoneCombatants(uid, monster, tier, dmMood)
if err != nil {
if leader {
return nil, nil, "", "Couldn't set up the fight: " + err.Error()
}
slog.Warn("combat: party member left out of fight", "user", uid, "err", err)
skip(uid, "Couldn't bring you into the fight: "+err.Error())
continue
}
if leader {
enemy = &e
}
hp, hpMax := dndHPSnapshot(uid)
if hp <= 0 {
if leader {
return nil, nil, "", seatZeroRefusal(sender, uid,
"You're in no shape to fight. `!rest` first.",
"Your party leader is in no shape to fight. The fight waits for them.")
}
skip(uid, "You're in no shape to fight — the party goes in without you. `!rest` when you can.")
continue
}
// A member's live fight lives on a combat_participant row, so
// hasActiveCombatSession — which keys on combat_session.user_id — would
// answer "no" for them mid-fight. The caller has already ruled out this
// room's own encounter, so anything found here is a different fight.
if s, serr := activeCombatSessionFor(uid); serr == nil && s != nil {
if leader {
return nil, nil, "", seatZeroRefusal(sender, uid,
"You're already in a fight. Finish it with `!attack` / `!flee`.",
"Your party leader is already in a fight somewhere else.")
}
slog.Info("combat: party member busy in another fight", "user", uid, "session", s.SessionID)
skip(uid, "You're already in a fight elsewhere — the party goes in without you.")
continue
}
seats = append(seats, CombatSeatSetup{UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player})
}
return seats, enemy, senderSkip, ""
}
// seatCombatants is the roster's freshly-built characters in seating order — the
// same slice partyCombatantsForSession would rebuild from the persisted session,
// threaded through from the build that seated them instead.
func seatCombatants(seats []CombatSeatSetup) []*Combatant {
players := make([]*Combatant, len(seats))
for i, s := range seats {
players[i] = s.C
}
return players
}
// seatZeroRefusal picks the copy for a blocked seat 0. The leader hears about
// themselves in the second person; a member hears who the party is waiting on.
func seatZeroRefusal(sender, leader id.UserID, own, aboutLeader string) string {
if sender == leader {
return own
}
return aboutLeader
}
// announcePartyFightStart DMs every seated member the opening block: the shared
// header, their own HP and curios, the roster's initiative order, whatever the
// enemy did before anyone could stop it, and either "your move" or who the round
// is waiting on.
//
// opening carries the events of a round-1 enemy turn — the monster can win
// initiative, and a party that reads "your move" over an unnarrated 12-point hit
// has been lied to. outcomes, when set, is the per-seat close-out of a fight that
// ended before it started.
//
// Solo does not come through here — handleFightCmd answers the one player who
// typed, with the bytes it always sent.
func (p *AdventurePlugin) announcePartyFightStart(
sess *CombatSession, players []*Combatant, enemy *Combatant, header string,
opening []CombatEvent, outcomes []string,
) {
acting, waiting := actingSeat(sess, players, enemy)
names := make([]string, len(players))
for i, c := range players {
names[i] = c.Name
}
for seat, uid := range sess.SeatUserIDs() {
var b strings.Builder
b.WriteString(header)
b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", sess.seatHP(seat), sess.seatHPMax(seat)))
if curios := activeMagicItemsLine(id.UserID(uid)); curios != "" {
b.WriteString(curios + "\n")
}
b.WriteString("\n**Marching order:** ")
b.WriteString(strings.Join(initiativeNames(players, sess, enemy), " → "))
b.WriteString("\n\n")
if len(opening) > 0 {
b.WriteString(RenderPartyTurnRound(opening, names, enemy.Name, seat))
b.WriteString("\n\n")
}
switch {
case seat < len(outcomes):
b.WriteString(outcomes[seat])
case !waiting:
b.WriteString(fmt.Sprintf("**Round %d.** The round is resolving.", sess.Round))
case seat == acting:
b.WriteString(partyMovePrompt(sess.Round))
default:
b.WriteString(fmt.Sprintf("**Round %d.** Waiting on **%s**.", sess.Round, players[acting].Name))
}
if err := p.SendDM(id.UserID(uid), b.String()); err != nil {
slog.Error("combat: party fight-start DM failed", "user", uid, "err", err)
}
}
}
// partyMovePrompt is the line a seat sees when the round is its own. `!flee` is
// missing on purpose: in a party it is the leader's call, and P5 refuses it to
// every other seat.
func partyMovePrompt(round int) string {
return fmt.Sprintf("**Round %d.** Your move — `!attack`, `!cast <spell>`, `!consume <item>`.", round)
}
// initiativeNames renders this round's turn order for the opening block. It is
// the party's one look at the initiative P3 rolls for them — the number itself
// stays hidden (accessibility over crunch); the order is the useful part.
func initiativeNames(players []*Combatant, sess *CombatSession, enemy *Combatant) []string {
order := turnOrder(sess, sess.Round, players, enemy)
names := make([]string, 0, len(order))
for _, seat := range order {
if seat == enemySeat {
names = append(names, enemy.Name)
continue
}
names = append(names, players[seat].Name)
}
return names
}

View File

@@ -0,0 +1,308 @@
package plugin
import (
"strings"
"testing"
"maunium.net/go/mautrix/id"
)
// N3/P6c — seating the roster, and paying for it.
//
// The invariant every test here defends is the same one: a solo player must be
// unable to tell that parties exist. The seat-0 owner, the burn rate, and the
// opening block are all shapes a solo fight has already written thousands of
// times, and the balance corpus is the receipt.
// ── who owns the fight ───────────────────────────────────────────────────────
// roster[0] is the fight's owner: its lock key and its session row.
// A bare zone run — no expedition row anywhere — must not fall over looking for
// a leader. This is the `!zone enter` path, which predates expeditions entirely.
func TestFightRoster_BareZoneRunOwnsItsOwnFight(t *testing.T) {
setupEmptyTestDB(t)
wanderer := id.UserID("@wanderer:example.org")
if got := fightRoster(wanderer); len(got) != 1 || got[0] != wanderer {
t.Errorf("fightRoster(wanderer) = %v, want just the player themselves", got)
}
}
// The reason the lookup exists: a member's `!fight` opens the *leader's* fight,
// under the leader's lock and on the leader's session row.
func TestFightRoster_MembersFightBelongsToTheLeader(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
member := id.UserID("@member:example.org")
seedExpedition(t, "exp-1", leader, "active")
if err := joinParty("exp-1", member); err != nil {
t.Fatal(err)
}
for _, who := range []id.UserID{member, leader} {
if got := fightRoster(who)[0]; got != leader {
t.Errorf("fightRoster(%s)[0] = %q, want the leader %q", who, got, leader)
}
}
}
// ── who sits down ────────────────────────────────────────────────────────────
func TestFightRoster_SoloSeatsExactlyOne(t *testing.T) {
setupEmptyTestDB(t)
solo := id.UserID("@solo:example.org")
seedExpedition(t, "exp-solo", solo, "active")
roster := fightRoster(solo)
if len(roster) != 1 || roster[0] != solo {
t.Fatalf("solo roster = %v, want [%s]", roster, solo)
}
}
// Seat 0 is the leader whoever typed `!fight`. Every seat-0 invariant in the
// combat layer — the lock key, the once-only close-out effects, the leader-only
// `!flee` — reads the roster's head, so a member-first ordering would flee the
// leader's run on a member's say-so.
func TestFightRoster_LeaderIsAlwaysSeatZero(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
member := id.UserID("@member:example.org")
seedExpedition(t, "exp-1", leader, "active")
if err := joinParty("exp-1", member); err != nil {
t.Fatal(err)
}
for _, who := range []id.UserID{leader, member} {
roster := fightRoster(who)
if len(roster) != 2 {
t.Fatalf("fightRoster(%s) = %v, want 2 seats", who, roster)
}
if roster[0] != leader {
t.Errorf("fightRoster(%s) seats %q at 0, want the leader", who, roster[0])
}
}
}
// ── who actually gets a seat ─────────────────────────────────────────────────
// fightTestChar creates the full character stack buildZoneCombatants reads:
// the adventure character, then the D&D sheet its HP snapshot comes from.
func fightTestChar(t *testing.T, uid id.UserID, hp int) {
t.Helper()
if err := createAdvCharacter(uid, string(uid)); err != nil {
t.Fatal(err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5,
STR: 14, DEX: 12, CON: 14, INT: 10, WIS: 10, CHA: 10,
HPMax: 30, HPCurrent: hp, ArmorClass: 14,
}
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
}
func TestBuildFightSeats_SoloSeatsExactlyThePlayer(t *testing.T) {
setupEmptyTestDB(t)
solo := id.UserID("@solo:example.org")
fightTestChar(t, solo, 30)
seats, enemy, skip, refusal := (&AdventurePlugin{}).buildFightSeats(
solo, []id.UserID{solo}, dndBestiary["goblin"], 1, 0)
if refusal != "" || skip != "" {
t.Fatalf("solo fight refused: %s / %s", refusal, skip)
}
if len(seats) != 1 || seats[0].UserID != solo {
t.Fatalf("seats = %+v, want exactly the solo player", seats)
}
if seats[0].HP != 30 || seats[0].HPMax != 30 {
t.Errorf("seat HP = %d/%d, want 30/30", seats[0].HP, seats[0].HPMax)
}
if seats[0].C == nil || seats[0].C.Name == "" {
t.Errorf("seat carries no built combatant: %+v", seats[0])
}
if enemy == nil || enemy.Name == "" || enemy.Stats.MaxHP <= 0 {
t.Errorf("enemy not built: %+v", enemy)
}
}
// A downed member is not a blocked party: they sit the fight out, and the seats
// that remain still line up leader-first.
func TestBuildFightSeats_DownedMemberSitsOut(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
standing := id.UserID("@standing:example.org")
downed := id.UserID("@downed:example.org")
fightTestChar(t, leader, 30)
fightTestChar(t, standing, 25)
fightTestChar(t, downed, 0)
roster := []id.UserID{leader, downed, standing}
seats, _, skip, refusal := (&AdventurePlugin{}).buildFightSeats(
leader, roster, dndBestiary["goblin"], 1, 0)
if refusal != "" {
t.Fatalf("party refused over a downed member: %s", refusal)
}
if skip != "" {
t.Errorf("the leader was seated, so nothing was skipped on their behalf: %q", skip)
}
if len(seats) != 2 {
t.Fatalf("seated %d, want the leader and the standing member", len(seats))
}
if seats[0].UserID != leader || seats[1].UserID != standing {
t.Errorf("seats = [%s %s], want [leader standing]", seats[0].UserID, seats[1].UserID)
}
// The one who was left behind typed `!fight` too, and silence is not an answer.
_, _, skip, refusal = (&AdventurePlugin{}).buildFightSeats(
downed, roster, dndBestiary["goblin"], 1, 0)
if refusal != "" {
t.Fatalf("a downed member must not refuse the party's fight: %s", refusal)
}
if !strings.Contains(skip, "`!rest`") {
t.Errorf("the downed member should be told to rest, got %q", skip)
}
}
// Seat 0 is not optional, and the copy depends on who is reading it.
func TestBuildFightSeats_DownedLeaderRefusesTheFightForEveryone(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
member := id.UserID("@member:example.org")
fightTestChar(t, leader, 0)
fightTestChar(t, member, 25)
roster := []id.UserID{leader, member}
p := &AdventurePlugin{}
seats, _, _, refusal := p.buildFightSeats(leader, roster, dndBestiary["goblin"], 1, 0)
if len(seats) != 0 || refusal == "" {
t.Fatalf("downed leader seated %d players, refusal %q", len(seats), refusal)
}
if !strings.Contains(refusal, "`!rest`") {
t.Errorf("the leader should be told to rest, got %q", refusal)
}
_, _, _, refusal = p.buildFightSeats(member, roster, dndBestiary["goblin"], 1, 0)
if !strings.Contains(refusal, "leader") {
t.Errorf("the member should be told it is the leader holding things up, got %q", refusal)
}
if strings.Contains(refusal, "`!rest`") {
t.Errorf("the member cannot rest on the leader's behalf, got %q", refusal)
}
}
// ── the opening block ────────────────────────────────────────────────────────
// initiativeNames walks the turn order, which carries the enemy as sentinel seat
// -1. Indexing players with it would panic on every party's first `!fight`.
func TestInitiativeNames_RendersTheEnemySentinelNotAPanic(t *testing.T) {
players, enemy := biasedParty()
sess := partySession(CombatPhasePlayerTurn, 100, 100, 100)
names := initiativeNames(players, sess, enemy)
if len(names) != 4 {
t.Fatalf("order = %v, want 3 players + the enemy", names)
}
// biasedParty stacks initiative 300/200/100 against a speed-1 enemy.
if want := []string{"Ada", "Bram", "Cass", enemy.Name}; strings.Join(names, ",") != strings.Join(want, ",") {
t.Errorf("order = %v, want %v", names, want)
}
}
func TestInitiativeNames_SoloIsPlayerThenEnemy(t *testing.T) {
p := basePlayer()
e := baseEnemy()
sess := turnSession(CombatPhasePlayerTurn, 100, 100)
if got := initiativeNames([]*Combatant{&p}, sess, &e); len(got) != 2 || got[1] != e.Name {
t.Errorf("solo order = %v, want [player, enemy]", got)
}
}
// ── what the party eats ──────────────────────────────────────────────────────
// The rate a solo expedition burns at is a tuned constant that the whole
// difficulty corpus (Phase 3-B / 5-B) was measured against. It must come out of
// the party-aware path untouched.
func TestExpeditionBurnRatePct_SoloIsTheShippedRate(t *testing.T) {
setupEmptyTestDB(t)
solo := id.UserID("@solo:example.org")
seedExpedition(t, "exp-solo", solo, "active")
if got := expeditionBurnRatePct("exp-solo"); got != phase5BDailyBurnRatePct {
t.Errorf("solo burn rate = %d, want the shipped %d", got, phase5BDailyBurnRatePct)
}
// An expedition with no roster row at all is the pre-N3 world: same answer.
if got := expeditionBurnRatePct("exp-never-seen"); got != phase5BDailyBurnRatePct {
t.Errorf("rosterless burn rate = %d, want the shipped %d", got, phase5BDailyBurnRatePct)
}
}
// N × 0.8: a party eats more than one player and less than N of them.
func TestExpeditionBurnRatePct_PartyEatsMoreButNotProRata(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
seedExpedition(t, "exp-1", leader, "active")
want := map[int]int{1: 50, 2: 80, 3: 120}
for seat := 1; seat <= 2; seat++ {
if err := joinParty("exp-1", id.UserID(memberID(seat))); err != nil {
t.Fatal(err)
}
size := seat + 1
if got := expeditionBurnRatePct("exp-1"); got != want[size] {
t.Errorf("party of %d burns at %d%%, want %d%%", size, got, want[size])
}
}
}
// Bit-identity, not approximate agreement: a solo expedition's supplies snapshot
// after the party-aware burn must equal the one applyDailyBurn produced.
func TestApplyExpeditionDailyBurn_SoloMatchesApplyDailyBurnExactly(t *testing.T) {
setupEmptyTestDB(t)
solo := id.UserID("@solo:example.org")
seedExpedition(t, "exp-solo", solo, "active")
supplies := ExpeditionSupplies{Current: 40, Max: 40, DailyBurn: 3, HarshMod: 1.5}
e := &Expedition{ID: "exp-solo", UserID: string(solo), Supplies: supplies}
for _, tc := range []struct{ harsh, siege bool }{{false, false}, {true, false}, {false, true}} {
wantS, wantBurn := applyDailyBurn(supplies, tc.harsh, tc.siege)
gotS, gotBurn := applyExpeditionDailyBurn(e, tc.harsh, tc.siege)
if gotBurn != wantBurn || gotS != wantS {
t.Errorf("harsh=%v siege=%v: got (%v, %g), want (%v, %g)",
tc.harsh, tc.siege, gotS, gotBurn, wantS, wantBurn)
}
}
}
func TestApplyExpeditionDailyBurn_PartyOfThreeBurnsTwoPointFourShares(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
seedExpedition(t, "exp-1", leader, "active")
for seat := 1; seat <= 2; seat++ {
if err := joinParty("exp-1", id.UserID(memberID(seat))); err != nil {
t.Fatal(err)
}
}
supplies := ExpeditionSupplies{Current: 40, Max: 40, DailyBurn: 3, HarshMod: 1}
e := &Expedition{ID: "exp-1", UserID: string(leader), Supplies: supplies}
_, solo := applyDailyBurn(supplies, false, false)
_, party := applyExpeditionDailyBurn(e, false, false)
// DailyBurn 3 at the party-of-3 rate of 120% — exactly 2.4 solo shares, and
// exactly the value the int rate yields. A float32 0.8 would land at 3.6000001.
if want := float32(3.6); party != want {
t.Errorf("party of 3 burned %v SU, want %v", party, want)
}
if party >= solo*3 {
t.Errorf("party of 3 burned %g, which is no better than three solo runs (%g)", party, solo*3)
}
if party <= solo {
t.Errorf("party of 3 burned %g, no more than one player alone (%g)", party, solo)
}
}

View File

@@ -0,0 +1,424 @@
package plugin
import (
"fmt"
"log/slog"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// N3/P5 — the party turn layer.
//
// A solo fight is a conversation between one player and the engine: they type,
// it answers, and nothing happens in between. A party fight is a queue. Three
// things follow from that, and this file is all three:
//
// 1. Turn ownership. Only the seat on the clock may act, so every combat
// command has to ask "is it mine?" before it spends a slot or burns an item.
// 2. A fight-scoped lock. Three members typing `!attack` at once take three
// *different* user locks, and the check above would pass for all three.
// 3. A turn deadline. One member who wanders off must not freeze the other two
// until the 1h session reaper wakes up.
//
// Solo pays for none of it: the fight lock collapses to the user lock it always
// took, the turn check is trivially true, and nothing latches a solo seat onto
// autopilot — a lone player who walks away is the session reaper's problem, as
// they have always been.
// partyTurnDeadline is how long a party fight waits on one member before
// resolving their turn for them. Swept by the existing one-minute eventTicker
// (D4: no net-new tickers), so a lapse actually fires somewhere in
// [deadline, deadline+1m) — 34 minutes here.
//
// The number is a compromise between two failure modes. Too short and a player
// reading the room on their phone loses their boss turn; too long and two people
// sit staring at a prompt. Expeditions run for days, so the asymmetry favours
// patience.
const partyTurnDeadline = 3 * time.Minute
// combatTurn is a fight, opened for one member, with that member verified to be
// the seat on the clock. Held only for the duration of one command, under the
// locks release() drops.
type combatTurn struct {
sess *CombatSession
players []*Combatant
enemy *Combatant
seat int
// uid is the member acting, i.e. the player at seat.
uid id.UserID
}
// isParty reports whether more than one character is seated.
func (ct *combatTurn) isParty() bool { return ct.sess.IsParty() }
// seatNames is the roster's display names in seating order, for narration.
func (ct *combatTurn) seatNames() []string {
names := make([]string, len(ct.players))
for i, c := range ct.players {
names[i] = c.Name
}
return names
}
// activeCombatSessionFor finds the in-flight fight a player is in, whether they
// are its owner (seat 0) or a seated member. getActiveCombatSession alone keys
// on combat_session.user_id and so tells a party member they are not fighting.
func activeCombatSessionFor(userID id.UserID) (*CombatSession, error) {
if s, err := getActiveCombatSession(userID); err != nil || s != nil {
return s, err
}
return getActiveCombatSessionForMember(userID)
}
// lockCombatFight takes the locks a combat command needs, in an order that
// cannot deadlock: the fight's lock first — keyed on seat 0, the session's owner
// — and then the acting member's own lock. Every other command in the codebase
// takes at most one user lock, so there is no cycle to close.
//
// A solo fight's owner *is* the sender, and sync.Mutex is not reentrant, so that
// case takes exactly one lock: the same one handleAttackCmd has always taken.
func (p *AdventurePlugin) lockCombatFight(owner, sender id.UserID) func() {
fight := p.advUserLock(owner)
fight.Lock()
if owner == sender {
return fight.Unlock
}
self := p.advUserLock(sender)
self.Lock()
return func() {
self.Unlock()
fight.Unlock()
}
}
// beginCombatTurn opens the sender's fight for an action: it locates the
// session, takes its locks, settles any phase the engine still owes (an enemy
// turn left half-resolved by a crash), rebuilds the roster, and verifies the
// sender is the seat on the clock.
//
// On any refusal it releases what it took and returns a player-facing message
// with a nil turn; noFightMsg is the caller's own copy for "you are not in a
// fight", which differs per command. On success the caller must call release().
//
// Acting also unlatches the member from autopilot: typing anything is proof they
// are back at the keyboard.
func (p *AdventurePlugin) beginCombatTurn(sender id.UserID, noFightMsg string) (*combatTurn, func(), string) {
probe, err := activeCombatSessionFor(sender)
if err != nil {
return nil, nil, "Couldn't read combat state: " + err.Error()
}
if probe == nil {
return nil, nil, noFightMsg
}
release := p.lockCombatFight(id.UserID(probe.UserID), sender)
fail := func(msg string) (*combatTurn, func(), string) {
release()
return nil, nil, msg
}
// Re-read under the lock. The probe was unlocked, so the fight may have
// ended, or been replaced by a fresh one, in the window since.
sess, err := getCombatSession(probe.SessionID)
if err != nil {
return fail("Couldn't read combat state: " + err.Error())
}
if sess == nil || !sess.IsActive() {
return fail(noFightMsg)
}
seat, seated := sess.seatOf(sender)
if !seated {
return fail(noFightMsg)
}
players, enemy, err := p.partyCombatantsForSession(sess)
if err != nil {
return fail("Couldn't rebuild the fight: " + err.Error())
}
// Settle any phase the engine still owes before reading whose turn it is. A
// fight interrupted mid enemy-turn resumes parked there; without this the
// player's !attack would be refused as "not your turn" and the fight would
// never advance past it again.
if _, serr := settleCombatSession(sess, players, enemy); serr != nil {
return fail("Couldn't resolve the round: " + serr.Error())
}
if !sess.IsActive() {
// The owed phase was lethal: the settle above ended the fight. The
// terminal status is already persisted, and the reaper only scans for
// active sessions, so this is the last chance anyone has to pay the
// party out. Close it here rather than answering "you're not in a
// fight" over a win nobody was credited for.
ct := &combatTurn{sess: sess, players: players, enemy: enemy, seat: seat, uid: sender}
outcomes := p.closePartyRound(ct)
if !ct.isParty() {
return fail(outcomes[0])
}
p.announcePartyRound(ct, nil, "", outcomes)
return fail("")
}
acting, waiting := actingSeat(sess, players, enemy)
if !waiting || acting != seat {
return fail(notYourTurnMsg(players, acting, waiting))
}
// They typed, so they are here. Hand back the wheel.
sess.actorStatusesPtr(seat).Autopilot = false
return &combatTurn{sess: sess, players: players, enemy: enemy, seat: seat, uid: sender}, release, ""
}
// notYourTurnMsg tells a member who is holding the round up.
func notYourTurnMsg(players []*Combatant, acting int, waiting bool) string {
if !waiting || acting < 0 || acting >= len(players) {
return "The round is still resolving — try again in a moment."
}
return fmt.Sprintf("It's **%s**'s turn. Hang tight — I'll act for them if they're away.", players[acting].Name)
}
// ── driving a round ──────────────────────────────────────────────────────────
// driveCombatRound resolves the acting member's action, then keeps the fight
// moving until it comes to rest on a player who can actually answer: the enemy
// turn and round-end tick resolve, downed seats forfeit, and seats latched onto
// autopilot are played by the picker.
//
// Solo never enters the autopilot loop — nothing latches a solo seat.
func (p *AdventurePlugin) driveCombatRound(ct *combatTurn, action PlayerAction) ([]CombatEvent, error) {
events, err := runPartyCombatRound(ct.sess, ct.players, ct.enemy, action)
if err != nil {
return events, err
}
more, err := p.driveAutopilotedSeats(ct)
return append(events, more...), err
}
// driveAutopilotedSeats plays out every latched seat standing between the fight
// and its next live human turn.
func (p *AdventurePlugin) driveAutopilotedSeats(ct *combatTurn) ([]CombatEvent, error) {
var events []CombatEvent
for i := 0; i < partyRoundStepCap; i++ {
seat, waiting := actingSeat(ct.sess, ct.players, ct.enemy)
if !waiting || !ct.sess.seatIsAutopiloted(seat) {
return events, nil
}
more, err := p.runAutoSeatTurn(ct, seat)
if err != nil {
return events, err
}
events = append(events, more...)
}
return events, fmt.Errorf("combat session %s: autopilot did not settle within %d turns",
ct.sess.SessionID, partyRoundStepCap)
}
// runAutoSeatTurn resolves one seat's turn with nobody at the keyboard, through
// the same decision tree the headless sim and the expedition autopilot use. A
// cast or consume the picker chose but the resolver then refuses (the slot went
// missing, the item was sold from another room) degrades to a weapon attack
// rather than stalling the round.
func (p *AdventurePlugin) runAutoSeatTurn(ct *combatTurn, seat int) ([]CombatEvent, error) {
uid := id.UserID(ct.sess.seatUserID(seat))
kind, arg := p.pickAutoCombatActionForSeat(uid, ct.sess, seat)
action := PlayerAction{Kind: ActionAttack}
settle := func(bool) {}
switch kind {
case "cast":
if a, s, msg := p.castActionForSeat(ct, seat, arg); msg == "" {
action, settle = a, s
} else {
slog.Debug("combat: autopilot cast refused, swinging instead",
"session", ct.sess.SessionID, "seat", seat, "spell", arg, "why", msg)
}
case "consume":
if a, s, msg := p.consumeActionForSeat(ct, seat, arg); msg == "" {
action, settle = a, s
} else {
slog.Debug("combat: autopilot consume refused, swinging instead",
"session", ct.sess.SessionID, "seat", seat, "item", arg, "why", msg)
}
}
events, err := runPartyCombatRound(ct.sess, ct.players, ct.enemy, action)
settle(err == nil)
return events, err
}
// ── the turn deadline ────────────────────────────────────────────────────────
// nudgeStalledPartyTurns latches every party seat whose turn deadline has lapsed
// onto autopilot and plays the fight forward. Driven off eventTicker, beside the
// session reaper, so it costs no new ticker.
//
// Solo sessions are never listed: a lone player who walks away owns their own
// fight, and the 1h reaper already finishes it for them.
func (p *AdventurePlugin) nudgeStalledPartyTurns() {
stalled, err := listStalledPartyCombatSessions()
if err != nil {
slog.Error("combat: failed to list stalled party turns", "err", err)
return
}
for _, sess := range stalled {
p.nudgeStalledPartyTurn(sess.SessionID)
}
}
// nudgeStalledPartyTurn resolves one stalled fight under its lock. It re-reads
// the session after locking: the member may have acted in the window between the
// sweep's query and the lock, in which case there is nothing to do.
func (p *AdventurePlugin) nudgeStalledPartyTurn(sessionID string) {
probe, err := getCombatSession(sessionID)
if err != nil || probe == nil {
return
}
owner := id.UserID(probe.UserID)
release := p.lockCombatFight(owner, owner)
defer release()
sess, err := getCombatSession(sessionID)
if err != nil {
slog.Error("combat: stalled-turn reload failed", "session", sessionID, "err", err)
return
}
if sess == nil || !sess.IsActive() || !sess.IsParty() || !turnDeadlineLapsed(sess) {
return
}
players, enemy, err := p.partyCombatantsForSession(sess)
if err != nil {
slog.Warn("combat: cannot rebuild stalled party fight", "session", sessionID, "err", err)
return
}
seat, waiting := actingSeat(sess, players, enemy)
if !waiting {
return
}
// Latch the absent member. From here their turns resolve the moment the
// round reaches them, until they type something.
sess.actorStatusesPtr(seat).Autopilot = true
away := id.UserID(sess.seatUserID(seat))
ct := &combatTurn{sess: sess, players: players, enemy: enemy, seat: seat, uid: away}
events, err := p.driveAutopilotedSeats(ct)
if err != nil {
slog.Error("combat: stalled-turn autopilot failed", "session", sessionID, "err", err)
return
}
preamble := fmt.Sprintf("⏳ **%s** was away — I'm taking their turns.\n\n", players[seat].Name)
p.announcePartyRound(ct, events, preamble, p.closePartyRound(ct))
}
// turnDeadlineLapsed reports whether the fight has sat on one member's turn past
// partyTurnDeadline. LastActionAt is stamped by every saveCombatSession, and the
// save that parked the fight on this seat's player_turn was the last one — so it
// is exactly when the member's clock started.
func turnDeadlineLapsed(sess *CombatSession) bool {
return sess.Phase == CombatPhasePlayerTurn &&
time.Since(sess.LastActionAt) >= partyTurnDeadline
}
// listStalledPartyCombatSessions returns every active *party* fight parked on a
// player's turn past the deadline. The roster_size filter keeps solo fights —
// which is every fight that has ever run — out of the sweep entirely.
func listStalledPartyCombatSessions() ([]*CombatSession, error) {
cutoff := time.Now().UTC().Add(-partyTurnDeadline)
rows, err := db.Get().Query(`SELECT `+combatSessionCols+`
FROM combat_session
WHERE status = 'active'
AND roster_size > 1
AND phase = 'player_turn'
AND last_action_at <= ?
ORDER BY last_action_at ASC`, cutoff)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*CombatSession
for rows.Next() {
s, err := scanCombatSession(rows)
if err != nil {
return nil, err
}
out = append(out, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Hydrate only after the cursor is closed: a nested query while iterating
// can stall on a single-connection pool.
rows.Close()
for _, s := range out {
if err := hydrateCombatParticipants(s); err != nil {
return nil, err
}
}
return out, nil
}
// ── telling the party what happened ──────────────────────────────────────────
// closePartyRound runs the terminal side effects exactly once and returns each
// seat's own close-out block. It returns nil while the fight is still in flight.
//
// Callers must invoke it whether or not they intend to narrate: a silent
// autopilot round still owes the party its XP, loot, and death bookkeeping.
func (p *AdventurePlugin) closePartyRound(ct *combatTurn) []string {
if ct.sess.IsActive() {
return nil
}
return p.finishPartyCombatSession(ct)
}
// announcePartyRound DMs every seated member the round that just resolved. It is
// the fan-out the single-recipient SendDM seam does not have: one call per seat,
// with each member's own footer — or their own close-out, if outcomes is set.
//
// Solo callers do not use it — handleCombatActionCmd replies to the one player
// directly, as it always has.
func (p *AdventurePlugin) announcePartyRound(ct *combatTurn, events []CombatEvent, preamble string, outcomes []string) {
names := ct.seatNames()
acting, waiting := actingSeat(ct.sess, ct.players, ct.enemy)
for seat, uid := range ct.sess.SeatUserIDs() {
// Rendered once per reader: the flavor pool speaks in the second person,
// so each member's own events must be theirs and nobody else's.
body := RenderPartyTurnRound(events, names, ct.enemy.Name, seat)
tail := ""
switch {
case seat < len(outcomes):
tail = outcomes[seat]
case waiting:
tail = partyRoundFooter(ct, seat, acting)
}
if err := p.SendDM(id.UserID(uid), preamble+body+"\n\n"+tail); err != nil {
slog.Error("combat: party round DM failed", "user", uid, "err", err)
}
}
}
// partyRoundFooter is the per-member close of a round: the roster's HP, then
// either "your move" or who everyone is waiting on.
func partyRoundFooter(ct *combatTurn, seat, acting int) string {
var b strings.Builder
for i, c := range ct.players {
down := ""
if !ct.sess.seatAlive(i) {
down = " _(down)_"
}
b.WriteString(fmt.Sprintf("%s: **%d/%d**%s\n", c.Name, ct.sess.seatHP(i), ct.sess.seatHPMax(i), down))
}
b.WriteString(fmt.Sprintf("%s: **%d/%d**\n\n", ct.enemy.Name, ct.sess.EnemyHP, ct.sess.EnemyHPMax))
if seat == acting {
b.WriteString(partyMovePrompt(ct.sess.Round))
} else {
b.WriteString(fmt.Sprintf("**Round %d.** Waiting on **%s**.", ct.sess.Round, ct.players[acting].Name))
}
return b.String()
}

View File

@@ -0,0 +1,537 @@
package plugin
import (
"encoding/json"
"strings"
"testing"
"time"
"maunium.net/go/mautrix/id"
)
// N3/P5 — the session layer.
//
// Everything here exercises a seated party, which production cannot yet build:
// P6 supplies the invite. The point of testing it now is that P5's mistakes are
// silent ones — a buff landing on the wrong sheet, a latch that resets on every
// step, a solo row that grew a key — and none of them announce themselves.
// ── Seat addressing ──────────────────────────────────────────────────────────
// partySession builds a 3-seat roster in memory, with each seat's HP and
// statuses distinguishable so a mix-up cannot pass.
func partySession(phase string, seatHP ...int) *CombatSession {
s := turnSession(phase, seatHP[0], 100)
s.UserID = "@leader:x"
for i, hp := range seatHP[1:] {
s.Participants = append(s.Participants, CombatParticipant{
Seat: i + 1, UserID: memberID(i + 1), HP: hp, HPMax: hp + 10,
})
}
s.rosterSize = len(seatHP)
return s
}
func memberID(seat int) string {
return string(rune('a'+seat-1)) + "member:x"
}
func TestSeatAccessors_AddressTheRightRow(t *testing.T) {
s := partySession(CombatPhasePlayerTurn, 40, 55, 70)
s.PlayerHPMax = 90
for seat, want := range []int{40, 55, 70} {
if got := s.seatHP(seat); got != want {
t.Errorf("seatHP(%d) = %d, want %d", seat, got, want)
}
}
if got := s.seatHPMax(0); got != 90 {
t.Errorf("seatHPMax(0) = %d, want the session row's 90", got)
}
if got := s.seatHPMax(2); got != 80 {
t.Errorf("seatHPMax(2) = %d, want the participant row's 80", got)
}
if got := s.seatUserID(0); got != "@leader:x" {
t.Errorf("seatUserID(0) = %q, want the session owner", got)
}
if got := s.seatUserID(1); got != memberID(1) {
t.Errorf("seatUserID(1) = %q, want %q", got, memberID(1))
}
}
func TestSeatOf_FindsMembersAndRejectsStrangers(t *testing.T) {
s := partySession(CombatPhasePlayerTurn, 40, 55, 70)
if seat, ok := s.seatOf("@leader:x"); !ok || seat != 0 {
t.Errorf("seatOf(leader) = (%d,%v), want (0,true)", seat, ok)
}
if seat, ok := s.seatOf(id.UserID(memberID(2))); !ok || seat != 2 {
t.Errorf("seatOf(member 2) = (%d,%v), want (2,true)", seat, ok)
}
if _, ok := s.seatOf("@nobody:x"); ok {
t.Error("seatOf(stranger) reported a seat — an outsider could act in the fight")
}
}
// A member's mid-fight buff must land on their own sheet. Before P5 the cast
// path folded every delta into the session's embedded ActorStatuses — seat 0 —
// so a party member casting Shield on themselves armoured the leader instead.
func TestActorStatusesPtr_WritesToTheCastingSeat(t *testing.T) {
s := partySession(CombatPhasePlayerTurn, 40, 55, 70)
s.actorStatusesPtr(2).applyBuffDelta(turnBuffDelta{dAC: 5})
if got := s.Statuses.BuffACBonus; got != 0 {
t.Errorf("seat 0 picked up seat 2's buff: BuffACBonus = %d, want 0", got)
}
if got := s.Participants[1].Statuses.BuffACBonus; got != 5 {
t.Errorf("seat 2's BuffACBonus = %d, want 5", got)
}
}
// ── The autopilot latch ──────────────────────────────────────────────────────
// The latch is per-seat session state with no combatState counterpart, so it
// rides through the engine on snapshotActor's carry-over of the prior snapshot —
// the same road the Buff* deltas take. If it did not, an away member would be
// handed the wheel back on every single step and the party would stall anew each
// round.
func TestSnapshotActor_CarriesTheAutopilotLatchAcrossAStep(t *testing.T) {
c := basePlayer()
prior := ActorStatuses{Autopilot: true, BuffACBonus: 3}
got := snapshotActor(newActor(&c), prior)
if !got.Autopilot {
t.Error("snapshotActor dropped the autopilot latch — an away member unlatches every step")
}
if got.BuffACBonus != 3 {
t.Errorf("snapshotActor dropped a carried-over buff: BuffACBonus = %d, want 3", got.BuffACBonus)
}
}
func TestSeatNeedsNoHuman(t *testing.T) {
s := partySession(CombatPhasePlayerTurn, 40, 0, 70)
s.Participants[1].Statuses.Autopilot = true
if s.seatNeedsNoHuman(0) {
t.Error("a standing, unlatched seat needs its human")
}
if !s.seatNeedsNoHuman(1) {
t.Error("a downed seat must not block the round")
}
if !s.seatNeedsNoHuman(2) {
t.Error("a latched seat must resolve without waiting")
}
}
// ── Wire compatibility ───────────────────────────────────────────────────────
// Two fields landed on persisted structs in P5. Both must vanish from a solo
// row, which is every row prod holds today: statuses_json and turn_log_json are
// decoded by readers that predate them, and a fight in flight across the deploy
// must resume byte-identically.
func TestP5Fields_StayOffSoloRows(t *testing.T) {
raw, err := json.Marshal(CombatStatuses{ActorStatuses: ActorStatuses{PoisonTicks: 1}})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "autopilot") {
t.Errorf("a solo statuses_json carries the autopilot key: %s", raw)
}
raw, err = json.Marshal(CombatEvent{Actor: "player", Action: "hit", Damage: 4})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "Seat") {
t.Errorf("a solo turn_log event carries the Seat key: %s", raw)
}
// …and both must survive the round trip when they are set.
raw, _ = json.Marshal(ActorStatuses{Autopilot: true})
var back ActorStatuses
if err := json.Unmarshal(raw, &back); err != nil {
t.Fatal(err)
}
if !back.Autopilot {
t.Errorf("autopilot did not round-trip: %s", raw)
}
}
// ── Event attribution ────────────────────────────────────────────────────────
// biasedParty builds a roster whose initiative order is fixed at [0,1,2,enemy],
// so a test can park the cursor on a known seat.
func biasedParty() ([]*Combatant, *Combatant) {
a, b, c := basePlayer(), basePlayer(), basePlayer()
a.Name, b.Name, c.Name = "Ada", "Bram", "Cass"
a.Mods.InitiativeBias, b.Mods.InitiativeBias, c.Mods.InitiativeBias = 300, 200, 100
e := baseEnemy()
e.Stats.Speed = 1
return []*Combatant{&a, &b, &c}, &e
}
func TestTurnEngine_StampsEventsWithTheSeatThatActed(t *testing.T) {
players, enemy := biasedParty()
sess := partySession(CombatPhasePlayerTurn, 100, 100, 100)
sess.Statuses.TurnIdx = 1 // Bram's turn
events, err := partyStep(sess, players, enemy, PlayerAction{Kind: ActionAttack})
if err != nil {
t.Fatal(err)
}
if len(events) == 0 {
t.Fatal("Bram's turn produced no events")
}
for _, e := range events {
if e.Seat != 1 {
t.Errorf("event %+v stamped seat %d, want 1 (Bram swung)", e, e.Seat)
}
}
}
// Solo events must all be seat 0 — that is what keeps the field omitted from
// every solo turn_log_json.
func TestTurnEngine_SoloEventsAreAllSeatZero(t *testing.T) {
sess := turnSession(CombatPhasePlayerTurn, 100, 100)
p, e := basePlayer(), baseEnemy()
events, err := partyStep(sess, []*Combatant{&p}, &e, PlayerAction{Kind: ActionAttack})
if err != nil {
t.Fatal(err)
}
for _, ev := range events {
if ev.Seat != 0 {
t.Errorf("solo event %+v stamped seat %d, want 0", ev, ev.Seat)
}
}
}
// The flavor pool speaks in the second person. A round must therefore read
// differently to each member: your own swing is "You score 9 damage", your
// ally's is "**Cass** hits Rat for 9". Getting this backwards tells three people
// they each personally landed the same blow.
func TestRenderPartyTurnRound_SecondPersonForTheReaderThirdForAllies(t *testing.T) {
events := []CombatEvent{
{Actor: "player", Action: "hit", Damage: 7, Seat: 0},
{Actor: "player", Action: "hit", Damage: 9, Seat: 2},
}
names := []string{"Ada", "Bram", "Cass"}
// The reader's own line comes from the flavor pool, whose phrasings vary
// ("You score 9 damage", "A measured strike. 9 damage."). What is invariant
// is that it never names them in the third person, and that the ally's line
// always does.
ada := RenderPartyTurnRound(events, names, "Rat", 0)
if strings.Contains(ada, "**Ada**") {
t.Errorf("Ada's own swing was rendered in the third person:\n%s", ada)
}
if !strings.Contains(ada, "**Cass** hits Rat for 9.") {
t.Errorf("Ada should read Cass's swing in the third person:\n%s", ada)
}
if strings.Contains(ada, "Bram") {
t.Errorf("Bram did nothing this round but was named:\n%s", ada)
}
cass := RenderPartyTurnRound(events, names, "Rat", 2)
if strings.Contains(cass, "**Cass**") {
t.Errorf("Cass's own swing was rendered in the third person:\n%s", cass)
}
if !strings.Contains(cass, "**Ada** hits Rat for 7.") {
t.Errorf("Cass should read Ada's swing in the third person:\n%s", cass)
}
}
// A downed ally is the one thing a member must not miss in their own DM.
func TestRenderAllySeatEvent_FlagsAnAllyGoingDown(t *testing.T) {
got := renderAllySeatEvent(
CombatEvent{Actor: "enemy", Action: "hit", Damage: 12, PlayerHP: 0, Seat: 1}, "Bram", "Rat")
if !strings.Contains(got, "is down") {
t.Errorf("an ally dropping to 0 HP should be called out, got %q", got)
}
standing := renderAllySeatEvent(
CombatEvent{Actor: "enemy", Action: "hit", Damage: 3, PlayerHP: 20, Seat: 1}, "Bram", "Rat")
if strings.Contains(standing, "is down") {
t.Errorf("a standing ally was reported down: %q", standing)
}
}
// An event whose seat has fallen off the roster falls back to seat 0 rather
// than panicking on a replayed turn_log.
func TestRenderPartyTurnRound_OutOfRangeSeatFallsBack(t *testing.T) {
got := RenderPartyTurnRound(
[]CombatEvent{{Actor: "player", Action: "hit", Damage: 3, Seat: 9}}, []string{"Ada"}, "Rat", 0)
if got == "" || strings.Contains(got, "without a clean blow") {
t.Errorf("out-of-range seat should still render a line, got:\n%s", got)
}
}
// ── The turn deadline ────────────────────────────────────────────────────────
func TestTurnDeadlineLapsed(t *testing.T) {
fresh := time.Now().UTC()
stale := fresh.Add(-partyTurnDeadline - time.Second)
cases := []struct {
name string
phase string
at time.Time
want bool
}{
{"fresh player turn", CombatPhasePlayerTurn, fresh, false},
{"stale player turn", CombatPhasePlayerTurn, stale, true},
// The enemy's turn belongs to the engine, not to a player who might be
// away — a fight parked there is mid-resolution, not stalled on anyone.
{"stale enemy turn", CombatPhaseEnemyTurn, stale, false},
{"stale round end", CombatPhaseRoundEnd, stale, false},
}
for _, tc := range cases {
s := partySession(tc.phase, 40, 40)
s.LastActionAt = tc.at
if got := turnDeadlineLapsed(s); got != tc.want {
t.Errorf("%s: turnDeadlineLapsed = %v, want %v", tc.name, got, tc.want)
}
}
}
// ── Locking ──────────────────────────────────────────────────────────────────
// A solo fight's owner is the sender. sync.Mutex is not reentrant, so taking the
// fight lock and then the user lock would deadlock the very command every solo
// player types. This test hangs rather than fails if that regresses.
func TestLockCombatFight_SoloTakesExactlyOneLock(t *testing.T) {
p := &AdventurePlugin{}
done := make(chan struct{})
go func() {
release := p.lockCombatFight("@solo:x", "@solo:x")
release()
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("lockCombatFight self-deadlocked on a solo fight")
}
}
// A party member's command holds both the fight's lock and their own, so no
// other command of theirs races the round they are resolving.
func TestLockCombatFight_PartyHoldsBothLocks(t *testing.T) {
p := &AdventurePlugin{}
release := p.lockCombatFight("@leader:x", "@member:x")
if p.advUserLock("@leader:x").TryLock() {
t.Error("the fight's lock (seat 0) was not held")
}
if p.advUserLock("@member:x").TryLock() {
t.Error("the acting member's own lock was not held")
}
release()
if !p.advUserLock("@leader:x").TryLock() {
t.Error("release() left the fight's lock held")
}
if !p.advUserLock("@member:x").TryLock() {
t.Error("release() left the member's lock held")
}
}
func TestNotYourTurnMsg_NamesWhoWeAreWaitingOn(t *testing.T) {
players, _ := biasedParty()
if got := notYourTurnMsg(players, 1, true); !strings.Contains(got, "Bram") {
t.Errorf("message should name the acting player, got %q", got)
}
if got := notYourTurnMsg(players, 0, false); strings.Contains(got, "Ada") {
t.Errorf("nobody is on the clock mid-resolution, got %q", got)
}
}
// ── Starting a fight ─────────────────────────────────────────────────────────
// enemyWithHP is the monster the seats below face. Only its HP pool and its
// Speed matter here: the pool sizes the fight, the speed feeds initiative.
func enemyWithHP(hp int) *Combatant {
e := baseEnemy()
e.Stats.MaxHP = hp
return &e
}
// partySeats pairs each combatant with a seat setup, as buildFightSeats does.
func partySeats(players []*Combatant, hp int) []CombatSeatSetup {
seats := make([]CombatSeatSetup, len(players))
for i, c := range players {
seats[i] = CombatSeatSetup{UserID: id.UserID(memberID(i)), HP: hp, HPMax: hp, C: c}
}
seats[0].UserID = "@leader:x"
return seats
}
func TestStartPartyCombatSession_SoloWritesNoParticipantRows(t *testing.T) {
setupEmptyTestDB(t)
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemyWithHP(60),
[]CombatSeatSetup{{UserID: "@solo:x", HP: 40, HPMax: 40}})
if err != nil {
t.Fatal(err)
}
if sess.IsParty() || sess.RosterSize() != 1 {
t.Fatalf("solo session reports a roster of %d", sess.RosterSize())
}
ps, err := loadCombatParticipants(sess.SessionID)
if err != nil {
t.Fatal(err)
}
if len(ps) != 0 {
t.Errorf("solo fight wrote %d participant rows, want 0", len(ps))
}
}
// Each seat's fight-start one-shots are read off their own combatant. A party
// Abjurer brings their own Arcane Ward; the leader does not inherit it.
func TestStartPartyCombatSession_SeedsEachSeatsOwnOneShots(t *testing.T) {
setupEmptyTestDB(t)
leader, abjurer := basePlayer(), basePlayer()
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemyWithHP(60), []CombatSeatSetup{
{UserID: "@leader:x", HP: 40, HPMax: 40, C: &leader},
{UserID: "@abjurer:x", HP: 30, HPMax: 30, C: &abjurer,
Mods: CombatModifiers{ArcaneWardHP: 12, WardCharges: 2}},
})
if err != nil {
t.Fatal(err)
}
if !sess.IsParty() || sess.RosterSize() != 2 {
t.Fatalf("roster size = %d, want 2", sess.RosterSize())
}
if sess.Statuses.ArcaneWardHP != 0 {
t.Errorf("the leader inherited the abjurer's ward: %d", sess.Statuses.ArcaneWardHP)
}
// …and it must be there after a round trip through the DB.
back, err := getCombatSession(sess.SessionID)
if err != nil {
t.Fatal(err)
}
if got := back.actorStatusesForSeat(1); got.ArcaneWardHP != 12 || got.WardCharges != 2 {
t.Errorf("seat 1 statuses after reload = %+v, want ArcaneWardHP 12 / WardCharges 2", got)
}
if back.seatHP(1) != 30 || back.seatUserID(1) != "@abjurer:x" {
t.Errorf("seat 1 = %d HP / %s", back.seatHP(1), back.seatUserID(1))
}
}
// A monster that wins initiative swings first. startCombatSession parks every
// fight on a player_turn, which is right for the hardcoded solo order and wrong
// for a party: the resume path would snap the cursor to the first player slot
// and the enemy would silently forfeit round 1.
func TestStartPartyCombatSession_EnemyThatWinsInitiativeOpensTheRound(t *testing.T) {
setupEmptyTestDB(t)
players, enemy := biasedParty()
// Invert the bias biasedParty stacks: the monster now outruns everyone.
for _, c := range players {
c.Mods.InitiativeBias = -1000
}
enemy.Stats.Speed = 500
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemy,
partySeats(players, 100))
if err != nil {
t.Fatal(err)
}
if sess.Phase != CombatPhaseEnemyTurn {
t.Fatalf("round 1 opened on phase %q; the enemy won initiative and must act first", sess.Phase)
}
if order := turnOrder(sess, 1, players, enemy); order[0] != enemySeat {
t.Fatalf("turn order = %v, want the enemy at the head", order)
}
// And the phase survives the round trip, since a resume reads it back.
back, err := getCombatSession(sess.SessionID)
if err != nil {
t.Fatal(err)
}
if back.Phase != CombatPhaseEnemyTurn {
t.Errorf("reloaded phase = %q, want the enemy still on the clock", back.Phase)
}
}
// The other half of the same rule: when the party wins, nothing moved.
func TestStartPartyCombatSession_PartyThatWinsInitiativeStillOpensOnAPlayer(t *testing.T) {
setupEmptyTestDB(t)
players, enemy := biasedParty()
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemy,
partySeats(players, 100))
if err != nil {
t.Fatal(err)
}
if sess.Phase != CombatPhasePlayerTurn || sess.Statuses.TurnIdx != 0 {
t.Fatalf("round 1 opened on %q/%d, want player_turn at the head of the order",
sess.Phase, sess.Statuses.TurnIdx)
}
}
// ── The round driver ─────────────────────────────────────────────────────────
// The old loop rested on any player_turn. A party's downed seat still holds a
// player_turn, so the round would come to rest on a corpse and the fight would
// wait forever for a dead member to type `!attack`.
func TestSettleCombatSession_StepsPastADownedSeat(t *testing.T) {
setupEmptyTestDB(t)
players, enemy := biasedParty()
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemy,
partySeats(players, 100))
if err != nil {
t.Fatal(err)
}
// Bram is down, and it is his turn. biasedParty stacks initiative so the
// party leads the round; force the phase back to it in case the roll disagrees.
sess.Participants[0].HP = 0
sess.Phase = CombatPhasePlayerTurn
sess.Statuses.TurnIdx = 1
if _, err := settleCombatSession(sess, players, enemy); err != nil {
t.Fatal(err)
}
if !sess.IsActive() {
t.Fatalf("fight ended; two members were still standing (status %q)", sess.Status)
}
seat, waiting := actingSeat(sess, players, enemy)
if !waiting {
t.Fatal("settle came to rest on no player's turn")
}
if seat != 2 {
t.Errorf("settled on seat %d, want seat 2 — seat 1 is down and forfeits", seat)
}
}
// Settling a session already parked on a standing player's turn must do nothing
// at all. Every solo fight sits exactly there between commands, and a stray step
// would resolve a round the player never asked for.
func TestSettleCombatSession_IsANoOpOnASoloPlayerTurn(t *testing.T) {
setupEmptyTestDB(t)
p, e := basePlayer(), baseEnemy()
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemyWithHP(60),
[]CombatSeatSetup{{UserID: "@solo:x", HP: 40, HPMax: 40}})
if err != nil {
t.Fatal(err)
}
before := *sess
events, err := settleCombatSession(sess, []*Combatant{&p}, &e)
if err != nil {
t.Fatal(err)
}
if len(events) != 0 {
t.Errorf("settle emitted %d events on an idle solo turn", len(events))
}
if sess.Round != before.Round || sess.Phase != before.Phase || sess.PlayerHP != before.PlayerHP {
t.Errorf("settle advanced an idle solo fight: %d/%s/%d -> %d/%s/%d",
before.Round, before.Phase, before.PlayerHP, sess.Round, sess.Phase, sess.PlayerHP)
}
}

View File

@@ -44,9 +44,10 @@ func (p *AdventurePlugin) reapExpiredCombatSessions() {
// nothing to do.
func (p *AdventurePlugin) reapCombatSession(userIDStr, sessionID string) {
userID := id.UserID(userIDStr)
userMu := p.advUserLock(userID)
userMu.Lock()
defer userMu.Unlock()
// The session's owner is seat 0, which is the user the sweep listed — so the
// fight lock and the user lock are the same mutex here, taken once.
release := p.lockCombatFight(userID, userID)
defer release()
sess, err := getActiveCombatSession(userID)
if err != nil {
@@ -58,41 +59,51 @@ func (p *AdventurePlugin) reapCombatSession(userIDStr, sessionID string) {
return
}
player, enemy, err := p.combatantsForSession(sess)
if err != nil {
// Can't reconstruct the fight — park it terminal so it isn't retried
// every minute forever.
slog.Warn("combat: reaper cannot rebuild session, marking expired",
"user", userID, "session", sess.SessionID, "err", err)
expire := func(why string, args ...any) {
slog.Warn("combat: reaper "+why, append([]any{"user", userID, "session", sess.SessionID}, args...)...)
if merr := markCombatSessionExpired(sess.SessionID); merr != nil {
slog.Error("combat: reaper failed to mark session expired", "session", sess.SessionID, "err", merr)
}
}
players, enemy, err := p.partyCombatantsForSession(sess)
if err != nil {
// Can't reconstruct the fight — park it terminal so it isn't retried
// every minute forever.
expire("cannot rebuild session, marking expired", "err", err)
return
}
// Every seat swings until the fight lands. Deliberately *not* the auto-picker
// the turn deadline uses: this is an abandoned fight, and finishing it should
// not quietly burn the player's spell slots and potions on their behalf. The
// turn-deadline latch is different — that member is mid-fight and their party
// is waiting, so playing their character properly is the whole point.
//
// Each pass resolves one seat's turn and drains the enemy turn, the round-end
// tick, and any downed seat behind it, so a solo fight walks exactly the loop
// it always did.
ct := &combatTurn{sess: sess, players: players, enemy: enemy, seat: 0, uid: userID}
rounds := 0
for sess.IsActive() {
if rounds >= reaperRoundCap {
slog.Warn("combat: reaper hit round cap, marking expired",
"user", userID, "session", sess.SessionID)
if merr := markCombatSessionExpired(sess.SessionID); merr != nil {
slog.Error("combat: reaper failed to mark session expired", "session", sess.SessionID, "err", merr)
}
expire("hit round cap, marking expired")
return
}
if _, rerr := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionAttack}); rerr != nil {
slog.Error("combat: reaper round failed", "user", userID, "session", sess.SessionID, "err", rerr)
if merr := markCombatSessionExpired(sess.SessionID); merr != nil {
slog.Error("combat: reaper failed to mark session expired", "session", sess.SessionID, "err", merr)
}
if _, rerr := runPartyCombatRound(sess, players, enemy, PlayerAction{Kind: ActionAttack}); rerr != nil {
expire("round failed", "err", rerr)
return
}
rounds++
}
outcome := p.finishCombatSession(userID, sess, enemy)
outcomes := p.closePartyRound(ct)
preamble := fmt.Sprintf("⏳ Your fight with **%s** timed out — I finished it for you.\n\n", enemy.Name)
if err := p.SendDM(userID, preamble+outcome); err != nil {
slog.Error("combat: reaper failed to DM outcome", "user", userID, "err", err)
if !ct.isParty() {
if err := p.SendDM(userID, preamble+outcomes[0]); err != nil {
slog.Error("combat: reaper failed to DM outcome", "user", userID, "err", err)
}
return
}
p.announcePartyRound(ct, nil, preamble, outcomes)
}

View File

@@ -0,0 +1,153 @@
package plugin
// Roster/cursor tests for the N-player combat state (N3/P2).
//
// The solo path is pinned bit-for-bit by TestCombatCharacterization; these
// cover the machinery that pin cannot see, because solo never moves the
// cursor off seat 0.
import (
"math/rand/v2"
"testing"
)
func TestNewActor_DerivesPerFightStateFromMods(t *testing.T) {
c := basePlayer()
c.Mods.WardCharges = 2
c.Mods.SporeCloud = 3
c.Mods.ReflectNext = 0.5
c.Mods.AutoCritFirst = true
c.Mods.ArcaneWardHP = 25
a := newActor(&c)
if a.c != &c {
t.Error("actor should point back at its Combatant")
}
if a.playerHP != c.Stats.MaxHP {
t.Errorf("playerHP = %d, want MaxHP %d", a.playerHP, c.Stats.MaxHP)
}
if a.wardCharges != 2 || a.sporeRounds != 3 || a.reflectFrac != 0.5 || !a.autoCrit || a.arcaneWardHP != 25 {
t.Errorf("consumable one-shots not carried from Mods: %+v", a)
}
}
func TestNewActor_StartHPAndHealChargeBackfill(t *testing.T) {
// StartHP below MaxHP means the character walks in wounded.
wounded := basePlayer()
wounded.Stats.StartHP = 40
if got := newActor(&wounded).playerHP; got != 40 {
t.Errorf("wounded entry HP = %d, want 40", got)
}
// StartHP at or above MaxHP is ignored (guards a stale snapshot).
full := basePlayer()
full.Stats.StartHP = 999
if got := newActor(&full).playerHP; got != full.Stats.MaxHP {
t.Errorf("StartHP above MaxHP should not raise entry HP, got %d", got)
}
// Legacy one-shot: a HealItem amount with no explicit count backfills to 1.
legacy := basePlayer()
legacy.Mods.HealItem = 30
if got := newActor(&legacy).healChargesLeft; got != 1 {
t.Errorf("legacy heal backfill = %d charges, want 1", got)
}
// An explicit count wins over the backfill.
stocked := basePlayer()
stocked.Mods.HealItem = 30
stocked.Mods.HealItemCharges = 4
if got := newActor(&stocked).healChargesLeft; got != 4 {
t.Errorf("explicit heal charges = %d, want 4", got)
}
// No HealItem at all means no charges, even if a count leaked through.
none := basePlayer()
if got := newActor(&none).healChargesLeft; got != 0 {
t.Errorf("no heal item should mean 0 charges, got %d", got)
}
}
// The cursor is the whole point of the embed: per-actor fields must follow
// seat(), and fight-scoped fields must not.
func TestCombatState_SeatSwitchesPerActorStateOnly(t *testing.T) {
alice, bob := basePlayer(), basePlayer()
alice.Name, bob.Name = "Alice", "Bob"
a0, a1 := newActor(&alice), newActor(&bob)
st := &combatState{
actor: a0,
actors: []*actor{a0, a1},
enemyHP: 100,
rng: rand.New(rand.NewPCG(1, 1)),
}
// Wound seat 0 and burn one of its once-per-fight one-shots.
st.seat(0)
st.playerHP = 10
st.luckyUsed = true
// Seat 1 must be untouched — a promoted write goes to the cursor, not the
// struct. This is the assertion that would fail if actor were embedded by
// value instead of by pointer.
st.seat(1)
if st.playerHP != bob.Stats.MaxHP {
t.Errorf("seat 1 HP = %d, want a full pool %d — seat 0's wound leaked", st.playerHP, bob.Stats.MaxHP)
}
if st.luckyUsed {
t.Error("seat 1 saw seat 0's consumed Lucky reroll")
}
if st.c.Name != "Bob" {
t.Errorf("cursor Combatant = %q, want Bob", st.c.Name)
}
// Fight-scoped state is shared: writing it under one seat is visible
// under the other.
st.enemyHP = 42
st.enemyBlockUp = true
st.seat(0)
if st.playerHP != 10 || !st.luckyUsed {
t.Error("seat 0 lost its own state across a cursor round-trip")
}
if st.enemyHP != 42 || !st.enemyBlockUp {
t.Error("enemy stance should be fight-scoped, not per-actor")
}
if st.c.Name != "Alice" {
t.Errorf("cursor Combatant = %q, want Alice", st.c.Name)
}
}
func TestCombatState_AnyAlive(t *testing.T) {
alice, bob := basePlayer(), basePlayer()
a0, a1 := newActor(&alice), newActor(&bob)
st := &combatState{actor: a0, actors: []*actor{a0, a1}}
if !st.anyAlive() {
t.Error("a fresh roster should be alive")
}
a0.playerHP = 0
if !st.anyAlive() {
t.Error("one downed member should not end the fight while another stands")
}
a1.playerHP = 0
if st.anyAlive() {
t.Error("a fully downed roster should not be alive")
}
}
// Solo fights seat exactly one actor and park the cursor on it. If this ever
// regresses, the characterization golden stops proving anything about the
// production auto-resolve path.
func TestSimulateCombat_SeatsExactlyOneActor(t *testing.T) {
p, e := basePlayer(), baseEnemy()
seat0 := newActor(&p)
st := &combatState{actor: seat0, actors: []*actor{seat0}, enemyHP: e.Stats.MaxHP}
if len(st.actors) != 1 {
t.Fatalf("solo roster length = %d, want 1", len(st.actors))
}
if st.actor != st.actors[0] {
t.Error("solo cursor must point at seat 0")
}
}

View File

@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"log/slog"
"strings"
"time"
"gogobee/internal/db"
@@ -43,33 +44,34 @@ const (
// reaper sweeps it. Matches the started_at + 1h note in the schema.
const combatSessionTTL = time.Hour
// CombatStatuses is the serialized between-round effect state for a fight: the
// subset of combatState that genuinely persists round-to-round, plus the
// fight-scoped buffs a mid-fight !cast / !consume layers on. Everything here
// round-trips combatState -> Statuses -> combatState across every engine step,
// so a fight resumes from exact mid-state after a suspend or bot restart.
// ActorStatuses is the per-seat half of the persisted effect state: everything
// that belongs to one character rather than to the fight. It mirrors the fields
// of `actor` (combat_engine.go) that must survive a suspend/resume.
//
// All fields are scalar by design — CombatStatuses must stay comparable so the
// The split follows P2's rule. A debuff the enemy stacked onto one character
// (stat_drain / debuff / max_hp_drain), that character's concentration, their
// depleting charges, and their once-per-fight one-shots are all per-actor — a
// party of three carries three independent copies. The enemy's own stance and
// the round cursor are fight-scoped and live on CombatStatuses.
//
// Seat 0's copy is embedded in the session row's statuses_json (see
// CombatStatuses); seats 1+ each get a combat_participant row carrying this
// struct alone. That asymmetry is deliberate: a solo fight writes exactly the
// bytes it wrote before parties existed, and no participant rows at all.
//
// All fields are scalar by design — ActorStatuses must stay comparable so the
// persistence round-trip can be asserted with ==.
type CombatStatuses struct {
// Monster-ability effects — the original between-round persistence set.
PoisonTicks int `json:"poison_ticks,omitempty"`
PoisonDmg int `json:"poison_dmg,omitempty"`
StunPlayer bool `json:"stun_player,omitempty"`
Enraged bool `json:"enraged,omitempty"`
ArmorBroken bool `json:"armor_broken,omitempty"`
ArmorBreakAmt float64 `json:"armor_break_amt,omitempty"`
// EnemySkipNext carries a control-spell skip (Hold Person, Sleep) from the
// player_turn phase it was cast in to the enemy_turn phase that consumes it
// — those phases resolve as separate engine steps with separate in-memory
// combatState, so the flag must survive the commit between them.
EnemySkipNext bool `json:"enemy_skip_next,omitempty"`
type ActorStatuses struct {
// Monster-ability effects landing on this character.
PoisonTicks int `json:"poison_ticks,omitempty"`
PoisonDmg int `json:"poison_dmg,omitempty"`
StunPlayer bool `json:"stun_player,omitempty"`
// Fight-scoped depleting resources — mirror the combatState charges that
// genuinely carry round-to-round. Seeded at fight start (Arcane Ward) or by
// a mid-fight !cast / !consume, restored into combatState on every resume,
// written back on every commit so a depleting charge can't silently reset
// when a fight is suspended and resumed.
// Depleting resources — mirror the actor charges that genuinely carry
// round-to-round. Seeded at fight start (Arcane Ward) or by a mid-fight
// !cast / !consume, restored into the actor on every resume, written back on
// every commit so a depleting charge can't silently reset when a fight is
// suspended and resumed.
WardCharges int `json:"ward_charges,omitempty"`
SporeRounds int `json:"spore_rounds,omitempty"`
ReflectFrac float64 `json:"reflect_frac,omitempty"`
@@ -77,6 +79,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 this character'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.
@@ -88,31 +99,27 @@ type CombatStatuses struct {
AssassinateReroll bool `json:"assassinate_reroll_used,omitempty"`
AssassinateBonus bool `json:"assassinate_bonus_used,omitempty"`
// Slice-3 stateful monster-ability effects — armed by applyAbility, read by
// the shared resolution primitives, round-tripped through combatState so a
// suspended/resumed fight (or a reaper auto-play) keeps the same effect
// state. EnemyEvadeNext is a one-shot; the rest persist for the fight.
EnemyEvadeNext bool `json:"enemy_evade_next,omitempty"`
EnemyBlockUp bool `json:"enemy_block_up,omitempty"`
EnemyAdvantage bool `json:"enemy_advantage,omitempty"`
EnemyRetaliateFrac float64 `json:"enemy_retaliate_frac,omitempty"`
EnemyRegen int `json:"enemy_regen,omitempty"`
EnemySurviveArmed bool `json:"enemy_survive_armed,omitempty"`
PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
MaxHPDrain int `json:"max_hp_drain,omitempty"`
// Autopilot latches this seat onto the auto-picker for the rest of the
// fight, set when its turn deadline lapses once (see partyTurnDeadline).
// Without the latch an away member taxes the party the full deadline every
// single round; with it, they cost one wait and then resolve instantly. Any
// combat command from that member clears it.
//
// Like the Buff* deltas it is session-layer state with no combatState
// counterpart, so snapshotActor carries it over from the prior snapshot
// rather than re-deriving it. omitempty keeps it off every solo row — a solo
// fight has no turn deadline, only the 1h session reaper.
Autopilot bool `json:"autopilot,omitempty"`
// Slice-4 monster-ability effects — the former flavor-only placeholders.
// EnemyRevealNext is a one-shot; the other three persist for the fight.
EnemySpellResist bool `json:"enemy_spell_resist,omitempty"`
EnemyRevealNext bool `json:"enemy_reveal_next,omitempty"`
EnemyFearImmune bool `json:"enemy_fear_immune,omitempty"`
EnemyAtkBuff int `json:"enemy_atk_buff,omitempty"`
// Debuffs the enemy has stacked onto this character specifically.
PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
MaxHPDrain int `json:"max_hp_drain,omitempty"`
// Persistent stat buffs from mid-fight !cast / !consume, accumulated as
// deltas against the freshly-rebuilt combatant. applySessionBuffs folds
// these back onto the player every round; diffTurnBuff produces them.
// BuffDamageReductMul is multiplicative (0 = none / 1.0 neutral).
// Persistent stat buffs from this character's mid-fight !cast / !consume,
// accumulated as deltas against their freshly-rebuilt combatant.
// applySessionBuffs folds these back on every round; diffTurnBuff produces
// them. BuffDamageReductMul is multiplicative (0 = none / 1.0 neutral).
BuffACBonus int `json:"buff_ac_bonus,omitempty"`
BuffAtkBonus int `json:"buff_atk_bonus,omitempty"`
BuffSpeedBonus int `json:"buff_speed_bonus,omitempty"`
@@ -125,10 +132,65 @@ type CombatStatuses struct {
BuffSpiritDmg int `json:"buff_spirit_dmg,omitempty"`
}
// CombatStatuses is the serialized between-round effect state for a fight: the
// fight-scoped half (the enemy's stance, the round cursor) plus seat 0's
// ActorStatuses, embedded. Everything here round-trips combatState -> Statuses
// -> combatState across every engine step, so a fight resumes from exact
// mid-state after a suspend or bot restart.
//
// ActorStatuses is embedded anonymously and untagged, so it flattens into the
// same one-level JSON object the field list used to produce. Rows written
// before the split decode unchanged, and rows written after are byte-compatible
// with a reader that predates it.
//
// All fields are scalar by design — CombatStatuses must stay comparable so the
// persistence round-trip can be asserted with ==.
type CombatStatuses struct {
// Seat 0's per-character state. Seats 1+ carry their own copy in
// combat_participant rows.
ActorStatuses
Enraged bool `json:"enraged,omitempty"`
ArmorBroken bool `json:"armor_broken,omitempty"`
ArmorBreakAmt float64 `json:"armor_break_amt,omitempty"`
// EnemySkipNext carries a control-spell skip (Hold Person, Sleep) from the
// player_turn phase it was cast in to the enemy_turn phase that consumes it
// — those phases resolve as separate engine steps with separate in-memory
// combatState, so the flag must survive the commit between them. Holding the
// enemy holds it for the whole party, hence fight-scoped.
EnemySkipNext bool `json:"enemy_skip_next,omitempty"`
// TurnIdx is the position within the round's initiative order (see
// turnOrder) of whoever acts next. A solo fight's order is the fixed
// [player, enemy], so TurnIdx mirrors the phase and the field is dead
// weight — omitempty keeps it out of every solo row. Rows written before
// this field existed decode as 0 and are reconciled against Phase on
// resume, so a mid-fight upgrade lands on the right slot.
TurnIdx int `json:"turn_idx,omitempty"`
// Slice-3 stateful monster-ability effects — armed by applyAbility, read by
// the shared resolution primitives, round-tripped through combatState so a
// suspended/resumed fight (or a reaper auto-play) keeps the same effect
// state. EnemyEvadeNext is a one-shot; the rest persist for the fight.
EnemyEvadeNext bool `json:"enemy_evade_next,omitempty"`
EnemyBlockUp bool `json:"enemy_block_up,omitempty"`
EnemyAdvantage bool `json:"enemy_advantage,omitempty"`
EnemyRetaliateFrac float64 `json:"enemy_retaliate_frac,omitempty"`
EnemyRegen int `json:"enemy_regen,omitempty"`
EnemySurviveArmed bool `json:"enemy_survive_armed,omitempty"`
// Slice-4 monster-ability effects — the former flavor-only placeholders.
// EnemyRevealNext is a one-shot; the other three persist for the fight.
EnemySpellResist bool `json:"enemy_spell_resist,omitempty"`
EnemyRevealNext bool `json:"enemy_reveal_next,omitempty"`
EnemyFearImmune bool `json:"enemy_fear_immune,omitempty"`
EnemyAtkBuff int `json:"enemy_atk_buff,omitempty"`
}
// applyBuffDelta folds one resolved buff (the result of a !cast / !consume
// player turn) into the persisted fight-scoped state. Stat components
// player turn) into that character's persisted state. Stat components
// accumulate as deltas; depleting resources add to their running counters.
func (s *CombatStatuses) applyBuffDelta(d turnBuffDelta) {
func (s *ActorStatuses) applyBuffDelta(d turnBuffDelta) {
s.BuffACBonus += d.dAC
s.BuffAtkBonus += d.dAtk
s.BuffSpeedBonus += d.dSpeed
@@ -160,7 +222,14 @@ func (s *CombatStatuses) applyBuffDelta(d turnBuffDelta) {
// turn-based build deliberately omits pre-combat consumables and queued casts —
// but the full set is seeded for robustness. Returns true if anything was set.
func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) bool {
st := &s.Statuses
return seedActorOneShots(&s.Statuses.ActorStatuses, playerMods)
}
// seedActorOneShots copies one character's fight-start one-shot resources onto
// their persisted statuses. Seat 0's live on the session row; a party member's
// live on their participant row, and each seat reads its own combatant's mods —
// a party Abjurer brings their own Arcane Ward, not the leader's.
func seedActorOneShots(st *ActorStatuses, playerMods CombatModifiers) bool {
st.WardCharges = playerMods.WardCharges
st.SporeRounds = playerMods.SporeCloud
st.ReflectFrac = playerMods.ReflectNext
@@ -171,6 +240,17 @@ func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) boo
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0
}
// CombatParticipant is one party member's seat in a fight, from seat 1 up.
// Seat 0 is the session row itself (UserID / PlayerHP / Statuses.ActorStatuses)
// and never appears here.
type CombatParticipant struct {
Seat int
UserID string
HP int
HPMax int
Statuses ActorStatuses
}
// CombatSession is the in-memory shape of a combat_session row.
type CombatSession struct {
SessionID string
@@ -190,11 +270,116 @@ type CombatSession struct {
StartedAt time.Time
LastActionAt time.Time
ExpiresAt time.Time
// Participants are the party's seats 1..N-1, ordered by seat and contiguous
// (loadCombatParticipants enforces both). Empty for a solo fight — the only
// kind that existed before N3 — so nothing in the solo path allocates or
// queries for it.
Participants []CombatParticipant
// rosterSize mirrors the combat_session.roster_size column as scanned. It
// exists only so a loader can tell "solo, skip the participant query" from
// "party, go fetch the seats" in one round trip. In memory Participants is
// the single source of truth; every write re-derives the column from it.
rosterSize int
}
// IsActive reports whether the fight is still in flight.
func (s *CombatSession) IsActive() bool { return s.Status == CombatStatusActive }
// RosterSize is the number of seated player characters: seat 0 plus the party.
func (s *CombatSession) RosterSize() int { return 1 + len(s.Participants) }
// IsParty reports whether more than one character is seated.
func (s *CombatSession) IsParty() bool { return len(s.Participants) > 0 }
// SeatUserIDs lists every seated member's user id in seat order.
func (s *CombatSession) SeatUserIDs() []string {
out := make([]string, 0, s.RosterSize())
out = append(out, s.UserID)
for _, p := range s.Participants {
out = append(out, p.UserID)
}
return out
}
// actorStatusesForSeat returns the persisted per-character state for a seat.
// Seat 0 reads through to the session's embedded copy.
func (s *CombatSession) actorStatusesForSeat(seat int) ActorStatuses {
if seat == 0 {
return s.Statuses.ActorStatuses
}
return s.Participants[seat-1].Statuses
}
// actorStatusesPtr is actorStatusesForSeat for writers. The mid-fight buff path
// (!cast / !consume) folds its delta into the *casting* seat's statuses; before
// P5 it wrote unconditionally to the session's embedded copy, which is seat 0 —
// so a party member's buff would have landed on the leader.
func (s *CombatSession) actorStatusesPtr(seat int) *ActorStatuses {
if seat == 0 {
return &s.Statuses.ActorStatuses
}
return &s.Participants[seat-1].Statuses
}
// seatHP / seatHPMax read one seat's HP pool. Seat 0's lives on the session row
// (PlayerHP / PlayerHPMax); seats 1+ carry their own on their participant row.
func (s *CombatSession) seatHP(seat int) int {
if seat == 0 {
return s.PlayerHP
}
return s.Participants[seat-1].HP
}
func (s *CombatSession) seatHPMax(seat int) int {
if seat == 0 {
return s.PlayerHPMax
}
return s.Participants[seat-1].HPMax
}
// seatUserID names the player sitting at a seat.
func (s *CombatSession) seatUserID(seat int) string {
if seat == 0 {
return s.UserID
}
return s.Participants[seat-1].UserID
}
// seatOf locates a player on the roster. The false return is the "you are not in
// this fight" answer every party-aware command needs before it touches a turn.
func (s *CombatSession) seatOf(userID id.UserID) (int, bool) {
u := string(userID)
if s.UserID == u {
return 0, true
}
for i, p := range s.Participants {
if p.UserID == u {
return i + 1, true
}
}
return 0, false
}
// seatAlive reports whether a seat is still standing. A downed seat forfeits its
// turn silently rather than blocking the round on a corpse.
func (s *CombatSession) seatAlive(seat int) bool { return s.seatHP(seat) > 0 }
// seatIsAutopiloted reports whether a seat has been latched onto the auto-picker
// by a lapsed turn deadline.
func (s *CombatSession) seatIsAutopiloted(seat int) bool {
return s.actorStatusesForSeat(seat).Autopilot
}
// seatNeedsNoHuman reports whether the engine can resolve a seat's turn without
// waiting on its player: it is down (forfeits silently) or latched onto
// autopilot. driveCombatRound keeps stepping while this holds, so a round only
// comes to rest on a live human's turn.
func (s *CombatSession) seatNeedsNoHuman(seat int) bool {
return !s.seatAlive(seat) || s.seatIsAutopiloted(seat)
}
// Errors returned by the combat session layer.
var (
ErrCombatSessionAlreadyActive = errors.New("combat session already active for player")
@@ -207,7 +392,7 @@ const combatSessionCols = `
session_id, user_id, run_id, encounter_id, enemy_id,
round, phase, player_hp, player_hp_max, enemy_hp, enemy_hp_max,
statuses_json, turn_log_json, status,
started_at, last_action_at, expires_at`
started_at, last_action_at, expires_at, roster_size`
// newCombatSessionID — 16-char hex token. Same scheme as zone runs / expeditions.
func newCombatSessionID() string {
@@ -268,6 +453,80 @@ func startCombatSession(userID id.UserID, runID, encounterID, enemyID string, pl
return s, nil
}
// startPartyCombatSession opens a fight for a seated roster. seats[0] owns the
// session row (and is the expedition's leader); seats 1..N-1 get their own
// combat_participant rows. Each seat carries the HP pool and the fight-start
// one-shot resources (Abjuration's Arcane Ward, …) of its own character.
//
// A one-seat roster is exactly startCombatSession: no participant rows, no
// roster_size bump, and the single unwrapped INSERT the solo path has always
// issued. That is the invariant the whole balance corpus rests on.
func (p *AdventurePlugin) startPartyCombatSession(
runID, encounterID, enemyID string, enemy *Combatant, seats []CombatSeatSetup,
) (*CombatSession, error) {
if len(seats) == 0 {
return nil, fmt.Errorf("start combat session: empty roster")
}
enemyHP := enemy.Stats.MaxHP
owner := seats[0]
sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID,
owner.HP, owner.HPMax, enemyHP, enemyHP)
if err != nil {
return nil, err
}
// Seat 0's one-shots live on the session row; seeding them is a mutation of
// sess.Statuses that the save below flushes along with the participants.
dirty := seedCombatSessionOneShots(sess, owner.Mods)
if len(seats) > 1 {
ps := make([]CombatParticipant, 0, len(seats)-1)
for i, s := range seats[1:] {
var st ActorStatuses
seedActorOneShots(&st, s.Mods)
ps = append(ps, CombatParticipant{
Seat: i + 1, UserID: string(s.UserID), HP: s.HP, HPMax: s.HPMax, Statuses: st,
})
}
if err := insertCombatParticipants(sess.SessionID, ps); err != nil {
return nil, fmt.Errorf("seat party: %w", err)
}
sess.Participants = ps
sess.rosterSize = len(seats)
// startCombatSession parks every fight on a player_turn, because a solo
// order is hardcoded [player, enemy] and the player always leads. A party
// rolls for initiative and the monster can win it, so round 1's phase has
// to come from the order rather than from that assumption — otherwise the
// cursor snaps forward to the first player slot on resume and the enemy
// silently forfeits its opening turn. Round 2+ already derives this in
// stepRoundEnd.
order := turnOrder(sess, sess.Round, seatCombatants(seats), enemy)
sess.Phase = phaseForSeat(order[0])
sess.Statuses.TurnIdx = 0
dirty = true
}
if dirty {
if err := saveCombatSession(sess); err != nil {
return nil, fmt.Errorf("seed combat session: %w", err)
}
}
return sess, nil
}
// CombatSeatSetup is one character's entry into a fight: who they are, the HP
// pool they bring, the modifiers their fight-start one-shots are read off, and
// the built combatant itself — which the initiative roll needs, and which the
// caller threads on to the opening block rather than rebuilding the roster.
type CombatSeatSetup struct {
UserID id.UserID
HP int
HPMax int
Mods CombatModifiers
C *Combatant
}
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil).
func getActiveCombatSession(userID id.UserID) (*CombatSession, error) {
row := db.Get().QueryRow(`SELECT `+combatSessionCols+`
@@ -279,7 +538,10 @@ func getActiveCombatSession(userID id.UserID) (*CombatSession, error) {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return s, err
if err != nil {
return nil, err
}
return s, hydrateCombatParticipants(s)
}
// hasActiveCombatSession reports whether the player is currently locked into a
@@ -307,7 +569,10 @@ func getCombatSession(sessionID string) (*CombatSession, error) {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return s, err
if err != nil {
return nil, err
}
return s, hydrateCombatParticipants(s)
}
// getCombatSessionForEncounter returns the most recent session for a
@@ -324,7 +589,10 @@ func getCombatSessionForEncounter(runID, encounterID string) (*CombatSession, er
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return s, err
if err != nil {
return nil, err
}
return s, hydrateCombatParticipants(s)
}
func scanCombatSession(row scanner) (*CombatSession, error) {
@@ -337,7 +605,7 @@ func scanCombatSession(row scanner) (*CombatSession, error) {
&s.SessionID, &s.UserID, &s.RunID, &s.EncounterID, &s.EnemyID,
&s.Round, &s.Phase, &s.PlayerHP, &s.PlayerHPMax, &s.EnemyHP, &s.EnemyHPMax,
&statusesJSON, &logJSON, &s.Status,
&s.StartedAt, &s.LastActionAt, &s.ExpiresAt,
&s.StartedAt, &s.LastActionAt, &s.ExpiresAt, &s.rosterSize,
); err != nil {
return nil, err
}
@@ -360,32 +628,68 @@ func scanCombatSession(row scanner) (*CombatSession, error) {
// saveCombatSession persists the mutable fields after a state-machine step.
// session_id / user_id / run_id / encounter_id / enemy_id / *_hp_max are
// immutable for a fight's lifetime and are not rewritten.
//
// A party fight also writes back its seats, in the same transaction as the
// session row: a crash between the two would leave seat 0 a round ahead of the
// rest of the roster. The solo path keeps its single unwrapped UPDATE — there
// are no seats to desync from, and it is the path the whole balance corpus and
// every pre-N3 fight take.
//
// roster_size is not rewritten here: insertCombatParticipants stamped it at
// fight start and the roster is fixed for the fight's lifetime (a dead member
// keeps their seat at 0 HP).
func saveCombatSession(s *CombatSession) error {
statusesJSON, _ := json.Marshal(s.Statuses)
logJSON, _ := json.Marshal(s.TurnLog)
now := time.Now().UTC()
if _, err := db.Get().Exec(`
UPDATE combat_session
SET round = ?, phase = ?,
player_hp = ?, enemy_hp = ?,
statuses_json = ?, turn_log_json = ?,
status = ?, last_action_at = ?
WHERE session_id = ?`,
if len(s.Participants) == 0 {
if _, err := db.Get().Exec(saveCombatSessionSQL,
s.Round, s.Phase, s.PlayerHP, s.EnemyHP,
string(statusesJSON), string(logJSON), s.Status, now, s.SessionID,
); err != nil {
return err
}
s.LastActionAt = now
return nil
}
tx, err := db.Get().Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(saveCombatSessionSQL,
s.Round, s.Phase, s.PlayerHP, s.EnemyHP,
string(statusesJSON), string(logJSON), s.Status, now, s.SessionID,
); err != nil {
return err
}
if err := saveCombatParticipantsTx(tx, s.SessionID, s.Participants); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
s.LastActionAt = now
return nil
}
const saveCombatSessionSQL = `
UPDATE combat_session
SET round = ?, phase = ?,
player_hp = ?, enemy_hp = ?,
statuses_json = ?, turn_log_json = ?,
status = ?, last_action_at = ?
WHERE session_id = ?`
// listExpiredCombatSessions returns every active session past its expires_at.
// The timeout reaper (reapExpiredCombatSessions) auto-plays each of these to a
// real win/loss from persisted mid-state — per the 2026-05-13 decision, an
// abandoned fight is finished by the engine, not flatly marked as a retreat.
func listExpiredCombatSessions() ([]*CombatSession, error) {
rows, err := db.Get().Query(`SELECT `+combatSessionCols+`
rows, err := db.Get().Query(`SELECT ` + combatSessionCols + `
FROM combat_session
WHERE status = 'active'
AND expires_at <= CURRENT_TIMESTAMP
@@ -403,9 +707,162 @@ func listExpiredCombatSessions() ([]*CombatSession, error) {
}
out = append(out, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Hydrate only after the cursor is closed: a nested query while iterating
// can stall on a single-connection pool.
rows.Close()
for _, s := range out {
if err := hydrateCombatParticipants(s); err != nil {
return nil, err
}
}
return out, nil
}
// ── Party seats (N3/P4) ──────────────────────────────────────────────────────
// hydrateCombatParticipants fills s.Participants for a party session. A solo
// session (roster_size 1, which is every row written before N3) returns
// immediately without touching combat_participant, so the common path stays at
// the single query it has always been.
func hydrateCombatParticipants(s *CombatSession) error {
if s == nil || s.rosterSize <= 1 {
return nil
}
ps, err := loadCombatParticipants(s.SessionID)
if err != nil {
return err
}
if got := 1 + len(ps); got != s.rosterSize {
return fmt.Errorf("combat session %s: roster_size %d but %d seats persisted",
s.SessionID, s.rosterSize, got)
}
s.Participants = ps
return nil
}
// loadCombatParticipants returns seats 1..N-1 in seat order, verifying that the
// seats are contiguous from 1. The engine indexes the roster positionally, so a
// gap would silently shift every member down a seat — better to fail the load.
func loadCombatParticipants(sessionID string) ([]CombatParticipant, error) {
rows, err := db.Get().Query(`
SELECT seat, user_id, hp, hp_max, statuses_json
FROM combat_participant
WHERE session_id = ?
ORDER BY seat ASC`, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CombatParticipant
for rows.Next() {
var (
p CombatParticipant
statusesJSON string
)
if err := rows.Scan(&p.Seat, &p.UserID, &p.HP, &p.HPMax, &statusesJSON); err != nil {
return nil, err
}
if statusesJSON != "" {
if err := json.Unmarshal([]byte(statusesJSON), &p.Statuses); err != nil {
return nil, fmt.Errorf("decode participant statuses (seat %d): %w", p.Seat, err)
}
}
if want := len(out) + 1; p.Seat != want {
return nil, fmt.Errorf("combat session %s: seat %d out of order (want %d)",
sessionID, p.Seat, want)
}
out = append(out, p)
}
return out, rows.Err()
}
// insertCombatParticipants seats the party at fight start and stamps
// roster_size so later loads know to come back for them. Seat 0 is the session
// row's own user and is not written here; callers pass seats 1..N-1.
func insertCombatParticipants(sessionID string, ps []CombatParticipant) error {
if len(ps) == 0 {
return nil
}
tx, err := db.Get().Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, p := range ps {
statusesJSON, _ := json.Marshal(p.Statuses)
if _, err := tx.Exec(`
INSERT INTO combat_participant (session_id, seat, user_id, hp, hp_max, statuses_json)
VALUES (?, ?, ?, ?, ?, ?)`,
sessionID, p.Seat, p.UserID, p.HP, p.HPMax, string(statusesJSON),
); err != nil {
return fmt.Errorf("insert participant seat %d: %w", p.Seat, err)
}
}
if _, err := tx.Exec(
`UPDATE combat_session SET roster_size = ? WHERE session_id = ?`,
1+len(ps), sessionID,
); err != nil {
return fmt.Errorf("stamp roster_size: %w", err)
}
return tx.Commit()
}
// saveCombatParticipantsTx writes back the mutable half of every seat after an
// engine step. session_id / seat / user_id / hp_max are immutable for a fight's
// lifetime and are not rewritten. It runs inside the caller's transaction so
// the seats commit together with the session row that indexes them.
func saveCombatParticipantsTx(tx *sql.Tx, sessionID string, ps []CombatParticipant) error {
for _, p := range ps {
statusesJSON, _ := json.Marshal(p.Statuses)
if _, err := tx.Exec(`
UPDATE combat_participant
SET hp = ?, statuses_json = ?
WHERE session_id = ? AND seat = ?`,
p.HP, string(statusesJSON), sessionID, p.Seat,
); err != nil {
return fmt.Errorf("save participant seat %d: %w", p.Seat, err)
}
}
return nil
}
// getActiveCombatSessionForMember finds the in-flight fight a player is seated
// in at seat 1+, i.e. the one their own user_id does not key. Seat 0's fight is
// getActiveCombatSession's job; this is the party-member equivalent, and it
// returns (nil, nil) when there is none.
func getActiveCombatSessionForMember(userID id.UserID) (*CombatSession, error) {
row := db.Get().QueryRow(`SELECT `+prefixCols("cs", combatSessionCols)+`
FROM combat_session cs
JOIN combat_participant cp ON cp.session_id = cs.session_id
WHERE cp.user_id = ? AND cs.status = 'active'
ORDER BY cs.started_at DESC
LIMIT 1`, string(userID))
s, err := scanCombatSession(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return s, hydrateCombatParticipants(s)
}
// prefixCols qualifies every column in a SELECT list with a table alias, so the
// shared combatSessionCols can be reused inside a join without ambiguity on the
// columns both tables carry (session_id, user_id, statuses_json).
func prefixCols(alias, cols string) string {
fields := strings.Split(cols, ",")
for i, f := range fields {
fields[i] = alias + "." + strings.TrimSpace(f)
}
return strings.Join(fields, ", ")
}
// markCombatSessionExpired is the fallback terminal outcome for a stale session
// the reaper cannot auto-play (zone run gone, enemy missing from the bestiary,
// or a runaway fight that won't converge). It parks the row in 'expired'/'over'

View File

@@ -107,41 +107,79 @@ func (p *AdventurePlugin) buildZoneCombatants(
return player, enemy, dndChar, nil
}
// combatantsForSession reconstructs the Combatant pair for an in-flight
// CombatSession. It reloads the zone run for the DM mood + tier and looks the
// enemy up in the bestiary by the session's EnemyID. The pair carries no HP —
// the turn engine reads that from the session row.
// combatantsForSession reconstructs the Combatant pair for an in-flight solo
// CombatSession — the shape every caller wanted before parties existed. It is
// partyCombatantsForSession reduced to seat 0, and it errors rather than
// silently dropping the rest of a party's roster on the floor.
func (p *AdventurePlugin) combatantsForSession(sess *CombatSession) (player Combatant, enemy Combatant, err error) {
if sess.IsParty() {
return Combatant{}, Combatant{}, fmt.Errorf(
"combat session %s seats %d players; use partyCombatantsForSession", sess.SessionID, sess.RosterSize())
}
players, e, err := p.partyCombatantsForSession(sess)
if err != nil {
return Combatant{}, Combatant{}, err
}
return *players[0], *e, nil
}
// partyCombatantsForSession reconstructs the seated roster and the enemy for an
// in-flight CombatSession. It reloads the zone run for the DM mood + tier and
// looks the enemy up in the bestiary by the session's EnemyID. The combatants
// carry no HP — the turn engine reads that from the session row and the
// participant rows.
//
// Every seat is rebuilt from *its own* player: their sheet, their equipment,
// their passives, their magic items. That is N times the per-round DB chatter
// combatantsForSession already had (project_combat_session_cache_deferred), and
// a party of 3 pays it three times over. Solo — every fight that has ever run,
// and the whole balance corpus — still issues exactly one build.
func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Combatant, *Combatant, error) {
run, rerr := getZoneRun(sess.RunID)
if rerr != nil {
return Combatant{}, Combatant{}, fmt.Errorf("load zone run: %w", rerr)
return nil, nil, fmt.Errorf("load zone run: %w", rerr)
}
if run == nil {
return Combatant{}, Combatant{}, fmt.Errorf("combat session %s: zone run %s not found", sess.SessionID, sess.RunID)
return nil, nil, fmt.Errorf("combat session %s: zone run %s not found", sess.SessionID, sess.RunID)
}
monster, ok := dndBestiary[sess.EnemyID]
if !ok {
return Combatant{}, Combatant{}, fmt.Errorf("combat session %s: enemy %q not in bestiary", sess.SessionID, sess.EnemyID)
return nil, nil, fmt.Errorf("combat session %s: enemy %q not in bestiary", sess.SessionID, sess.EnemyID)
}
zone := zoneOrFallback(run.ZoneID)
player, enemy, _, err = p.buildZoneCombatants(id.UserID(sess.UserID), monster, int(zone.Tier), run.DMMood)
if err != nil {
return player, enemy, err
seats := sess.SeatUserIDs()
players := make([]*Combatant, len(seats))
var enemy Combatant
for seat, uid := range seats {
player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood)
if err != nil {
return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
}
// Fold any fight-scoped buffs this seat's mid-fight !cast / !consume
// layered on back onto their freshly-rebuilt combatant. The depleting
// one-shots (ward/spore/…) live on their persisted statuses and flow
// through the turn engine's resume/commit cycle, so only the persistent
// stat deltas are applied here — and only that seat's own.
applySessionBuffs(&player, sess.actorStatusesForSeat(seat))
players[seat] = &player
if seat == 0 {
// The enemy build reads only (monster, tier, dmMood): every seat
// rebuilds the identical stat block, so seat 0's copy is the fight's.
// Only the *player* half of the build varies by seat.
enemy = e
}
}
// Fold any fight-scoped buffs a mid-fight !cast / !consume layered on back
// onto the freshly-rebuilt player. The depleting one-shots (ward/spore/…)
// live on the session's Statuses and flow through the turn engine's
// resume/commit cycle, so only the persistent stat deltas are applied here.
applySessionBuffs(&player, sess.Statuses)
return player, enemy, err
return players, &enemy, nil
}
// applySessionBuffs re-derives the persistent stat effect of every mid-fight
// buff onto the rebuilt player. The buffs are stored as accumulated deltas
// (diffTurnBuff produced them against the player's state at cast time), so
// buff onto one rebuilt character. The buffs are stored as accumulated deltas
// (diffTurnBuff produced them against that character's state at cast time), so
// re-applying them to a deterministic rebuild reproduces the same totals every
// round without double-counting.
func applySessionBuffs(player *Combatant, s CombatStatuses) {
// round without double-counting. It takes ActorStatuses rather than the whole
// session because buffs are per-caster: each seat folds in only its own.
func applySessionBuffs(player *Combatant, s ActorStatuses) {
player.Stats.AC += s.BuffACBonus
player.Stats.AttackBonus += s.BuffAtkBonus
player.Stats.Speed += s.BuffSpeedBonus

View File

@@ -52,7 +52,7 @@ func TestStartCombatSession_RoundTrip(t *testing.T) {
got.Phase = CombatPhaseRoundEnd
got.PlayerHP = 40
got.EnemyHP = 15
got.Statuses = CombatStatuses{PoisonTicks: 2, PoisonDmg: 5, Enraged: true}
got.Statuses = CombatStatuses{ActorStatuses: ActorStatuses{PoisonTicks: 2, PoisonDmg: 5}, Enraged: true}
got.TurnLog = append(got.TurnLog, CombatEvent{Round: 3, Actor: "player", Action: "hit", Damage: 9})
if err := saveCombatSession(got); err != nil {
t.Fatalf("saveCombatSession: %v", err)
@@ -67,7 +67,7 @@ func TestStartCombatSession_RoundTrip(t *testing.T) {
if reloaded.PlayerHP != 40 || reloaded.EnemyHP != 15 {
t.Errorf("hp not persisted: %+v", reloaded)
}
if reloaded.Statuses != (CombatStatuses{PoisonTicks: 2, PoisonDmg: 5, Enraged: true}) {
if reloaded.Statuses != (CombatStatuses{ActorStatuses: ActorStatuses{PoisonTicks: 2, PoisonDmg: 5}, Enraged: true}) {
t.Errorf("statuses not persisted: %+v", reloaded.Statuses)
}
if len(reloaded.TurnLog) != 1 || reloaded.TurnLog[0].Action != "hit" {
@@ -181,7 +181,7 @@ func turnSession(phase string, playerHP, enemyHP int) *CombatSession {
func stepEngine(sess *CombatSession, player, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
rng := rand.New(rand.NewPCG(42, phaseOrdinal(sess.Phase)))
te := resumeTurnEngine(sess, player, enemy, rng)
te := resumeTurnEngine(sess, []*Combatant{player}, enemy, rng)
events, err := te.step(action)
if err != nil {
return nil, err
@@ -223,7 +223,7 @@ func TestTurnEngine_PhaseProgression(t *testing.T) {
func TestTurnEngine_PoisonTickAtRoundEnd(t *testing.T) {
sess := turnSession(CombatPhaseRoundEnd, 50, 80)
sess.Statuses = CombatStatuses{PoisonTicks: 2, PoisonDmg: 7}
sess.Statuses = CombatStatuses{ActorStatuses: ActorStatuses{PoisonTicks: 2, PoisonDmg: 7}}
player, enemy := basePlayer(), baseEnemy()
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
@@ -246,7 +246,7 @@ func TestTurnEngine_PoisonTickAtRoundEnd(t *testing.T) {
func TestTurnEngine_PoisonTickCanBeLethal(t *testing.T) {
sess := turnSession(CombatPhaseRoundEnd, 4, 80)
sess.Statuses = CombatStatuses{PoisonTicks: 1, PoisonDmg: 9}
sess.Statuses = CombatStatuses{ActorStatuses: ActorStatuses{PoisonTicks: 1, PoisonDmg: 9}}
player, enemy := basePlayer(), baseEnemy()
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
@@ -263,6 +263,74 @@ func TestTurnEngine_PoisonTickCanBeLethal(t *testing.T) {
}
}
func TestTurnEngine_ConcentrationTickAtRoundEnd(t *testing.T) {
sess := turnSession(CombatPhaseRoundEnd, 50, 80)
sess.Statuses = CombatStatuses{ActorStatuses: ActorStatuses{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{ActorStatuses: ActorStatuses{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()
@@ -307,7 +375,7 @@ func TestTurnEngine_StepRejectsTerminalSession(t *testing.T) {
player, enemy := basePlayer(), baseEnemy()
rng := rand.New(rand.NewPCG(1, 1))
te := resumeTurnEngine(sess, &player, &enemy, rng)
te := resumeTurnEngine(sess, []*Combatant{&player}, &enemy, rng)
if _, err := te.step(PlayerAction{Kind: ActionAttack}); err != errCombatSessionOver {
t.Errorf("err = %v, want errCombatSessionOver", err)
}
@@ -365,7 +433,7 @@ func TestApplyBuffDelta(t *testing.T) {
func TestApplySessionBuffs(t *testing.T) {
player := basePlayer() // AC 0, AttackBonus 0, CritRate 0.05, Speed 10, DamageReduct 1.0
applySessionBuffs(&player, CombatStatuses{
applySessionBuffs(&player, ActorStatuses{
BuffACBonus: 3, BuffAtkBonus: 2, BuffSpeedBonus: 5, BuffCritRate: 0.15,
BuffDamageBonus: 0.25, BuffDamageReductMul: 0.8, BuffPetProc: 0.5, BuffPetDmg: 6,
})
@@ -405,7 +473,7 @@ func TestSeedCombatSessionOneShots(t *testing.T) {
// re-bank a depleted ward charge — on every resumed round.
func TestTurnEngine_OneShotsRoundTrip(t *testing.T) {
sess := turnSession(CombatPhaseRoundEnd, 50, 50)
sess.Statuses = CombatStatuses{
sess.Statuses = CombatStatuses{ActorStatuses: ActorStatuses{
WardCharges: 3, SporeRounds: 2, ReflectFrac: 0.5, AutoCritFirst: true,
ArcaneWardHP: 10, HealChargesLeft: 1,
Raged: true, LuckyUsed: true, DeathSaveUsed: true, PendingRage: true,
@@ -413,7 +481,7 @@ func TestTurnEngine_OneShotsRoundTrip(t *testing.T) {
// Buff stat deltas are owned by the command layer — commit must leave
// them untouched rather than zeroing them on every step.
BuffACBonus: 2, BuffDamageBonus: 0.15,
}
}}
want := sess.Statuses // round_end with no poison mutates none of these
player, enemy := basePlayer(), baseEnemy()

View File

@@ -218,8 +218,8 @@ func DeriveDungeonMonsterStats(loc *AdvLocation) (CombatStats, CombatModifiers)
death := loc.BaseDeathPct // 8 to 60
stats := CombatStats{
MaxHP: 12 + int(t*7+death*0.3), // T1≈22, T5≈65 — sized so dnd-scale players can finish a kill within the phase budget
Attack: int(t*1.5) + int(death*0.04), // T1=1, T5=9 — tuned to dnd HP pools (players have 1380 HP, not 100+)
MaxHP: 12 + int(t*7+death*0.3), // T1≈22, T5≈65 — sized so dnd-scale players can finish a kill within the phase budget
Attack: int(t*1.5) + int(death*0.04), // T1=1, T5=9 — tuned to dnd HP pools (players have 1380 HP, not 100+)
Defense: 2 + int(t*1.2),
Speed: 4 + int(t*1.5),
CritRate: 0.03 + death*0.003,

View File

@@ -284,11 +284,11 @@ func TestBalanceRegression_DungeonDeathRates(t *testing.T) {
// drift), not 1:1 well-equipped combat. These bounds reflect the
// new "fairly breezy with some death" target.
cases := []tc{
{1, 1, advDungeons[0], 0.03, 0.0}, // T1: trivial with proper gear
{2, 2, advDungeons[1], 0.05, 0.0}, // T2: trivial at level
{5, 3, advDungeons[2], 0.08, 0.0}, // T3: low risk
{7, 4, advDungeons[3], 0.15, 0.0}, // T4: still mostly safe
{9, 5, advDungeons[4], 0.25, 0.0}, // T5: occasional bad luck
{1, 1, advDungeons[0], 0.03, 0.0}, // T1: trivial with proper gear
{2, 2, advDungeons[1], 0.05, 0.0}, // T2: trivial at level
{5, 3, advDungeons[2], 0.08, 0.0}, // T3: low risk
{7, 4, advDungeons[3], 0.15, 0.0}, // T4: still mostly safe
{9, 5, advDungeons[4], 0.25, 0.0}, // T5: occasional bad luck
}
for _, c := range cases {

View File

@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"math/rand/v2"
"sort"
)
// Phase 13 — Turn-based combat state machine.
@@ -64,31 +65,76 @@ 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
}
// enemySeat is the sentinel roster index the monster occupies in a turn order.
// Player seats are the roster indices 0..len(actors)-1.
const enemySeat = -1
// turnEngine wraps a combatState reconstructed from a persisted CombatSession
// so a single phase can be resolved and then written back.
//
// players is the seated roster in roster order; order is this round's seat
// sequence, which interleaves the roster with enemySeat. A solo fight's order
// is always [0, enemySeat] — the fixed player-then-enemy sequence the engine
// has always run — so no initiative is rolled and its RNG stream is untouched.
type turnEngine struct {
sess *CombatSession
player *Combatant
enemy *Combatant
st *combatState
result *CombatResult
sess *CombatSession
players []*Combatant
enemy *Combatant
order []int
st *combatState
result *CombatResult
}
// combatSessionSeed hashes the session id into the base PCG seed.
func combatSessionSeed(sess *CombatSession) uint64 {
var seed uint64 = 1469598103934665603 // FNV-ish offset basis
for _, c := range sess.SessionID {
seed = (seed ^ uint64(c)) * 1099511628211
}
return seed
}
// combatSessionRNG seeds a deterministic generator from the session id and the
// phase being resolved, so a fight replays reproducibly no matter how many bot
// restarts occur mid-fight. Each (round, phase) gets a distinct stream — a flat
// per-session seed would correlate the player's and enemy's d20s within a round.
//
// This is the solo form of combatSessionStepRNG, kept as the name the engine's
// single-seat callers and tests use.
func combatSessionRNG(sess *CombatSession) *rand.Rand {
var seed uint64 = 1469598103934665603 // FNV-ish offset basis
for _, c := range sess.SessionID {
seed = (seed ^ uint64(c)) * 1099511628211
return combatSessionStepRNG(sess, 0)
}
// combatSessionStepRNG seeds the generator for one step resolved by one seat.
// A round holds one player_turn step per party member, and they all share a
// (round, phase) pair — so the acting seat is mixed into the *seed* to keep
// their d20s independent. Seat 0 and the enemy sentinel mix to nothing, which
// is what makes a solo fight draw exactly the pre-roster stream.
func combatSessionStepRNG(sess *CombatSession, seat int) *rand.Rand {
seed := combatSessionSeed(sess)
if seat > 0 {
seed ^= uint64(seat) * 0x9E3779B97F4A7C15 // golden-ratio odd multiplier
}
stream := uint64(sess.Round)*4 + phaseOrdinal(sess.Phase)
return rand.New(rand.NewPCG(seed, stream))
}
// combatInitiativeRNG seeds the once-per-round initiative roll. It takes the
// round explicitly because stepRoundEnd must order the round it is opening,
// not the one it is closing, and sess.Round is only advanced by commit.
// A distinct seed mix keeps it clear of every step stream.
func combatInitiativeRNG(sess *CombatSession, round int) *rand.Rand {
return rand.New(rand.NewPCG(combatSessionSeed(sess)^0xD1B54A32D192ED03, uint64(round)))
}
func phaseOrdinal(phase string) uint64 {
switch phase {
case CombatPhasePlayerTurn:
@@ -102,36 +148,200 @@ func phaseOrdinal(phase string) uint64 {
}
}
// turnOrder returns the seat sequence for one round: the player roster and the
// enemy sentinel, highest initiative first. Initiative mirrors the auto-resolve
// engine's formula (speed + d10 + InitiativeBias); ties break toward the lower
// seat, and the enemy loses every tie.
//
// A solo roster short-circuits to the engine's historical fixed order and rolls
// nothing — the turn-based engine has never had initiative, and giving one seat
// a coin-flip on who swings first would be a live balance change.
func turnOrder(sess *CombatSession, round int, players []*Combatant, enemy *Combatant) []int {
if len(players) <= 1 {
return []int{0, enemySeat}
}
type entry struct {
seat int
init float64
}
rng := combatInitiativeRNG(sess, round)
entries := make([]entry, 0, len(players)+1)
for i, p := range players {
entries = append(entries, entry{i, float64(p.Stats.Speed) + rngFloat(rng)*10 + p.Mods.InitiativeBias})
}
entries = append(entries, entry{enemySeat, float64(enemy.Stats.Speed) + rngFloat(rng)*10})
sort.SliceStable(entries, func(i, j int) bool {
a, b := entries[i], entries[j]
if a.init != b.init {
return a.init > b.init
}
if (a.seat == enemySeat) != (b.seat == enemySeat) {
return b.seat == enemySeat
}
return a.seat < b.seat
})
order := make([]int, len(entries))
for i, e := range entries {
order[i] = e.seat
}
return order
}
// phaseForSeat names the phase under which a seat resolves its turn.
func phaseForSeat(seat int) string {
if seat == enemySeat {
return CombatPhaseEnemyTurn
}
return CombatPhasePlayerTurn
}
// stepSeat is the seat that resolves the session's current phase: the acting
// player on a player_turn, the enemy sentinel on anything else.
//
// It is the *single* derivation of "whose step is this". advancePartyCombatSession
// needs it before the engine is resumed, because the seat seeds that step's RNG;
// the command layer needs it to tell a member it is not their turn. Both read
// only (sessionID, round, roster), so they agree by construction.
func stepSeat(sess *CombatSession, players []*Combatant, enemy *Combatant) int {
if sess.Phase != CombatPhasePlayerTurn {
return enemySeat
}
order := turnOrder(sess, sess.Round, players, enemy)
return order[turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)]
}
// actingSeat names the player seat currently on the clock. The false return
// means the fight is not waiting on anybody: it is mid enemy-turn, mid
// round-end, or over.
func actingSeat(sess *CombatSession, players []*Combatant, enemy *Combatant) (int, bool) {
if !sess.IsActive() || sess.Phase != CombatPhasePlayerTurn {
return 0, false
}
return stepSeat(sess, players, enemy), true
}
// turnIdxForPhase reconciles a persisted cursor against the persisted phase.
// They can disagree for exactly one reason: a fight that was in flight when
// TurnIdx was introduced decodes as 0 regardless of whose turn it is. Phase is
// the older, load-bearing field, so it wins; the cursor snaps to the first slot
// of the matching kind. On a solo order ([player, enemy]) that is exact.
func turnIdxForPhase(order []int, idx int, phase string) int {
if idx >= 0 && idx < len(order) && phaseForSeat(order[idx]) == phase {
return idx
}
for i, seat := range order {
if phaseForSeat(seat) == phase {
return i
}
}
return 0
}
// resumeTurnEngine rebuilds the in-memory combatState from a persisted session.
// rng is the deterministic source for this step (see combatSessionRNG).
func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.Rand) *turnEngine {
// rng is the deterministic source for this step (see combatSessionStepRNG).
//
// restoreActor rebuilds one seat from its persisted state: live HP, the pool
// ceiling, and the per-character effect set. Depleting resources and the
// once-per-fight "already used" flags are restored rather than rearmed, so a
// charge can't silently reset across a suspend/resume or a reaper auto-play.
// snapshotActor is its exact inverse — keep the two field lists in step.
func restoreActor(c *Combatant, hp, hpMax int, s ActorStatuses) *actor {
return &actor{
c: c,
playerHP: hp,
hpMax: hpMax,
poisonTicks: s.PoisonTicks,
poisonDmg: s.PoisonDmg,
stunPlayer: s.StunPlayer,
wardCharges: s.WardCharges,
sporeRounds: s.SporeRounds,
reflectFrac: s.ReflectFrac,
autoCrit: s.AutoCritFirst,
arcaneWardHP: s.ArcaneWardHP,
healChargesLeft: s.HealChargesLeft,
concentrationDmg: s.ConcentrationDmg,
deathSaveUsed: s.DeathSaveUsed,
luckyUsed: s.LuckyUsed,
raged: s.Raged,
pendingRageAttack: s.PendingRage,
firstAttackBonusUsed: s.FirstAtkBonusUsed,
assassinateRerollUsed: s.AssassinateReroll,
assassinateBonusUsed: s.AssassinateBonus,
// Enemy debuffs stacked onto this character specifically.
playerAtkDrain: s.PlayerAtkDrain,
playerACDebuff: s.PlayerACDebuff,
maxHPDrain: s.MaxHPDrain,
}
}
// snapshotActor folds one seat's live state back into its persisted form. The
// Buff* deltas are owned by the command layer (a !cast / !consume folds them in
// via applyBuffDelta) and are not combatState fields, so they are carried over
// from the prior snapshot rather than re-derived.
func snapshotActor(a *actor, prior ActorStatuses) ActorStatuses {
s := prior
s.PoisonTicks = a.poisonTicks
s.PoisonDmg = a.poisonDmg
s.StunPlayer = a.stunPlayer
s.WardCharges = a.wardCharges
s.SporeRounds = a.sporeRounds
s.ReflectFrac = a.reflectFrac
s.AutoCritFirst = a.autoCrit
s.ArcaneWardHP = a.arcaneWardHP
s.HealChargesLeft = a.healChargesLeft
s.ConcentrationDmg = a.concentrationDmg
s.DeathSaveUsed = a.deathSaveUsed
s.LuckyUsed = a.luckyUsed
s.Raged = a.raged
s.PendingRage = a.pendingRageAttack
s.FirstAtkBonusUsed = a.firstAttackBonusUsed
s.AssassinateReroll = a.assassinateRerollUsed
s.AssassinateBonus = a.assassinateBonusUsed
s.PlayerAtkDrain = a.playerAtkDrain
s.PlayerACDebuff = a.playerACDebuff
s.MaxHPDrain = a.maxHPDrain
return s
}
// Every seat restores its own persisted per-character statuses: seat 0 from the
// session row's embedded ActorStatuses, seats 1+ from their combat_participant
// row. Without that, a party member's once-per-fight one-shots would rearm on
// every engine step. players[i] must be the combatant for seat i — the caller
// owns that ordering, and it must match sess.Participants[i-1].UserID.
func resumeTurnEngine(sess *CombatSession, players []*Combatant, enemy *Combatant, rng *rand.Rand) *turnEngine {
seat0 := restoreActor(players[0], sess.PlayerHP, sess.PlayerHPMax, sess.Statuses.ActorStatuses)
actors := make([]*actor, len(players))
actors[0] = seat0
for i := 1; i < len(players); i++ {
// A roster longer than the persisted seats can only happen if a caller
// hands us combatants the session never seated. Open those fresh rather
// than panicking mid-fight; hydrateCombatParticipants already rejects
// the reverse (a session claiming more seats than it persisted).
if i-1 >= len(sess.Participants) {
actors[i] = newActor(players[i])
continue
}
p := sess.Participants[i-1]
actors[i] = restoreActor(players[i], p.HP, p.HPMax, p.Statuses)
}
st := &combatState{
playerHP: sess.PlayerHP,
actor: seat0,
actors: actors,
enemyHP: sess.EnemyHP,
round: sess.Round,
poisonTicks: sess.Statuses.PoisonTicks,
poisonDmg: sess.Statuses.PoisonDmg,
stunPlayer: sess.Statuses.StunPlayer,
enraged: sess.Statuses.Enraged,
armorBroken: sess.Statuses.ArmorBroken,
armorBreakAmt: sess.Statuses.ArmorBreakAmt,
enemySkipFirst: sess.Statuses.EnemySkipNext,
// Fight-scoped depleting resources + once-per-fight one-shots: restored
// from the persisted statuses so a charge or "already used" flag can't
// reset across a suspend/resume. commit writes the updated values back.
wardCharges: sess.Statuses.WardCharges,
sporeRounds: sess.Statuses.SporeRounds,
reflectFrac: sess.Statuses.ReflectFrac,
autoCrit: sess.Statuses.AutoCritFirst,
arcaneWardHP: sess.Statuses.ArcaneWardHP,
healChargesLeft: sess.Statuses.HealChargesLeft,
deathSaveUsed: sess.Statuses.DeathSaveUsed,
luckyUsed: sess.Statuses.LuckyUsed,
raged: sess.Statuses.Raged,
pendingRageAttack: sess.Statuses.PendingRage,
firstAttackBonusUsed: sess.Statuses.FirstAtkBonusUsed,
assassinateRerollUsed: sess.Statuses.AssassinateReroll,
assassinateBonusUsed: sess.Statuses.AssassinateBonus,
// Slice-3 stateful monster-ability effects — armed by applyAbility,
// round-tripped here so they survive a suspend/resume or reaper auto-play.
enemyEvadeNext: sess.Statuses.EnemyEvadeNext,
@@ -140,9 +350,6 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
enemyRetaliateFrac: sess.Statuses.EnemyRetaliateFrac,
enemyRegen: sess.Statuses.EnemyRegen,
enemySurviveArmed: sess.Statuses.EnemySurviveArmed,
playerAtkDrain: sess.Statuses.PlayerAtkDrain,
playerACDebuff: sess.Statuses.PlayerACDebuff,
maxHPDrain: sess.Statuses.MaxHPDrain,
// Slice-4 monster-ability effects — the former flavor-only placeholders.
enemySpellResist: sess.Statuses.EnemySpellResist,
enemyRevealNext: sess.Statuses.EnemyRevealNext,
@@ -150,19 +357,48 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
enemyAtkBuff: sess.Statuses.EnemyAtkBuff,
rng: rng,
}
return &turnEngine{
sess: sess,
player: player,
enemy: enemy,
st: st,
result: &CombatResult{},
order := turnOrder(sess, sess.Round, players, enemy)
sess.Statuses.TurnIdx = turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)
if sess.Phase == CombatPhasePlayerTurn {
st.seat(order[sess.Statuses.TurnIdx])
}
return &turnEngine{
sess: sess,
players: players,
enemy: enemy,
order: order,
st: st,
result: &CombatResult{},
}
}
// advance moves the round cursor to the next seat, or to round_end once every
// seat has acted.
func (te *turnEngine) advance() {
te.sess.Statuses.TurnIdx++
if te.sess.Statuses.TurnIdx >= len(te.order) {
te.sess.Phase = CombatPhaseRoundEnd
return
}
te.sess.Phase = phaseForSeat(te.order[te.sess.Statuses.TurnIdx])
}
// step resolves exactly one phase of the fight and advances sess.Phase. The
// events generated this step are returned (also accumulated by commit into
// sess.TurnLog). It does not persist — call commit then saveCombatSession, or
// use advanceCombatSession which does both.
// stampSeat tags every event appended since mark with the roster seat it is
// about, so a party's play-by-play can name the right character. The primitives
// in combat_primitives.go emit against the cursor and know nothing of seats, so
// the tag is applied here, once per phase step, rather than at ~20 append sites.
//
// Solo stamps seat 0 onto seat-0 events — a no-op that omitempty erases.
func (te *turnEngine) stampSeat(mark, seat int) {
for i := mark; i < len(te.st.events); i++ {
te.st.events[i].Seat = seat
}
}
func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) {
if !te.sess.IsActive() {
return nil, errCombatSessionOver
@@ -170,9 +406,18 @@ func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) {
te.st.events = nil
switch te.sess.Phase {
case CombatPhasePlayerTurn:
// The cursor was seated on the acting player by resumeTurnEngine, and
// stepPlayerTurn never moves it, so every event this phase emits — the
// swings, the pet, the spirit weapon, the retaliate that kills them —
// belongs to that seat.
acting := te.st.seatIdx
te.stepPlayerTurn(action)
te.stampSeat(0, acting)
case CombatPhaseEnemyTurn:
te.stepEnemyTurn()
// stepEnemyTurn seats its target before anything resolves, and the whole
// phase lands on that one character.
te.stampSeat(0, te.st.seatIdx)
case CombatPhaseRoundEnd:
te.stepRoundEnd()
case CombatPhaseOver:
@@ -184,6 +429,13 @@ func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) {
}
func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
// A seat that went down earlier this round forfeits its turn silently.
// Unreachable while the roster is solo — a downed solo player has already
// ended the fight.
if te.st.playerHP <= 0 {
te.advance()
return
}
switch action.Kind {
case ActionFlee:
te.st.events = append(te.st.events, CombatEvent{
@@ -204,26 +456,34 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
// Extra Attack only fired in the auto-resolve SimulateCombat path; that
// left every elite/boss !fight at L11+ short two swings/turn, which the
// J1 boss-trace surfaced as Fighter's T3+ wall.
// Returns true once the fight is decided — usually the enemy is down,
// but a retaliate aura can drop the player on their own swing, so
// disambiguate the outcome by HP.
if resolvePlayerSwings(te.st, te.player, te.enemy, &turnCombatPhase, te.result) {
if te.st.playerHP <= 0 {
te.finish(CombatStatusLost)
} else {
// Returns true once the swing decided something — usually the enemy is
// down, but a retaliate aura can drop the swinger on their own attack. A
// downed swinger only ends the fight if nobody else is standing.
// The outcome is read off HP rather than the bool: resolvePlayerSwings also
// returns false when a retaliate aura kills the swinger between extra
// attacks, and the old code walked that corpse into the enemy's turn.
resolvePlayerSwings(te.st, te.st.c, te.enemy, &turnCombatPhase, te.result)
if te.st.enemyHP <= 0 {
te.finish(CombatStatusWon)
return
}
if !te.st.anyAlive() {
te.finish(CombatStatusLost)
return
}
// A swinger who went down to retaliate gets no bonus-action follow-ups.
// Solo never reaches here dead — anyAlive above would have ended the fight.
if te.st.playerHP > 0 {
if te.petStrike() {
te.finish(CombatStatusWon)
return
}
if te.spiritWeaponStrike() {
te.finish(CombatStatusWon)
return
}
return
}
if te.petStrike() {
te.finish(CombatStatusWon)
return
}
if te.spiritWeaponStrike() {
te.finish(CombatStatusWon)
return
}
te.sess.Phase = CombatPhaseEnemyTurn
te.advance()
}
// petStrike resolves the player's pet attack for a turn-based fight. The pet
@@ -236,10 +496,10 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
// the enemy.
func (te *turnEngine) petStrike() bool {
st := te.st
if te.player.Mods.PetAttackProc <= 0 || st.randFloat() >= te.player.Mods.PetAttackProc {
if st.c.Mods.PetAttackProc <= 0 || st.randFloat() >= st.c.Mods.PetAttackProc {
return false
}
petDmg := te.player.Mods.PetAttackDmg + st.roll(5)
petDmg := st.c.Mods.PetAttackDmg + st.roll(5)
st.enemyHP = max(0, st.enemyHP-petDmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "pet", Action: "pet_attack",
@@ -254,10 +514,10 @@ func (te *turnEngine) petStrike() bool {
// pet flavor on a petless caster. Returns true if the strike dropped the enemy.
func (te *turnEngine) spiritWeaponStrike() bool {
st := te.st
if te.player.Mods.SpiritWeaponProc <= 0 || st.randFloat() >= te.player.Mods.SpiritWeaponProc {
if st.c.Mods.SpiritWeaponProc <= 0 || st.randFloat() >= st.c.Mods.SpiritWeaponProc {
return false
}
dmg := te.player.Mods.SpiritWeaponDmg + st.roll(5)
dmg := st.c.Mods.SpiritWeaponDmg + st.roll(5)
st.enemyHP = max(0, st.enemyHP-dmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "spirit_weapon", Action: "spirit_weapon_strike",
@@ -275,7 +535,7 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
st := te.st
if eff == nil {
// Defensive: a kind with no payload just passes the turn.
te.sess.Phase = CombatPhaseEnemyTurn
te.advance()
return
}
action := eff.Action
@@ -296,9 +556,15 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
if eff.PlayerHeal > 0 {
// Respect any max_hp_drain monster ability — a drained player can't be
// healed back past the lowered ceiling.
hpCap := max(1, te.sess.PlayerHPMax-st.maxHPDrain)
hpCap := max(1, st.hpMax-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,
@@ -332,10 +598,23 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
st.enemySkipFirst = true
}
}
te.sess.Phase = CombatPhaseEnemyTurn
te.advance()
}
// enemyTarget picks the seat the monster swings at this turn: uniformly among
// the standing roster. A solo roster draws nothing, so its RNG stream keeps the
// pre-roster shape. Returns false when the whole roster is down.
func (te *turnEngine) enemyTarget() (int, bool) { return enemyTargetSeat(te.st) }
func (te *turnEngine) stepEnemyTurn() {
// Seat the target before anything reads the cursor: the ability path, the
// pet procs, and the swing loop all resolve against whoever is targeted.
target, ok := te.enemyTarget()
if !ok {
te.finish(CombatStatusLost)
return
}
te.st.seat(target)
// A control spell cast last phase forfeits the enemy's attack this round —
// unless the enemy is fear_immune, in which case the control fizzled and it
// acts as normal.
@@ -351,7 +630,7 @@ func (te *turnEngine) stepEnemyTurn() {
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held",
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
})
te.sess.Phase = CombatPhaseRoundEnd
te.advance()
return
}
}
@@ -363,8 +642,15 @@ func (te *turnEngine) stepEnemyTurn() {
// player went down without a save.
abilityDealtDamage := false
if te.enemy.Ability != nil && turnAbilityFires(te.enemy.Ability, te.st, te.enemy) {
if applyAbility(te.st, te.player, te.enemy, &turnCombatPhase, te.result) {
te.finish(CombatStatusLost)
if applyAbility(te.st, te.st.c, te.enemy, &turnCombatPhase, te.result) {
// The target went down without a save. The fight only ends if it
// took the last member with it; otherwise the enemy has spent its
// turn on that kill.
if !te.st.anyAlive() {
te.finish(CombatStatusLost)
} else {
te.advance()
}
return
}
switch te.enemy.Ability.Effect {
@@ -380,8 +666,8 @@ func (te *turnEngine) stepEnemyTurn() {
// remaining swings resolve normally — a single proc shouldn't nullify a
// boss's whole multiattack round. (This deliberately diverges from the
// auto-resolve engine's apply-to-all model.)
petWhiff := te.player.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.player.Mods.PetWhiffProc
petDeflect := te.player.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.player.Mods.PetDeflectProc
petWhiff := te.st.c.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.st.c.Mods.PetWhiffProc
petDeflect := te.st.c.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.st.c.Mods.PetDeflectProc
if petDeflect {
te.result.PetDeflected = true
}
@@ -399,10 +685,15 @@ func (te *turnEngine) stepEnemyTurn() {
// Spend the proc on the first swing only; later swings see false.
swingWhiff := petWhiff && i == 0
swingDeflect := petDeflect && i == 0
decided := resolveEnemyAttack(te.st, te.player, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false)
decided := resolveEnemyAttack(te.st, te.st.c, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false)
if te.st.playerHP <= 0 {
te.finish(CombatStatusLost)
return
// The target is down. The enemy stops swinging at a corpse; the
// fight ends only if the roster is empty.
if !te.st.anyAlive() {
te.finish(CombatStatusLost)
return
}
break
}
if decided || te.st.enemyHP <= 0 {
te.finish(CombatStatusWon)
@@ -410,7 +701,7 @@ func (te *turnEngine) stepEnemyTurn() {
}
}
}
te.sess.Phase = CombatPhaseRoundEnd
te.advance()
}
// turnAbilityFires decides whether a monster ability triggers this enemy turn.
@@ -438,22 +729,50 @@ func turnAbilityFires(ability *MonsterAbility, st *combatState, enemy *Combatant
}
// stepRoundEnd applies between-round status effects, then opens the next round.
// Per-actor effects (poison, concentration) tick once per seat, in seating
// order; the enemy's regen is fight-scoped and ticks once. A solo roster walks
// each loop exactly once, so it draws the same RNG the pre-roster engine did.
func (te *turnEngine) stepRoundEnd() {
st := te.st
if st.poisonTicks > 0 {
for i := range st.actors {
st.seat(i)
if st.poisonTicks <= 0 {
continue
}
mark := len(st.events)
st.playerHP = max(0, st.playerHP-st.poisonDmg)
st.poisonTicks--
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "poison_tick",
Damage: st.poisonDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
})
if st.playerHP <= 0 {
if !trySave(st, te.player, CombatPhaseRoundEnd) {
te.finish(CombatStatusLost)
return
}
if st.playerHP <= 0 && !trySave(st, st.c, CombatPhaseRoundEnd) && !st.anyAlive() {
te.stampSeat(mark, i)
te.finish(CombatStatusLost)
return
}
te.stampSeat(mark, i)
}
// Concentration aura (Spirit Guardians et al.): the lingering spell bites
// the enemy each round it stays up. Concentration is per-caster, so every
// seat holding one pulses. Ticks before enemy regen so a lethal pulse
// settles the fight before the enemy knits its wounds back.
for i := range st.actors {
st.seat(i)
if st.concentrationDmg <= 0 || st.enemyHP <= 0 || st.playerHP <= 0 {
continue
}
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, Seat: i,
})
if st.enemyHP <= 0 {
te.finish(CombatStatusWon)
return
}
}
st.seat(0)
// Regenerate (monster ability): the enemy knits its wounds at round end.
if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP {
st.enemyHP = min(te.enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen)
@@ -463,7 +782,11 @@ func (te *turnEngine) stepRoundEnd() {
})
}
st.round++
te.sess.Phase = CombatPhasePlayerTurn
// Initiative is re-rolled each round, so the next round's order is derived
// here — off st.round, since commit has not yet pushed it onto the session.
te.order = turnOrder(te.sess, st.round, te.players, te.enemy)
te.sess.Statuses.TurnIdx = 0
te.sess.Phase = phaseForSeat(te.order[0])
}
// finish parks the session in a terminal status + the 'over' phase.
@@ -478,60 +801,72 @@ func (te *turnEngine) finish(status string) {
//
// The Buff* stat deltas on Statuses are NOT combatState fields — they're owned
// by the command layer (a !cast / !consume folds them in) and applied to the
// rebuilt combatant by applySessionBuffs — so commit mutates Statuses in place
// rather than replacing it, leaving those deltas untouched.
// rebuilt combatant by applySessionBuffs — so snapshotActor carries them over
// from the prior state rather than re-deriving them.
//
// Per-actor state is read off each seat by index, never off the cursor: the
// enemy turn parks the cursor on its target and round_end walks it across the
// roster, so whoever it points at when commit runs is an accident of the phase.
// Seat 0 lands on the session row; seats 1+ on their participant rows.
func (te *turnEngine) commit() {
st := te.st
te.sess.Round = st.round
te.sess.PlayerHP = st.playerHP
te.sess.EnemyHP = st.enemyHP
seat0 := st.actors[0]
te.sess.PlayerHP = seat0.playerHP
s := &te.sess.Statuses
s.PoisonTicks = st.poisonTicks
s.PoisonDmg = st.poisonDmg
s.StunPlayer = st.stunPlayer
s.ActorStatuses = snapshotActor(seat0, s.ActorStatuses)
for i := 1; i < len(st.actors) && i-1 < len(te.sess.Participants); i++ {
p := &te.sess.Participants[i-1]
p.HP = st.actors[i].playerHP
p.Statuses = snapshotActor(st.actors[i], p.Statuses)
}
// Fight-scoped: the enemy's own stance, and the debuffs it wears.
s.Enraged = st.enraged
s.ArmorBroken = st.armorBroken
s.ArmorBreakAmt = st.armorBreakAmt
s.EnemySkipNext = st.enemySkipFirst
s.WardCharges = st.wardCharges
s.SporeRounds = st.sporeRounds
s.ReflectFrac = st.reflectFrac
s.AutoCritFirst = st.autoCrit
s.ArcaneWardHP = st.arcaneWardHP
s.HealChargesLeft = st.healChargesLeft
s.DeathSaveUsed = st.deathSaveUsed
s.LuckyUsed = st.luckyUsed
s.Raged = st.raged
s.PendingRage = st.pendingRageAttack
s.FirstAtkBonusUsed = st.firstAttackBonusUsed
s.AssassinateReroll = st.assassinateRerollUsed
s.AssassinateBonus = st.assassinateBonusUsed
s.EnemyEvadeNext = st.enemyEvadeNext
s.EnemyBlockUp = st.enemyBlockUp
s.EnemyAdvantage = st.enemyAdvantage
s.EnemyRetaliateFrac = st.enemyRetaliateFrac
s.EnemyRegen = st.enemyRegen
s.EnemySurviveArmed = st.enemySurviveArmed
s.PlayerAtkDrain = st.playerAtkDrain
s.PlayerACDebuff = st.playerACDebuff
s.MaxHPDrain = st.maxHPDrain
s.EnemySpellResist = st.enemySpellResist
s.EnemyRevealNext = st.enemyRevealNext
s.EnemyFearImmune = st.enemyFearImmune
s.EnemyAtkBuff = st.enemyAtkBuff
te.sess.TurnLog = append(te.sess.TurnLog, st.events...)
}
// advanceCombatSession resolves one phase of the fight and persists the result.
// It is the single entry point callers (commands, reaper) should use: it seeds
// the deterministic RNG, resumes the engine, steps, commits, and saves. The
// passed sess is mutated in place. The events generated this step are returned.
// advanceCombatSession resolves one phase of a solo fight and persists the
// result. It is the single entry point callers (commands, reaper) should use.
func advanceCombatSession(sess *CombatSession, player, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
return advancePartyCombatSession(sess, []*Combatant{player}, enemy, action)
}
// advancePartyCombatSession resolves one phase of the fight for the seated
// roster and persists the result: it derives the acting seat from this round's
// turn order, seeds that seat's deterministic RNG, resumes the engine, steps,
// commits, and saves. The passed sess is mutated in place. The events generated
// this step are returned.
func advancePartyCombatSession(sess *CombatSession, players []*Combatant, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
if sess == nil {
return nil, ErrNoActiveCombatSession
}
rng := combatSessionRNG(sess)
te := resumeTurnEngine(sess, player, enemy, rng)
if len(players) == 0 {
return nil, fmt.Errorf("combat session %s: empty roster", sess.SessionID)
}
// The seat is needed before the engine is resumed, because it seeds the
// step's RNG — so the order is derived once here and once inside
// resumeTurnEngine. Both derivations read only (sessionID, round, roster),
// so they agree by construction.
rng := combatSessionStepRNG(sess, stepSeat(sess, players, enemy))
te := resumeTurnEngine(sess, players, enemy, rng)
events, err := te.step(action)
if err != nil {
return nil, err

View File

@@ -0,0 +1,277 @@
package plugin
// Initiative + N-body turn-engine tests (N3/P3).
//
// The solo path is pinned bit-for-bit by TestCombatCharacterization for
// auto-resolve, and by the fixed-order assertions below for the turn engine.
// Everything else here exercises seats the production code cannot reach until
// P4 gives a session its participant rows.
import (
"testing"
)
// partyStep drives one engine step over a roster without touching the DB.
func partyStep(sess *CombatSession, players []*Combatant, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
seat := enemySeat
if sess.Phase == CombatPhasePlayerTurn {
order := turnOrder(sess, sess.Round, players, enemy)
seat = order[turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)]
}
te := resumeTurnEngine(sess, players, enemy, combatSessionStepRNG(sess, seat))
events, err := te.step(action)
if err != nil {
return nil, err
}
te.commit()
return events, nil
}
func TestTurnOrder_SoloIsAlwaysPlayerThenEnemy(t *testing.T) {
sess := turnSession(CombatPhasePlayerTurn, 100, 100)
p, e := basePlayer(), baseEnemy()
// A blisteringly fast monster still swings second: the turn engine has
// never rolled initiative for a duel, and P3 must not start.
e.Stats.Speed = 999
p.Mods.InitiativeBias = -50
for round := 1; round <= 5; round++ {
got := turnOrder(sess, round, []*Combatant{&p}, &e)
if len(got) != 2 || got[0] != 0 || got[1] != enemySeat {
t.Fatalf("round %d solo order = %v, want [0 %d]", round, got, enemySeat)
}
}
}
func TestTurnOrder_PartySeatsEveryoneOnceAndIsDeterministic(t *testing.T) {
sess := turnSession(CombatPhasePlayerTurn, 100, 100)
a, b, c := basePlayer(), basePlayer(), basePlayer()
e := baseEnemy()
players := []*Combatant{&a, &b, &c}
order := turnOrder(sess, 3, players, &e)
if len(order) != 4 {
t.Fatalf("order = %v, want 4 slots (3 players + enemy)", order)
}
seen := map[int]bool{}
for _, seat := range order {
if seen[seat] {
t.Fatalf("seat %d appears twice in %v", seat, order)
}
seen[seat] = true
}
if !seen[0] || !seen[1] || !seen[2] || !seen[enemySeat] {
t.Fatalf("order %v does not seat every combatant", order)
}
// Same (session, round) must rebuild the same order — that is what lets a
// resumed fight land on the seat it left off on.
again := turnOrder(sess, 3, players, &e)
for i := range order {
if order[i] != again[i] {
t.Fatalf("order not stable across rebuilds: %v vs %v", order, again)
}
}
}
func TestTurnOrder_InitiativeBiasWins(t *testing.T) {
sess := turnSession(CombatPhasePlayerTurn, 100, 100)
slow, fast := basePlayer(), basePlayer()
fast.Mods.InitiativeBias = 100 // dwarfs the d10 spread
e := baseEnemy()
order := turnOrder(sess, 1, []*Combatant{&slow, &fast}, &e)
if order[0] != 1 {
t.Errorf("order = %v, want the biased seat 1 first", order)
}
}
func TestCombatSessionStepRNG_SeatZeroMatchesLegacyStream(t *testing.T) {
sess := &CombatSession{SessionID: "abc123", Round: 2, Phase: CombatPhasePlayerTurn}
if combatSessionStepRNG(sess, 0).Uint64() != combatSessionRNG(sess).Uint64() {
t.Error("seat 0 must draw the pre-roster stream — the solo turn engine depends on it")
}
// The enemy sentinel shares seat 0's seed; its phase already separates it.
if combatSessionStepRNG(sess, enemySeat).Uint64() != combatSessionRNG(sess).Uint64() {
t.Error("the enemy sentinel must not perturb the seed")
}
// Party members share a (round, phase) pair, so only the seed keeps their
// d20s apart.
if combatSessionStepRNG(sess, 1).Uint64() == combatSessionStepRNG(sess, 0).Uint64() {
t.Error("seats 0 and 1 drew the same stream in the same phase")
}
if combatSessionStepRNG(sess, 1).Uint64() == combatSessionStepRNG(sess, 2).Uint64() {
t.Error("seats 1 and 2 drew the same stream in the same phase")
}
}
func TestTurnIdxForPhase_ReconcilesARowWrittenBeforeTheCursorExisted(t *testing.T) {
solo := []int{0, enemySeat}
// A fight suspended mid-enemy-turn decodes TurnIdx as 0. Phase is the older
// field and wins, so the cursor must snap to the enemy's slot — otherwise
// the engine re-runs the enemy turn forever.
if got := turnIdxForPhase(solo, 0, CombatPhaseEnemyTurn); got != 1 {
t.Errorf("legacy enemy_turn row reconciled to idx %d, want 1", got)
}
if got := turnIdxForPhase(solo, 0, CombatPhasePlayerTurn); got != 0 {
t.Errorf("player_turn row = idx %d, want 0", got)
}
// An out-of-range cursor (roster shrank) falls back to the phase.
if got := turnIdxForPhase(solo, 7, CombatPhaseEnemyTurn); got != 1 {
t.Errorf("out-of-range cursor = idx %d, want 1", got)
}
// A party order keeps an already-consistent cursor exactly where it is.
party := []int{1, enemySeat, 0}
if got := turnIdxForPhase(party, 2, CombatPhasePlayerTurn); got != 2 {
t.Errorf("consistent cursor moved to idx %d, want 2", got)
}
}
func TestTurnEngine_SoloRoundIsPlayerEnemyRoundEnd(t *testing.T) {
sess := turnSession(CombatPhasePlayerTurn, 10000, 10000)
p, e := basePlayer(), baseEnemy()
players := []*Combatant{&p}
for _, want := range []string{CombatPhaseEnemyTurn, CombatPhaseRoundEnd, CombatPhasePlayerTurn} {
if _, err := partyStep(sess, players, &e, PlayerAction{Kind: ActionAttack}); err != nil {
t.Fatal(err)
}
if sess.Phase != want {
t.Fatalf("phase = %q, want %q", sess.Phase, want)
}
}
if sess.Round != 2 {
t.Errorf("round = %d, want 2 after one full round", sess.Round)
}
if sess.Statuses.TurnIdx != 0 {
t.Errorf("solo cursor = %d, want 0 — it should never leave the first slot at a player turn", sess.Statuses.TurnIdx)
}
}
func TestTurnEngine_PartyRoundGivesEverySeatATurn(t *testing.T) {
sess := turnSession(CombatPhasePlayerTurn, 10000, 10000)
a, b := basePlayer(), basePlayer()
e := baseEnemy()
e.Stats.MaxHP = 100000
players := []*Combatant{&a, &b}
order := turnOrder(sess, 1, players, &e)
// Walk the round slot by slot, asserting each phase matches the order.
for i, seat := range order {
if sess.Phase != phaseForSeat(seat) {
t.Fatalf("slot %d: phase = %q, want %q for seat %d", i, sess.Phase, phaseForSeat(seat), seat)
}
if sess.Statuses.TurnIdx != i {
t.Fatalf("slot %d: cursor = %d", i, sess.Statuses.TurnIdx)
}
if _, err := partyStep(sess, players, &e, PlayerAction{Kind: ActionAttack}); err != nil {
t.Fatal(err)
}
}
if sess.Phase != CombatPhaseRoundEnd {
t.Fatalf("phase after every seat acted = %q, want round_end", sess.Phase)
}
if _, err := partyStep(sess, players, &e, PlayerAction{}); err != nil {
t.Fatal(err)
}
if sess.Round != 2 || sess.Statuses.TurnIdx != 0 {
t.Errorf("round_end left round=%d cursor=%d, want 2 and 0", sess.Round, sess.Statuses.TurnIdx)
}
}
func TestTurnEngine_DownedSeatForfeitsItsTurnSilently(t *testing.T) {
// Force a party order whose first slot is a player, then kill that player.
sess := turnSession(CombatPhasePlayerTurn, 10000, 10000)
a, b := basePlayer(), basePlayer()
e := baseEnemy()
players := []*Combatant{&a, &b}
order := turnOrder(sess, 1, players, &e)
first := order[0]
if first == enemySeat {
t.Skip("this round's initiative put the enemy first; the seat-skip path is covered by the next slot")
}
te := resumeTurnEngine(sess, players, &e, combatSessionStepRNG(sess, first))
te.st.actors[first].playerHP = 0
events, err := te.step(PlayerAction{Kind: ActionAttack})
if err != nil {
t.Fatal(err)
}
if len(events) != 0 {
t.Errorf("a downed seat emitted %d events, want none", len(events))
}
if !sess.IsActive() {
t.Error("one downed member ended the fight while another still stands")
}
if sess.Statuses.TurnIdx != 1 {
t.Errorf("cursor = %d, want the next slot", sess.Statuses.TurnIdx)
}
}
func TestTurnEngine_EnemyOnlySwingsAtStandingMembers(t *testing.T) {
sess := turnSession(CombatPhaseEnemyTurn, 10000, 10000)
a, b := basePlayer(), basePlayer()
a.Stats.MaxHP, b.Stats.MaxHP = 10000, 10000
e := baseEnemy()
players := []*Combatant{&a, &b}
// Seat 0 is down. Every enemy turn must land on seat 1, never on the corpse,
// and never end the fight.
for round := 1; round <= 25; round++ {
sess.Round = round
sess.Phase = CombatPhaseEnemyTurn
sess.Statuses.TurnIdx = 1
te := resumeTurnEngine(sess, players, &e, combatSessionStepRNG(sess, enemySeat))
te.st.actors[0].playerHP = 0
before := te.st.actors[1].playerHP
te.step(PlayerAction{})
if te.st.actors[0].playerHP != 0 {
t.Fatal("the enemy healed a corpse")
}
if !sess.IsActive() {
t.Fatalf("round %d: fight ended with a member still standing (%d HP)", round, before)
}
}
}
func TestTurnEngine_RosterWipeEndsTheFight(t *testing.T) {
sess := turnSession(CombatPhaseEnemyTurn, 1, 10000)
a, b := basePlayer(), basePlayer()
e := baseEnemy()
players := []*Combatant{&a, &b}
te := resumeTurnEngine(sess, players, &e, combatSessionStepRNG(sess, enemySeat))
te.st.actors[0].playerHP = 0
te.st.actors[1].playerHP = 0
if _, err := te.step(PlayerAction{}); err != nil {
t.Fatal(err)
}
if sess.Status != CombatStatusLost {
t.Errorf("status = %q, want %q once the whole roster is down", sess.Status, CombatStatusLost)
}
}
// commit persists seat 0, not whoever the cursor happens to be parked on. The
// enemy turn parks it on its target; round_end walks it across the roster.
func TestTurnEngine_CommitPersistsSeatZeroNotTheCursor(t *testing.T) {
sess := turnSession(CombatPhaseRoundEnd, 500, 10000)
a, b := basePlayer(), basePlayer()
e := baseEnemy()
players := []*Combatant{&a, &b}
te := resumeTurnEngine(sess, players, &e, combatSessionStepRNG(sess, enemySeat))
te.st.actors[0].playerHP = 300
te.st.actors[1].playerHP = 77
te.st.actors[1].luckyUsed = true
if _, err := te.step(PlayerAction{}); err != nil {
t.Fatal(err)
}
te.commit()
if sess.PlayerHP != 300 {
t.Errorf("persisted PlayerHP = %d, want seat 0's 300", sess.PlayerHP)
}
if sess.Statuses.LuckyUsed {
t.Error("seat 1's consumed Lucky reroll leaked onto the session row")
}
}

View File

@@ -32,9 +32,9 @@ type bandsintownEvent struct {
Latitude string `json:"latitude"`
Longitude string `json:"longitude"`
} `json:"venue"`
Lineup []string `json:"lineup"`
OnSaleAt string `json:"on_sale_datetime"`
Offers []struct {
Lineup []string `json:"lineup"`
OnSaleAt string `json:"on_sale_datetime"`
Offers []struct {
Type string `json:"type"`
URL string `json:"url"`
Status string `json:"status"`

View File

@@ -161,6 +161,20 @@ func abilityModifier(score int) int {
// startup to refresh stale rows.
const phase5BHPMult = 1.5
// casterHPMult is the J3 D8-d-fix caster durability lift. T4 sim showed
// caster HPMax sat ~30% below martial (~95 vs 141 at L10) and the AC-floor
// lift alone wasn't enough to crack the wall. Applied multiplicatively in
// computeMaxHP on top of phase5BHPMult so the existing Phase 5-B floor
// stays intact for martials. Refreshed for existing rows by
// bootstrapCasterHPRefresh (job key caster_hp_refresh_v1).
func casterHPMult(class DnDClass) float64 {
switch class {
case ClassCleric, ClassDruid, ClassBard, ClassWarlock, ClassMage, ClassSorcerer:
return 1.25
}
return 1.0
}
// computeMaxHP — D&D5e style. L1: full HP die + CON mod (min 1).
// Per level after: average HP die (roundup of die/2 + 1) + CON mod.
// The result is scaled by phase5BHPMult — see that constant for the
@@ -181,8 +195,8 @@ func computeMaxHP(class DnDClass, conMod, level int) int {
}
hp += gain
}
// Phase 5-B player power floor — round to nearest int.
scaled := int(float64(hp)*phase5BHPMult + 0.5)
// Phase 5-B player power floor + J3 D8-d-fix caster lift — round to nearest int.
scaled := int(float64(hp)*phase5BHPMult*casterHPMult(class) + 0.5)
if scaled < 1 {
scaled = 1
}
@@ -197,10 +211,16 @@ func computeAC(class DnDClass, dexMod int) int {
switch class {
case ClassFighter, ClassPaladin:
floor = 6 // heavy/medium-armor baseline
case ClassCleric, ClassRanger, ClassDruid:
case ClassCleric, ClassDruid:
floor = 5
case ClassRanger:
floor = 3
case ClassRogue, ClassBard, ClassWarlock:
case ClassBard, ClassWarlock:
floor = 3
case ClassRogue:
floor = 1
case ClassMage, ClassSorcerer:
floor = 2
}
return 10 + dexMod + floor
}
@@ -208,21 +228,21 @@ func computeAC(class DnDClass, dexMod int) int {
// ── DnDCharacter struct + repository ─────────────────────────────────────────
type DnDCharacter struct {
UserID id.UserID
Race DnDRace
Class DnDClass
Level int
XP int
STR, DEX, CON int
INT, WIS, CHA int
HPCurrent int
HPMax int
TempHP int
ArmorClass int
PendingSetup bool
AutoMigrated bool
OnboardingSent bool
ArmedAbility string
UserID id.UserID
Race DnDRace
Class DnDClass
Level int
XP int
STR, DEX, CON int
INT, WIS, CHA int
HPCurrent int
HPMax int
TempHP int
ArmorClass int
PendingSetup bool
AutoMigrated bool
OnboardingSent bool
ArmedAbility string
// Phase 9 — spell layer.
// PendingCast: JSON blob describing the queued spell to fire on next
// one-shot combat (empty string = nothing queued). Set by !cast for
@@ -231,30 +251,30 @@ type DnDCharacter struct {
// (empty = nothing active). ConcentrationExpiresAt is the wall-clock
// expiry; nil for indefinite. Casting another concentration spell
// supersedes whatever's here.
PendingCast string
ConcentrationSpell string
ConcentrationExpiresAt *time.Time
PendingCast string
ConcentrationSpell string
ConcentrationExpiresAt *time.Time
// Phase 10 — subclass.
// Subclass: empty until the player runs !subclass at L5+. Once set,
// changing requires `!subclass <name>` again subject to a 30-day
// cooldown (LastSubclassRespecAt) and 500-coin cost.
Subclass DnDSubclass
LastSubclassRespecAt *time.Time
Subclass DnDSubclass
LastSubclassRespecAt *time.Time
// Phase 10 SUB2a — Berserker Frenzy increments after each rage'd
// combat; future class abilities will also tick this. Cleared on
// long rest. 5e caps at 6 (death); we let it grow and let consumers
// clamp where needed.
Exhaustion int
Exhaustion int
// 2026-05-10 immersion: short rest charges (1/level, restored on long
// rest). resting_until gates !zone enter / !expedition start while the
// character is "still resting" — short rest = 1h, long rest = 8h.
ShortRestCharges int
RestingUntil *time.Time
LastRespecAt *time.Time
LastShortRestAt *time.Time
LastLongRestAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
LastRespecAt *time.Time
LastShortRestAt *time.Time
LastLongRestAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// Modifiers returns the six ability modifiers in STR/DEX/CON/INT/WIS/CHA order.

View File

@@ -24,9 +24,9 @@ import (
// ── Ability definitions ──────────────────────────────────────────────────────
type DnDAbility struct {
ID string
Name string
Class DnDClass
ID string
Name string
Class DnDClass
// Subclass: if non-empty, ability is only available when the player's
// subclass matches. Phase 10 adds the first such ability (Berserker
// rage). Used by parseAbility/arm gating and by classActiveAbilities

View File

@@ -54,9 +54,9 @@ func TestActiveAbilityCatalogue(t *testing.T) {
func TestParseAbility(t *testing.T) {
cases := []struct {
in string
id string
ok bool
in string
id string
ok bool
}{
{"second_wind", "second_wind", true},
{"second wind", "second_wind", true},

View File

@@ -38,28 +38,28 @@ func setupAuditTestDB(t *testing.T) {
// regardless of MaxHP parity.
func TestOrcRage_ThresholdExact(t *testing.T) {
cases := []struct {
hp, maxHP int
hp, maxHP int
shouldFire bool
}{
{50, 100, false}, // exactly 50% — does NOT fire (strict <)
{49, 100, true}, // just under
{8, 16, false}, // exactly 50% even MaxHP
{7, 16, true},
{8, 15, true}, // 8/15 = 53% — under former threshold of MaxHP/2=7. New: 8*2=16, 16<15 false. Hmm.
{8, 15, true}, // 8/15 = 53% — under former threshold of MaxHP/2=7. New: 8*2=16, 16<15 false. Hmm.
}
// Re-derive: HP*2 < MaxHP. So 8*2=16 < 15? No. Doesn't fire. That means at MaxHP=15, threshold trips at HP*2 < 15, i.e. HP < 7.5, i.e. HP ≤ 7.
// Old behavior used HP < MaxHP/2 = HP < 7 (integer div). So fired at HP < 7, i.e., HP ≤ 6. Different by one.
// Pick cases that distinguish.
cases = []struct {
hp, maxHP int
hp, maxHP int
shouldFire bool
}{
{50, 100, false},
{49, 100, true},
{8, 16, false},
{7, 16, true},
{6, 15, true}, // 6*2=12 < 15 ✓
{7, 15, true}, // 7*2=14 < 15 ✓ (old code's MaxHP/2=7 would have NOT fired at HP=7)
{6, 15, true}, // 6*2=12 < 15 ✓
{7, 15, true}, // 7*2=14 < 15 ✓ (old code's MaxHP/2=7 would have NOT fired at HP=7)
{8, 15, false}, // 8*2=16, not < 15
}
for _, c := range cases {

View File

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

View File

@@ -16,8 +16,8 @@ type DnDMonsterTemplate struct {
CR float32 // challenge rating (display only for now)
HP int
AC int
Attack int // engine's "Attack" stat (raw damage value)
AttackBonus int // d20 to-hit bonus
Attack int // engine's "Attack" stat (raw damage value)
AttackBonus int // d20 to-hit bonus
Speed int
BlockRate float64
Ability *MonsterAbility
@@ -176,10 +176,10 @@ var _ = func() bool {
"flameskull": {
ID: "flameskull", Name: "Flameskull",
CR: 4, HP: 40, AC: 13, Attack: 7, AttackBonus: 5, Speed: 15,
BlockRate: 0.05,
Ability: &MonsterAbility{Name: "Fireball", Phase: "decisive", ProcChance: 0.55, Effect: "aoe_fire"},
XPValue: 1100,
Notes: "Elite. Fireball 8d6 DEX DC 13. Rejuvenation in 1h unless holy water used.",
BlockRate: 0.05,
Ability: &MonsterAbility{Name: "Fireball", Phase: "decisive", ProcChance: 0.55, Effect: "aoe_fire"},
XPValue: 1100,
Notes: "Elite. Fireball 8d6 DEX DC 13. Rejuvenation in 1h unless holy water used.",
FireAttacker: true,
},
"boss_grol_unbroken": {
@@ -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,
@@ -385,37 +385,37 @@ var _ = func() bool {
"magmin": {
ID: "magmin", Name: "Magmin",
CR: 0.5, HP: 9, AC: 14, Attack: 2, AttackBonus: 3, Speed: 10,
BlockRate: 0.05,
Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1.00, Effect: "death_aoe"},
XPValue: 100,
Notes: "Death Burst: 2d6 fire in 10 ft on death. Ignite flammable objects on touch.",
BlockRate: 0.05,
Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1.00, Effect: "death_aoe"},
XPValue: 100,
Notes: "Death Burst: 2d6 fire in 10 ft on death. Ignite flammable objects on touch.",
FireAttacker: true,
},
"azer": {
ID: "azer", Name: "Azer",
CR: 2, HP: 39, AC: 17, Attack: 4, AttackBonus: 5, Speed: 12,
BlockRate: 0.20,
Ability: &MonsterAbility{Name: "Fire Aura", Phase: "any", ProcChance: 0.50, Effect: "retaliate"},
XPValue: 450,
Notes: "1d10 fire to melee attackers; immune to fire; weapons deal heated bonus damage.",
BlockRate: 0.20,
Ability: &MonsterAbility{Name: "Fire Aura", Phase: "any", ProcChance: 0.50, Effect: "retaliate"},
XPValue: 450,
Notes: "1d10 fire to melee attackers; immune to fire; weapons deal heated bonus damage.",
FireAttacker: true,
},
"salamander": {
ID: "salamander", Name: "Salamander",
CR: 5, HP: 90, AC: 15, Attack: 8, AttackBonus: 7, Speed: 12,
BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Constrict", Phase: "any", ProcChance: 0.40, Effect: "stun"},
XPValue: 1800,
Notes: "Constrict grapple + 2d6 fire/turn; spear multiattack; fire aura.",
BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Constrict", Phase: "any", ProcChance: 0.40, Effect: "stun"},
XPValue: 1800,
Notes: "Constrict grapple + 2d6 fire/turn; spear multiattack; fire aura.",
FireAttacker: true,
},
"fire_elemental": {
ID: "fire_elemental", Name: "Fire Elemental",
CR: 5, HP: 102, AC: 13, Attack: 8, AttackBonus: 6, Speed: 14,
BlockRate: 0.05,
Ability: &MonsterAbility{Name: "Fire Form", Phase: "any", ProcChance: 0.50, Effect: "retaliate"},
XPValue: 1800,
Notes: "1d10 contact fire damage; immune to fire; vulnerable to water/cold.",
BlockRate: 0.05,
Ability: &MonsterAbility{Name: "Fire Form", Phase: "any", ProcChance: 0.50, Effect: "retaliate"},
XPValue: 1800,
Notes: "1d10 contact fire damage; immune to fire; vulnerable to water/cold.",
FireAttacker: true,
},
"helmed_horror": {
@@ -429,10 +429,10 @@ var _ = func() bool {
"boss_emberlord_thyrak": {
ID: "boss_emberlord_thyrak", Name: "Emberlord Thyrak",
CR: 9, HP: 178, AC: 18, Attack: 14, AttackBonus: 8, Speed: 11,
BlockRate: 0.20,
Ability: &MonsterAbility{Name: "Forge Breath", Phase: "any", ProcChance: 0.30, Effect: "aoe"},
XPValue: 5000,
Notes: "Underforge boss. Phase 2 below 50% HP — spawns 2 Fire Elementals; breath recharge 46.",
BlockRate: 0.20,
Ability: &MonsterAbility{Name: "Forge Breath", Phase: "any", ProcChance: 0.30, Effect: "aoe"},
XPValue: 5000,
Notes: "Underforge boss. Phase 2 below 50% HP — spawns 2 Fire Elementals; breath recharge 46.",
FireAttacker: true,
},
}
@@ -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,
@@ -594,19 +594,28 @@ var _ = func() bool {
"young_red_dragon": {
ID: "young_red_dragon", Name: "Young Red Dragon",
CR: 10, HP: 178, AC: 18, Attack: 16, AttackBonus: 10, Speed: 16,
BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Fire Breath", Phase: "any", ProcChance: 0.30, Effect: "aoe_fire"},
XPValue: 5900,
Notes: "Elite. Fire Breath 16d6 DEX DC 21; Multiattack; Frightful Presence WIS DC 16.",
BlockRate: 0.10,
Ability: &MonsterAbility{Name: "Fire Breath", Phase: "any", ProcChance: 0.30, Effect: "aoe_fire"},
XPValue: 5900,
Notes: "Elite. Fire Breath 16d6 DEX DC 21; Multiattack; Frightful Presence WIS DC 16.",
FireAttacker: true,
},
"boss_infernax": {
ID: "boss_infernax", Name: "Infernax the Undying",
CR: 24, HP: 546, AC: 22, Attack: 38, AttackBonus: 11, Speed: 18,
BlockRate: 0.15,
Ability: &MonsterAbility{Name: "Frightful Presence", Phase: "opening", ProcChance: 0.80, Effect: "stun"},
XPValue: 62000,
Notes: "Dragon's Lair boss. Ancient Red Dragon. Legendary Actions; Lair Actions; phase 2 below 50% HP — fire ignores resistance.",
// 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.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,
},
// ── Abyss Portal ─────────────────────────────────────────────────
@@ -652,11 +661,19 @@ var _ = func() bool {
},
"boss_belaxath": {
ID: "boss_belaxath", Name: "Belaxath the Undivided",
CR: 19, HP: 262, AC: 19, Attack: 31, AttackBonus: 11, Speed: 14,
BlockRate: 0.20,
Ability: &MonsterAbility{Name: "Lightning Discharge", Phase: "decisive", ProcChance: 0.40, Effect: "aoe"},
XPValue: 22000,
Notes: "Abyss Portal boss. Balor. Fire Aura; Death Throes on death; Demonic Resilience; Legendary Resistance 3/combat; phase 2 below 40% HP — Huge size, advantage on attacks.",
// 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,
Notes: "Abyss Portal boss. Balor. Fire Aura; Death Throes on death; Demonic Resilience; Legendary Resistance 3/combat; phase 2 below 40% HP — Huge size, advantage on attacks.",
FireAttacker: true,
},
}
@@ -683,13 +700,13 @@ func dndBestiaryByCR(maxCR float32) []DnDMonsterTemplate {
// named monsters in the existing combat pipeline.
func (m DnDMonsterTemplate) toCombatStats() (CombatStats, CombatModifiers) {
stats := CombatStats{
MaxHP: m.HP,
Attack: m.Attack,
Defense: 0,
Speed: m.Speed,
BlockRate: m.BlockRate,
AC: m.AC,
AttackBonus: m.AttackBonus,
MaxHP: m.HP,
Attack: m.Attack,
Defense: 0,
Speed: m.Speed,
BlockRate: m.BlockRate,
AC: m.AC,
AttackBonus: m.AttackBonus,
FireAttacker: m.FireAttacker,
}
mods := CombatModifiers{DamageReduct: 1.0}

View File

@@ -90,9 +90,9 @@ func consumeFiredHealingItems(userID id.UserID, fired int) {
}
// Filter to heal items, sort cheapest-tier-first.
type entry struct {
id int64
tier int
name string
id int64
tier int
name string
}
var heals []entry
for _, item := range items {

View File

@@ -63,8 +63,12 @@ func decodePendingCast(s string) (PendingCast, bool) {
func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) error {
// In a turn-based Elite/Boss fight, !cast resolves as the player's turn
// for the round rather than queuing for "next combat". handleCombatCastCmd
// takes the per-user lock itself and re-checks the session under it.
if sess, _ := getActiveCombatSession(ctx.Sender); sess != nil {
// takes the fight's locks itself and re-checks the session under them.
//
// activeCombatSessionFor, not getActiveCombatSession: a seated party member
// owns no session row, and would otherwise fall through to the out-of-combat
// path and queue a PendingCast in the middle of their own boss fight.
if sess, _ := activeCombatSessionFor(ctx.Sender); sess != nil {
return p.handleCombatCastCmd(ctx, args)
}

View File

@@ -272,9 +272,19 @@ func spellExpectedDamage(s SpellDefinition, slot, charLevel int) float64 {
}
avgFace := (float64(faces) + 1) / 2
avg := float64(dice)*avgFace + float64(flat)
// Auto-damage (Magic Missile) doesn't roll to hit — count its
// expected-on-table value at face. Attack/save spells roll, and the
// engine will resolve hit chance at cast time.
// Concentration damage spells (heat_metal, spirit_guardians,
// flaming_sphere, call_lightning, spike_growth, cloud_of_daggers, …)
// re-tick each round while concentration holds. Without this factor
// the picker scores them as one-shots and they lose to higher-tier
// blasts on every comparison. Conservative ×3 = roughly the median
// fight length the picker can hope to keep concentration up; tier-
// scaled would be more correct but adds noise here.
// EffectDamageAttack is excluded — single-target attack-roll spells
// aren't generally concentration; the rare ones (hex-style) get
// their lift from mods, not this score.
if s.Concentration && (s.Effect == EffectDamageSave || s.Effect == EffectDamageAuto) {
avg *= 3
}
return avg
}

View File

@@ -408,3 +408,45 @@ func TestClassBalance_Phase1_FullMatrix(t *testing.T) {
}
}
}
// D8-c: concentration damage spells re-tick each round; the picker has to
// score them above one-shots of similar level or it'll never pick them.
func TestSpellExpectedDamage_ConcentrationMultiplier(t *testing.T) {
const concentrationFactor = 3.0
// 2d8 base: avg = 9.
const baseAvg = 9.0
cases := []struct {
name string
sp SpellDefinition
want float64
}{
{"oneshot save (shatter-like)",
SpellDefinition{Level: 2, Effect: EffectDamageSave, DamageDice: "2d8"},
baseAvg},
{"oneshot attack",
SpellDefinition{Level: 2, Effect: EffectDamageAttack, DamageDice: "2d8"},
baseAvg},
{"concentration save (spirit_guardians-like)",
SpellDefinition{Level: 2, Effect: EffectDamageSave, Concentration: true, DamageDice: "2d8"},
baseAvg * concentrationFactor},
{"concentration auto (spike_growth-like)",
SpellDefinition{Level: 2, Effect: EffectDamageAuto, Concentration: true, DamageDice: "2d8"},
baseAvg * concentrationFactor},
{"concentration attack stays unmultiplied",
SpellDefinition{Level: 2, Effect: EffectDamageAttack, Concentration: true, DamageDice: "2d8"},
baseAvg},
}
for _, tc := range cases {
got := spellExpectedDamage(tc.sp, tc.sp.Level, 10)
if got != tc.want {
t.Errorf("%s: expDmg=%.2f want %.2f", tc.name, got, tc.want)
}
}
// Upcast still applies before the multiplier: 2d8 at slot 3 = 3d8 = 13.5, ×3 = 40.5.
sp := SpellDefinition{Level: 2, Effect: EffectDamageSave, Concentration: true, DamageDice: "2d8"}
if got := spellExpectedDamage(sp, 3, 10); got != 13.5*concentrationFactor {
t.Errorf("upcast + concentration: got %.2f want %.2f", got, 13.5*concentrationFactor)
}
}

View File

@@ -36,17 +36,17 @@ var dndClassWeaponBonus = map[DnDClass]int{
// roughly 70-80% at low tiers and 50-60% at high tiers. Adjust after live
// data lands.
const (
dndArenaACBase = 10
dndArenaACBase = 10
dndArenaACPerThreat = 0.25 // ThreatLevel 0..30 → AC bump 0..7
dndDungeonACBase = 9
dndDungeonACBase = 9
// Dungeon AC scales linearly with tier: T1=10, T5=14
)
// Monster attack bonus. Counters player AC growth. T1 monster +5, T5 +9.
const (
dndArenaAtkBase = 4
dndArenaAtkBase = 4
dndArenaAtkPerThreat = 0.30
dndDungeonAtkBase = 4
dndDungeonAtkBase = 4
// T1 dungeon +5, T5 +9
)
@@ -170,8 +170,8 @@ func applyDnDEquipmentLayer(stats *CombatStats, c *DnDCharacter, equip map[Equip
// buildHarnessPlayer (expedition / class-balance harnesses) so the
// harness's measurement matches what live players experience.
const (
phase5BACBonus = 3
phase5BAttackBonus = 3
phase5BACBonus = 3
phase5BAttackBonus = 3
phase5BWeaponMagicBonus = 3
)
@@ -335,7 +335,7 @@ func autoBuildCharacter(userID id.UserID, char *AdventureCharacter) *DnDCharacte
Class: sug.Class,
Level: level,
STR: scores[0], DEX: scores[1], CON: scores[2],
INT: scores[3], WIS: scores[4], CHA: scores[5],
INT: scores[3], WIS: scores[4], CHA: scores[5],
PendingSetup: false,
AutoMigrated: true,
CreatedAt: time.Now().UTC(),

View File

@@ -213,8 +213,9 @@ func (p *AdventurePlugin) handleResourceSellCmd(ctx MessageContext, args string)
args = strings.TrimSpace(args)
lower := strings.ToLower(args)
// Post-expedition gate.
exp, err := getActiveExpedition(ctx.Sender)
// Post-expedition gate. A seated member is mid-expedition too — reading
// only their own row would let them run a shop from inside the dungeon.
exp, _, err := activeExpeditionFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't check expedition status.")
}

View File

@@ -44,18 +44,18 @@ const (
// WeaponProfile mirrors the spec's struct. DamageDie is parsed from "1d8",
// "2d6", "1d10" etc. into Count/Sides on construction.
type WeaponProfile struct {
ID string
Name string
Category WeaponCategory
DamageCount int // dice count
DamageSides int // die sides
DamageType string // slashing, piercing, bludgeoning
Properties []WeaponProperty
VersaCount int // versatile two-handed dice (0 if not versatile)
VersaSides int
MagicBonus int // 0 for mundane
MagicProp string // empty for mundane
NamedItem bool // true for §7.2 named magic weapons
ID string
Name string
Category WeaponCategory
DamageCount int // dice count
DamageSides int // die sides
DamageType string // slashing, piercing, bludgeoning
Properties []WeaponProperty
VersaCount int // versatile two-handed dice (0 if not versatile)
VersaSides int
MagicBonus int // 0 for mundane
MagicProp string // empty for mundane
NamedItem bool // true for §7.2 named magic weapons
}
// HasProperty reports whether the weapon has the given property.
@@ -137,16 +137,17 @@ const (
)
// ArmorProfile is the spec's struct. MaxDEXBonus convention:
// -1 = unlimited (light armor takes full DEX mod)
// 2 = medium (cap at +2)
// 0 = heavy (no DEX bonus)
// 0 for shields (shields don't add DEX)
//
// -1 = unlimited (light armor takes full DEX mod)
// 2 = medium (cap at +2)
// 0 = heavy (no DEX bonus)
// 0 for shields (shields don't add DEX)
type ArmorProfile struct {
ID string
Name string
Type ArmorType
BaseAC int // 0 for shields (shields use ShieldBonus)
ShieldBonus int // +2 base for shields, 0 for armor
BaseAC int // 0 for shields (shields use ShieldBonus)
ShieldBonus int // +2 base for shields, 0 for armor
MaxDEXBonus int
STRRequire int
StealthDisad bool

View File

@@ -8,17 +8,17 @@ import (
func TestParseDamageDie(t *testing.T) {
cases := []struct {
s string
s string
count, sides int
ok bool
ok bool
}{
{"1d8", 1, 8, true},
{"2d6", 2, 6, true},
{"1D10", 1, 10, true},
{" 1d12 ", 1, 12, true},
{"d8", 0, 0, false}, // no count
{"1d", 0, 0, false}, // no sides
{"1d1", 0, 0, false}, // sides < 2
{"d8", 0, 0, false}, // no count
{"1d", 0, 0, false}, // no sides
{"1d1", 0, 0, false}, // sides < 2
{"banana", 0, 0, false},
{"", 0, 0, false},
}
@@ -359,16 +359,16 @@ func TestSynthesizeWeaponProfile_NonWeaponSlotReturnsNil(t *testing.T) {
func TestSynthesizeArmorProfile_TierMapping(t *testing.T) {
cases := []struct {
tier int
wantBaseAC int
wantType ArmorType
tier int
wantBaseAC int
wantType ArmorType
}{
{1, 11, ArmorTypeLight}, // Padded
{2, 11, ArmorTypeLight}, // Leather
{3, 13, ArmorTypeMedium}, // Chain Shirt
{4, 14, ArmorTypeMedium}, // Scale Mail
{5, 15, ArmorTypeMedium}, // Half Plate
{6, 18, ArmorTypeHeavy}, // Plate
{1, 11, ArmorTypeLight}, // Padded
{2, 11, ArmorTypeLight}, // Leather
{3, 13, ArmorTypeMedium}, // Chain Shirt
{4, 14, ArmorTypeMedium}, // Scale Mail
{5, 15, ArmorTypeMedium}, // Half Plate
{6, 18, ArmorTypeHeavy}, // Plate
}
for _, c := range cases {
eq := &AdvEquipment{Slot: SlotArmor, Tier: c.tier, Name: "Armor"}
@@ -416,8 +416,8 @@ func TestPickWeaponAbilityMod(t *testing.T) {
}{
{"wpn_greatsword", 3}, // melee non-finesse → STR
{"wpn_longsword", 3},
{"wpn_dagger", 3}, // finesse melee, but STR > DEX, picks STR
{"wpn_longbow", 2}, // ranged → DEX
{"wpn_dagger", 3}, // finesse melee, but STR > DEX, picks STR
{"wpn_longbow", 2}, // ranged → DEX
{"wpn_shortbow", 2},
}
for _, tc := range cases {

Some files were not shown because too many files have changed in this diff Show More