37 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
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
203 changed files with 22065 additions and 2913 deletions

View File

@@ -1,12 +1,50 @@
# ---- Matrix Connection ---- # ---- 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 HOMESERVER_URL=https://matrix.example.com
BOT_USER_ID=@gogobee:example.com BOT_USER_ID=@gogobee:example.com
BOT_PASSWORD=your_password_here
BOT_DISPLAY_NAME=GogoBee 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) # Which rooms the bot posts scheduled content to (comma-separated room IDs)
BROADCAST_ROOMS=!roomid:example.com BROADCAST_ROOMS=!roomid:example.com
# The daily 08:00 WOTD auto-post is disabled by default.
# Set to true to enable it. The !wotd command and passive WOTD
# usage tracking work regardless of this setting.
ENABLE_WOTD_POST=false
# Admins who can run admin-only commands (comma-separated Matrix user IDs) # Admins who can run admin-only commands (comma-separated Matrix user IDs)
ADMIN_USERS=@yourmxid:example.com ADMIN_USERS=@yourmxid:example.com
@@ -123,3 +161,17 @@ MINIFLUX_MAX_PER_POLL=5 # max entries per feed per poll (default 5)
RATELIMIT_WEATHER=5 RATELIMIT_WEATHER=5
RATELIMIT_TRANSLATE=20 RATELIMIT_TRANSLATE=20
RATELIMIT_CONCERTS=10 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` | | `HOMESERVER_URL` | Matrix homeserver URL, e.g. `https://matrix.org` |
| `BOT_USER_ID` | Bot's Matrix user ID, e.g. `@gogobee: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) ### Core (optional)

View File

@@ -3,11 +3,18 @@
// outcomes mirror what live players hit. // outcomes mirror what live players hit.
// //
// Single run: // Single run:
//
// expedition-sim [-class fighter] [-level 5] [-zone goblin_warrens] // expedition-sim [-class fighter] [-level 5] [-zone goblin_warrens]
// [-bank 1000] [-cap 50] [-log] [-data DIR] // [-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, // Matrix mode (cartesian sweep over classes × levels × zones × N runs,
// one JSON object per stdout line, log suppressed by default): // one JSON object per stdout line, log suppressed by default):
//
// expedition-sim -matrix -classes fighter,mage -levels 5,10 \ // expedition-sim -matrix -classes fighter,mage -levels 5,10 \
// -zones goblin_warrens,wolf_den -runs 20 // -zones goblin_warrens,wolf_den -runs 20
package main package main
@@ -51,6 +58,9 @@ func main() {
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)") 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()") 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() flag.Parse()
@@ -58,6 +68,10 @@ func main() {
if *petLevel < 0 || *petLevel > 10 { if *petLevel < 0 || *petLevel > 10 {
fail("pet-level must be 0-10, got", *petLevel) 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.SetSimIncludeTrace(*trace)
plugin.SetSimPetLevel(*petLevel) plugin.SetSimPetLevel(*petLevel)
@@ -72,14 +86,40 @@ func main() {
includeLog = *logFlag includeLog = *logFlag
} }
}) })
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel) runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses)
return return
} }
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *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, days 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 dir := dataDir
if dir == "" { if dir == "" {
var err error var err error
@@ -90,7 +130,7 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
} }
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days) res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days, followers)
if err != nil { if err != nil {
if res != nil { if res != nil {
if !includeLog { if !includeLog {
@@ -117,7 +157,7 @@ type matrixJob struct {
rep int rep int
} }
func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel 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) cs := splitNonEmpty(classes)
ls := parseLevels(levels) ls := parseLevels(levels)
zs := splitNonEmpty(zones) zs := splitNonEmpty(zones)
@@ -148,7 +188,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days
var wg sync.WaitGroup var wg sync.WaitGroup
for i := 0; i < jobs; i++ { for i := 0; i < jobs; i++ {
wg.Add(1) wg.Add(1)
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel) go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses)
} }
go func() { go func() {
for _, j := range work { for _, j := range work {
@@ -167,7 +207,7 @@ func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days
} }
} }
func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel int) { 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() defer wg.Done()
for j := range in { for j := range in {
uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep) uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep)
@@ -187,6 +227,11 @@ func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult,
"-user", uid, "-user", uid,
fmt.Sprintf("-log=%t", includeLog), fmt.Sprintf("-log=%t", includeLog),
fmt.Sprintf("-pet-level=%d", petLevel), 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 { if trace {
args = append(args, "-trace") args = append(args, "-trace")
@@ -227,8 +272,7 @@ func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult,
} }
} }
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) {
func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap, days int) (*plugin.SimResult, error) {
runner, err := plugin.NewSimRunner(dataDir) runner, err := plugin.NewSimRunner(dataDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("init runner: %w", err) return nil, fmt.Errorf("init runner: %w", err)
@@ -239,7 +283,32 @@ func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zon
return nil, fmt.Errorf("build character: %w", err) return nil, fmt.Errorf("build character: %w", err)
} }
runner.Euro.Credit(uid, bank, "expedition-sim bankroll") runner.Euro.Credit(uid, bank, "expedition-sim bankroll")
return runner.RunExpedition(uid, zone, cap, days)
// 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) { func emitIndented(res *plugin.SimResult) {

24
go.mod
View File

@@ -11,37 +11,39 @@ require (
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/olebedev/when v1.1.0 github.com/olebedev/when v1.1.0
github.com/robfig/cron/v3 v3.0.1 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 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/image v0.40.0 golang.org/x/image v0.40.0
maunium.net/go/mautrix v0.28.0 maunium.net/go/mautrix v0.28.1
modernc.org/sqlite v1.50.1 modernc.org/sqlite v1.53.0
) )
require ( require (
filippo.io/edwards25519 v1.2.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect
github.com/AlekSi/pointer v1.0.0 // indirect github.com/AlekSi/pointer v1.0.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // 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/dustin/go-humanize v1.0.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/ncruces/go-strftime v1.0.0 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/gjson v1.19.0 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect github.com/tidwall/sjson v1.2.5 // indirect
go.mau.fi/util v0.9.9 // indirect go.mau.fi/util v0.9.10 // indirect
golang.org/x/crypto v0.51.0 // indirect golang.org/x/crypto v0.53.0 // indirect
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/net v0.54.0 // indirect golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.44.0 // indirect golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.38.0 // indirect
modernc.org/libc v1.72.3 // 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/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // 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/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 h1:OeB4O+QROhA/DiXUhBBlkgbzCx0ZVWMpWgKNu+PX9vI=
github.com/chehsunliu/poker v0.1.0/go.mod h1:V6K4yyDbafp0k6lUnYbwoTS/KsHSB1EWiJdEk54uB1w= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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-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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= 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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/notnil/joker v0.0.0-20180219043703-3f2f69a75914 h1:xXPuFr3PVM4p6Vw3j0CP29oWYRVKO3cPZjR6D7BxggQ= 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 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 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= 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.10 h1:wzvz5iDHyqDXB8vgisD4d3SzucLXNM3iNY+1O1RoHtg=
go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY= 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-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.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.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.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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 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.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= 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 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= 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= 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.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.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.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= 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-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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 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.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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= 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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/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.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.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.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.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.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.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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/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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.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.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= 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-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.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.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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 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.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= 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= 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/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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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.1 h1:Hic3oDMPbLbQu1fhboTRAKZcORMjzzkjxsa+SGk60b0=
maunium.net/go/mautrix v0.28.0/go.mod h1:/a9A7LGaqb9B3nho4tLd28n0EPcCdwpm2dxkxkLLgh0= maunium.net/go/mautrix v0.28.1/go.mod h1:mWXQNmOlrq4VTDU9f1HO03BSIswdUIyyY4wUKHqwzzY=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= 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 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= 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 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 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.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= 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 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= 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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 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/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= 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 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

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 ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"time"
"gogobee/internal/util"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/cryptohelper" "maunium.net/go/mautrix/crypto/cryptohelper"
"maunium.net/go/mautrix/id" "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. // Config holds the bot's startup configuration.
type Config struct { type Config struct {
Homeserver string Homeserver string
UserID string UserID string // Bot's full Matrix user ID, e.g. @twinbee:parodia.dev
Password string
DataDir string DataDir string
DisplayName 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. // NewClient creates and configures a mautrix client authenticated against
// The cryptohelper handles: // Matrix Authentication Service (MAS) via the OAuth 2.0 device grant, with
// - Persistent crypto store in SQLite (device keys, sessions, cross-signing keys) // E2EE via the cryptohelper.
// - 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
// //
// This solves the TS version's device verification issues because: // Auth flow (see masauth.go for detail):
// 1. Crypto state persists across restarts (not in-memory like fake-indexeddb) // - Discover MAS OAuth endpoints from the homeserver's well-known + OIDC docs.
// 2. Cross-signing makes other devices trust this bot automatically // - If we have a persisted refresh token, refresh it for a fresh access token.
// 3. The bot trusts all users' devices by default (appropriate for a bot) // - Otherwise run the device-authorization grant: print a URL + code for a
// 4. No manual emoji/SAS verification needed // 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) { func NewClient(cfg Config) (*mautrix.Client, error) {
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil { if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
return nil, fmt.Errorf("create data dir: %w", err) return nil, fmt.Errorf("create data dir: %w", err)
} }
if cfg.UserID == "" {
devicePath := filepath.Join(cfg.DataDir, "device.json") return nil, fmt.Errorf("BOT_USER_ID is required")
// Try to load existing device credentials
device, err := loadDevice(devicePath)
if err != nil {
slog.Info("no existing device found, will login fresh")
} }
var client *mautrix.Client ctx := context.Background()
if device != nil { // ---- MAS OAuth device-grant authentication ----
// Validate existing token auth := newMASAuth(cfg.DataDir)
valid, _ := util.IsTokenValid(cfg.Homeserver, device.AccessToken) auth.load()
if valid { if err := auth.discover(cfg.Homeserver); err != nil {
slog.Info("existing device credentials valid", "device_id", device.DeviceID) return nil, fmt.Errorf("MAS discovery: %w", err)
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)
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 { } else {
slog.Warn("existing device credentials invalid, logging in again") // First run: register a client and run the interactive device grant.
device = nil 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)
} }
} }
if device == nil { userID := id.UserID(cfg.UserID)
// Fresh login client, err := mautrix.NewClient(cfg.Homeserver, userID, auth.token())
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 { if err != nil {
return nil, fmt.Errorf("create client: %w", err) return nil, fmt.Errorf("create client: %w", err)
} }
client.DeviceID = id.DeviceID(loginResp.DeviceID) client.DeviceID = id.DeviceID(auth.deviceID)
// Save device info // Validate the token + identity before proceeding.
device = &DeviceInfo{ whoami, err := client.Whoami(ctx)
AccessToken: loginResp.AccessToken, if err != nil {
DeviceID: loginResp.DeviceID, return nil, fmt.Errorf("token validation failed (whoami): %w", err)
UserID: loginResp.UserID,
} }
if err := saveDevice(devicePath, device); err != nil { if whoami.UserID != userID {
slog.Warn("failed to save device info", "err", err) 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)
slog.Info("logged in successfully", // ---- E2EE via cryptohelper ----
"user_id", loginResp.UserID, // The crypto store persists device keys, olm/megolm sessions, cross-signing
"device_id", loginResp.DeviceID, // 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.
// 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).
cryptoDBPath := filepath.Join(cfg.DataDir, "crypto.db") cryptoDBPath := filepath.Join(cfg.DataDir, "crypto.db")
ch, err := cryptohelper.NewCryptoHelper(client, []byte("gogobee_pickle_key"), cryptoDBPath) ch, err := cryptohelper.NewCryptoHelper(client, []byte("gogobee_pickle_key"), cryptoDBPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("init crypto helper: %w", err) return nil, fmt.Errorf("init crypto helper: %w", err)
} }
// No LoginAs: we already have a token + device ID; the cryptohelper just
// LoginAs enables the cryptohelper to re-login if the token expires, // attaches E2EE to the existing session.
// and to bootstrap cross-signing on first run. Cross-signing means: if err := ch.Init(ctx); err != nil {
// - 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 {
return nil, fmt.Errorf("crypto helper init: %w", err) return nil, fmt.Errorf("crypto helper init: %w", err)
} }
// Attach crypto helper to client
client.Crypto = ch client.Crypto = ch
// Bootstrap cross-signing: generate keys, sign own device, sign master key. // Best-effort cross-signing bootstrap (makes the bot's device show as
// This makes the bot's device show as "verified" to other users. // 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() mach := ch.Machine()
recoveryKey, _, err := mach.GenerateAndUploadCrossSigningKeys(context.Background(), func(ui *mautrix.RespUserInteractive) interface{} { bootstrapCrossSigning(ctx, mach, cfg.DataDir)
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)
}
if err := mach.SignOwnDevice(context.Background(), mach.OwnIdentity()); err != nil { // ---- Background token refresher ----
slog.Warn("cross-signing: sign own device failed", "err", err) // Refresh ~60s before expiry and push the new token into the live client so
} else { // in-flight /sync and API calls keep authenticating.
slog.Info("cross-signing: own device signed") go refreshLoop(context.Background(), auth, client)
}
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")
}
slog.Info("E2EE initialized", slog.Info("E2EE initialized",
"user_id", client.UserID,
"device_id", client.DeviceID, "device_id", client.DeviceID,
"crypto_store", "sqlite-persistent", "crypto_store", "sqlite-persistent",
"auth", "mas-oauth-device-grant",
) )
return client, nil return client, nil
} }
func loadDevice(path string) (*DeviceInfo, error) { // refreshLoop keeps the client's access token fresh for the life of the process.
data, err := os.ReadFile(path) func refreshLoop(ctx context.Context, auth *masAuth, client *mautrix.Client) {
if err != nil { for {
return nil, err wait := time.Until(auth.expiry()) - 60*time.Second
if wait < 10*time.Second {
wait = 10 * time.Second
} }
var info DeviceInfo select {
if err := json.Unmarshal(data, &info); err != nil { case <-ctx.Done():
return nil, err 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))
} }
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 ( import (
"log/slog" "log/slog"
"os"
"strings"
"sync" "sync"
"gogobee/internal/plugin" "gogobee/internal/plugin"
@@ -11,11 +13,22 @@ import (
type Registry struct { type Registry struct {
mu sync.RWMutex mu sync.RWMutex
plugins []plugin.Plugin 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 { 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. // 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. // DispatchMessage sends a message context to all plugins in order.
func (r *Registry) DispatchMessage(ctx plugin.MessageContext) { func (r *Registry) DispatchMessage(ctx plugin.MessageContext) {
if _, ok := r.ignoredBots[string(ctx.Sender)]; ok {
return
}
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
for _, p := range r.plugins { 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. // DispatchReaction sends a reaction context to all plugins in order.
func (r *Registry) DispatchReaction(ctx plugin.ReactionContext) { func (r *Registry) DispatchReaction(ctx plugin.ReactionContext) {
if _, ok := r.ignoredBots[string(ctx.Sender)]; ok {
return
}
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
for _, p := range r.plugins { 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 // engages when a fork / elite / boss / supply pinch actually
// needs a decision. CAS-claim on this column gates re-entry. // needs a decision. CAS-claim on this column gates re-entry.
`ALTER TABLE dnd_expedition ADD COLUMN last_autorun_at DATETIME`, `ALTER TABLE dnd_expedition ADD COLUMN last_autorun_at DATETIME`,
// URL link previews now post the page's og:image/twitter:image
// thumbnail; cache it alongside the title/description.
`ALTER TABLE url_cache ADD COLUMN image_url TEXT NOT NULL DEFAULT ''`,
// 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 { for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil { if _, err := d.Exec(stmt); err != nil {
@@ -1124,6 +1148,7 @@ CREATE TABLE IF NOT EXISTS url_cache (
url TEXT PRIMARY KEY, url TEXT PRIMARY KEY,
title TEXT DEFAULT '', title TEXT DEFAULT '',
description TEXT DEFAULT '', description TEXT DEFAULT '',
image_url TEXT NOT NULL DEFAULT '',
cached_at INTEGER DEFAULT (unixepoch()) cached_at INTEGER DEFAULT (unixepoch())
); );
@@ -1496,6 +1521,21 @@ CREATE TABLE IF NOT EXISTS arena_stats (
updated_at INTEGER NOT NULL 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 -- Rival System
CREATE TABLE IF NOT EXISTS adventure_rival_records ( CREATE TABLE IF NOT EXISTS adventure_rival_records (
user_id TEXT NOT NULL, 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); 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 ─────────────────────── -- ── Turn-based combat — persistent per-fight session ───────────────────────
-- One row per manual elite/boss fight. Persists across bot restarts and -- 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 -- 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 status TEXT NOT NULL DEFAULT 'active', -- active|won|lost|fled|expired
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_action_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 CREATE INDEX IF NOT EXISTS idx_combat_session_active
ON combat_session(user_id, status); ON combat_session(user_id, status);
CREATE INDEX IF NOT EXISTS idx_combat_session_expiry CREATE INDEX IF NOT EXISTS idx_combat_session_expiry
ON combat_session(status, expires_at); 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. // SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
@@ -2047,9 +2178,9 @@ func SeedSchedulerDefaults(d *sql.DB) error {
{"holidays", "0 7 * * *"}, // 07:00 daily {"holidays", "0 7 * * *"}, // 07:00 daily
{"releases", "0 9 * * 1"}, // 09:00 Monday {"releases", "0 9 * * 1"}, // 09:00 Monday
{"birthday_check", "0 6 * * *"}, // 06:00 daily {"birthday_check", "0 6 * * *"}, // 06:00 daily
{"anime_releases", "0 10 * * *"},// 10:00 daily {"anime_releases", "0 10 * * *"}, // 10:00 daily
{"movie_releases", "0 11 * * *"},// 11:00 daily {"movie_releases", "0 11 * * *"}, // 11:00 daily
{"concert_digest", "0 12 * * 0"},// 12:00 Sunday {"concert_digest", "0 12 * * 0"}, // 12:00 Sunday
} }
stmt, err := d.Prepare(`INSERT OR IGNORE INTO scheduler_config (job_name, cron_expr) VALUES (?, ?)`) stmt, err := d.Prepare(`INSERT OR IGNORE INTO scheduler_config (job_name, cron_expr) VALUES (?, ?)`)

View File

@@ -2,6 +2,7 @@ package plugin
import ( import (
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"strings" "strings"
@@ -1137,9 +1138,160 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
return false 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. // statGTE checks if a user_stats column is >= threshold.
var allowedStatColumns = map[string]bool{ var allowedStatColumns = map[string]bool{
"total_messages": true, "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

@@ -28,6 +28,7 @@ type AdventurePlugin struct {
dmToPlayer map[id.RoomID]id.UserID dmToPlayer map[id.RoomID]id.UserID
pending sync.Map // userID string -> *advPendingInteraction pending sync.Map // userID string -> *advPendingInteraction
userLocks sync.Map // userID string -> *sync.Mutex 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 dmRemindedDate sync.Map // userID string -> "2006-01-02" date string
dmMenuSentAt sync.Map // userID string -> time.Time (last time actionable menu was DM'd) dmMenuSentAt sync.Map // userID string -> time.Time (last time actionable menu was DM'd)
arenaDeadlines sync.Map // userID string -> time.Time (auto-cashout deadline) arenaDeadlines sync.Map // userID string -> time.Time (auto-cashout deadline)
@@ -70,6 +71,16 @@ func (p *AdventurePlugin) advUserLock(userID id.UserID) *sync.Mutex {
return val.(*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 // advDMResponseWindow is the default window for active state-holding
// prompts (shop/blacksmith/hospital/masterwork/treasure confirms). // prompts (shop/blacksmith/hospital/masterwork/treasure confirms).
// Passive overnight-tolerant prompts (pet arrival, flavor DMs) use // 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). // through the L4-L5h dual-write soak (fresh deploys, restored backups).
bootstrapPlayerMetaFromLegacy() bootstrapPlayerMetaFromLegacy()
bootstrapRestoreExpeditionStreakDecay() bootstrapRestoreExpeditionStreakDecay()
bootstrapRoomsTraversed()
// Rehydrate DM room mappings for existing characters // Rehydrate DM room mappings for existing characters
chars, err := loadAllAdvCharacters() chars, err := loadAllAdvCharacters()
@@ -224,6 +236,12 @@ func (p *AdventurePlugin) Init() error {
// existing caster rows once at startup so the lift reaches live // existing caster rows once at startup so the lift reaches live
// players without waiting for level-up. // players without waiting for level-up.
bootstrapCasterHPRefresh() 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 // 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 // over-archived: it treats any active dnd_zone_run row not linked to
// an active expedition as a legacy `!adventure dungeon` orphan, which // an active expedition as a legacy `!adventure dungeon` orphan, which
@@ -335,6 +353,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "zone") { if p.IsCommand(ctx.Body, "zone") {
return p.handleDnDZoneCmd(ctx, p.GetArgs(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") { if p.IsCommand(ctx.Body, "fight") {
return p.handleFightCmd(ctx) return p.handleFightCmd(ctx)
} }
@@ -478,6 +499,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
return p.handleRepairAllCmd(ctx) return p.handleRepairAllCmd(ctx)
case strings.HasPrefix(lower, "repair "): case strings.HasPrefix(lower, "repair "):
return p.handleRepairSlotCmd(ctx, strings.TrimSpace(args[7:])) return p.handleRepairSlotCmd(ctx, strings.TrimSpace(args[7:]))
case lower == "temper":
return p.handleTemperCmd(ctx)
case lower == "boost": case lower == "boost":
return p.handleBoostCmd(ctx) return p.handleBoostCmd(ctx)
case lower == "recipes": case lower == "recipes":
@@ -512,6 +535,7 @@ const advHelpText = `**Adventure Commands**
` + "`!adventure blacksmith`" + ` — Visit the blacksmith (view repair costs) ` + "`!adventure blacksmith`" + ` — Visit the blacksmith (view repair costs)
` + "`!adventure repair all`" + ` — Repair all damaged equipment ` + "`!adventure repair all`" + ` — Repair all damaged equipment
` + "`!adventure repair <slot>`" + ` — Repair a specific slot ` + "`!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 recipes`" + ` — List crafting recipes known at your current Foraging level
` + "`!adventure mastery`" + ` — Per-slot equipment mastery progress and active bonus ` + "`!adventure mastery`" + ` — Per-slot equipment mastery progress and active bonus
` + "`!adventure treasures`" + ` — List your treasures · ` + "`treasures lock`" + ` to refuse swaps ` + "`!adventure treasures`" + ` — List your treasures · ` + "`treasures lock`" + ` to refuse swaps
@@ -701,7 +725,10 @@ func (p *AdventurePlugin) handleSellCmd(ctx MessageContext, args string) error {
} else { } else {
result = p.advSellItem(ctx.Sender, args) 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 { func (p *AdventurePlugin) handleInventoryCmd(ctx MessageContext) error {
@@ -844,6 +871,10 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
return p.resolveBlacksmithSlotChoice(ctx, interaction) return p.resolveBlacksmithSlotChoice(ctx, interaction)
case "blacksmith_confirm": case "blacksmith_confirm":
return p.resolveBlacksmithConfirm(ctx, interaction) return p.resolveBlacksmithConfirm(ctx, interaction)
case "temper_pick":
return p.resolveTemperPick(ctx, interaction)
case "temper_confirm":
return p.resolveTemperConfirm(ctx, interaction)
case "shop_category": case "shop_category":
return p.resolveShopCategoryChoice(ctx, interaction) return p.resolveShopCategoryChoice(ctx, interaction)
case "shop_item": case "shop_item":
@@ -1066,13 +1097,19 @@ func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharact
// ── Treasure Drop Check ───────────────────────────────────────────────────── // ── Treasure Drop Check ─────────────────────────────────────────────────────
func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCharacter, loc *AdvLocation) { // checkTreasureDrop rolls the treasure table for one earned moment. weight
drop, roll, rate := rollAdvTreasureDropDetailed(loc.Tier, userID, p.chatLevel(userID)) // 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 { if drop == nil {
// Near-miss feedback: when the roll was within 2× of the drop rate, // 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% // tell the player they almost got it. Treasure rates are 0.151.5%
// so without this signal the system feels invisible. // 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.", p.SendDM(userID, fmt.Sprintf("🎁 *Treasure: just missed* — rolled %.2f%% against %.2f%% drop chance.",
roll*100, rate*100)) roll*100, rate*100))
} }
@@ -1532,4 +1569,3 @@ func (p *AdventurePlugin) handleBoostCmd(ctx MessageContext) error {
advSetBoost(true) advSetBoost(true)
return p.SendReply(ctx.RoomID, ctx.EventID, "⚡ Double XP/money boost **enabled**! All adventure XP and loot values are doubled.") return p.SendReply(ctx.RoomID, ctx.EventID, "⚡ Double XP/money boost **enabled**! All adventure XP and loot values are doubled.")
} }

View File

@@ -304,13 +304,17 @@ func (p *AdventurePlugin) handleArenaStats(ctx MessageContext) error {
return p.SendDM(ctx.Sender, renderArenaPersonalStats(displayName, wins, losses, stats)) 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 { 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 { 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, "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 ─────────────────────────────────────────────────────── // ── Combat Resolution ───────────────────────────────────────────────────────
@@ -624,7 +628,10 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
text += fmt.Sprintf("\n\n🎉 **Combat Level %d!**", newLevel) 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). // arenaProcessBail handles bail payout (called from handleArenaBail or countdown).

View File

@@ -134,13 +134,13 @@ type ArenaLeaderboardEntry struct {
TotalDeaths int TotalDeaths int
} }
func renderArenaLeaderboard(entries []ArenaLeaderboardEntry) string { func renderArenaLeaderboard(season string, entries []ArenaLeaderboardEntry) string {
if len(entries) == 0 { 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 var b strings.Builder
b.WriteString("⚔️ **Arena Leaderboard**\n\n") b.WriteString(fmt.Sprintf("⚔️ **Arena Leaderboard — Season %s**\n\n", season))
medals := []string{"🥇", "🥈", "🥉"} medals := []string{"🥇", "🥈", "🥉"}
for i, e := range entries { 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", b.WriteString(fmt.Sprintf("%s **%s** — €%d earned | %s | %d runs | %d deaths\n",
prefix, e.DisplayName, e.TotalEarnings, tierLabel, e.TotalRuns, e.TotalDeaths)) prefix, e.DisplayName, e.TotalEarnings, tierLabel, e.TotalRuns, e.TotalDeaths))
} }
b.WriteString("\n_Season standings. `!arena stats` for your lifetime record._")
return b.String() 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) { func TestRenderArenaLeaderboard_Empty(t *testing.T) {
text := renderArenaLeaderboard(nil) text := renderArenaLeaderboard("2026-Q3", nil)
if !strings.Contains(text, "No arena runs") { if !strings.Contains(text, "Nobody has entered") {
t.Error("empty leaderboard should say no runs") 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: "Alice", TotalEarnings: 100000, HighestTier: 5, Tier5Completions: 1, TotalRuns: 5, TotalDeaths: 2},
{DisplayName: "Bob", TotalEarnings: 50000, HighestTier: 3, TotalRuns: 10, TotalDeaths: 7}, {DisplayName: "Bob", TotalEarnings: 50000, HighestTier: 3, TotalRuns: 10, TotalDeaths: 7},
} }
text := renderArenaLeaderboard(entries) text := renderArenaLeaderboard("2026-Q3", entries)
if !strings.Contains(text, "Alice") { if !strings.Contains(text, "Alice") {
t.Error("leaderboard should contain Alice") t.Error("leaderboard should contain Alice")
} }

View File

@@ -121,6 +121,7 @@ type AdvItem struct {
Value int64 Value int64
Slot EquipmentSlot // non-empty for MasterworkGear Slot EquipmentSlot // non-empty for MasterworkGear
SkillSource string // non-empty for MasterworkGear SkillSource string // non-empty for MasterworkGear
Temper int // rarity steps above base; magic_item rows only
} }
type AdvBuff struct { type AdvBuff struct {
@@ -560,7 +561,7 @@ func saveAdvEquipment(userID id.UserID, eq *AdvEquipment) error {
func loadAdvInventory(userID id.UserID) ([]AdvItem, error) { func loadAdvInventory(userID id.UserID) ([]AdvItem, error) {
d := db.Get() d := db.Get()
rows, err := d.Query(` 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 = ? FROM adventure_inventory WHERE user_id = ?
ORDER BY tier DESC, value DESC`, string(userID)) ORDER BY tier DESC, value DESC`, string(userID))
if err != nil { if err != nil {
@@ -572,7 +573,7 @@ func loadAdvInventory(userID id.UserID) ([]AdvItem, error) {
for rows.Next() { for rows.Next() {
var it AdvItem var it AdvItem
var slot string 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 return nil, err
} }
it.Slot = EquipmentSlot(slot) it.Slot = EquipmentSlot(slot)
@@ -584,9 +585,19 @@ func loadAdvInventory(userID id.UserID) ([]AdvItem, error) {
func addAdvInventoryItem(userID id.UserID, item AdvItem) error { func addAdvInventoryItem(userID id.UserID, item AdvItem) error {
d := db.Get() d := db.Get()
_, err := d.Exec(` _, err := d.Exec(`
INSERT INTO adventure_inventory (user_id, name, item_type, tier, value, slot, skill_source) INSERT INTO adventure_inventory (user_id, name, item_type, tier, value, slot, skill_source, temper)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
string(userID), item.Name, item.Type, item.Tier, item.Value, string(item.Slot), item.SkillSource) 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 return err
} }

View File

@@ -234,8 +234,13 @@ func RollConsumableDrop(activity AdvActivityType, tier int) *AdvItem {
if rand.Float64() >= 0.15 { if rand.Float64() >= 0.15 {
return nil return nil
} }
name := names[rand.IntN(len(names))] return consumableAdvItem(consumableDefByName(names[rand.IntN(len(names))]))
def := consumableDefByName(name) }
// 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 { if def == nil {
return 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. // BuyableConsumables returns all consumable defs that can be purchased from the shop.
func BuyableConsumables() []ConsumableDef { func BuyableConsumables() []ConsumableDef {
var result []ConsumableDef var result []ConsumableDef

View File

@@ -23,18 +23,65 @@ type advActiveEvent struct {
ExpiresAt time.Time ExpiresAt time.Time
} }
// ── In-memory schedule ─────────────────────────────────────────────────────── // ── Event anchors ────────────────────────────────────────────────────────────
// When a player acts on a given day, the next ticker iteration assigns them // N1/A6 — events used to roll at 0.5%/player/day from a deferred ticker slot,
// a one-shot roll-minute 60180 minutes in the future. At that minute, the // which put one sighting in front of a daily player roughly every 200 days.
// 0.5% trigger roll fires. Each player rolls at most once per UTC day. // 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 ( var (
advEventScheduleMu sync.Mutex advEventFiredMu sync.Mutex
advEventSchedule map[string]int // userID -> minute-of-day for the deferred roll advEventFired map[string]bool // userID -> an event already fired today
advEventRolled map[string]bool // userID -> already rolled today advEventFiredDay string // "2006-01-02" the map belongs to
advEventScheduleDay string // "2006-01-02" the maps belong 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 ───────────────────────────────────────────────────────────── // ── Event Ticker ─────────────────────────────────────────────────────────────
func (p *AdventurePlugin) eventTicker() { func (p *AdventurePlugin) eventTicker() {
@@ -46,90 +93,45 @@ func (p *AdventurePlugin) eventTicker() {
case <-p.stopCh: case <-p.stopCh:
return return
case <-ticker.C: 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 // Expire stale pending events every tick
expireAdvPendingEvents() expireAdvPendingEvents()
// Auto-play any combat sessions past their 1h timeout. // Auto-play any combat sessions past their 1h timeout.
p.reapExpiredCombatSessions() p.reapExpiredCombatSessions()
advEventScheduleMu.Lock() // Latch away party members onto autopilot so one absent player
if advEventScheduleDay != dateKey { // can't hold a co-op fight hostage (N3/P5). Solo fights are never
advEventSchedule = make(map[string]int) // listed — they answer to the reaper above.
advEventRolled = make(map[string]bool) p.nudgeStalledPartyTurns()
advEventScheduleDay = dateKey
}
// Schedule deferred rolls for any newly-acted players // Reclaim invites nobody answered (N3/P6b). Every read already
chars, err := loadAllAdvCharacters() // filters on the TTL; this just stops the rows accumulating.
if err != nil { purgeExpiredInvites()
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)
}
} }
} }
} }
func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) { // tryTriggerEvent fires the player's mid-day event. Reports whether an event
// Load character — must be alive and have acted today // 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) char, err := loadAdvCharacter(userID)
if err != nil || char == nil || !char.Alive || !char.HasActedToday() { if err != nil || char == nil || !char.Alive {
return return false
} }
// Already has an active event? // Already has an active event?
active, _ := loadAdvActiveEvent(userID) active, _ := loadAdvActiveEvent(userID)
if active != nil { if active != nil {
return return false
} }
// Mid-fight: a turn-based session locks the run. Don't drop a random // 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 // 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. // finishing the fight first, and the trigger DM talks over the combat feed.
if hasActiveCombatSession(userID) { if hasActiveCombatSession(userID) {
return return false
}
// 0.5% chance
if rand.Float64() >= 0.005 {
return
} }
// Determine today's activity for filtering // Determine today's activity for filtering
@@ -138,7 +140,7 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
// Pick an event // Pick an event
event := advPickRandomEvent(userID, activityType) event := advPickRandomEvent(userID, activityType)
if event == nil { if event == nil {
return return false
} }
// Insert into DB // Insert into DB
@@ -147,7 +149,7 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
eventID, err := insertAdvEvent(userID, event.Key, expiresAt) eventID, err := insertAdvEvent(userID, event.Key, expiresAt)
if err != nil { if err != nil {
slog.Error("adventure: events: failed to insert event", "user", userID, "err", err) 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) 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) _ = p.SendMessage(id.RoomID(gr), roomLine)
} }
return true
} }
// handleEventRespond processes `!adventure respond`. // 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.", "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{ 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.", "_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.", "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.", "_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_", "This could have been avoided with regular visits. _slides the cost estimate across the counter without breaking eye contact_",
} }

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" + "Your cat is already somewhere else.\n\nIt blinks.\n\nNot at you.\n\nAt nothing.\n\n" +
"The way cats do.", "The way cats do.",
} }
// Fires randomly in the morning DM. Cat has left something. // Fires randomly in the morning DM. Cat has left something.
// Results in a defense boost for the day. // Results in a defense boost for the day.
// The cat is proud. The cat will never stop doing this. // The cat is proud. The cat will never stop doing this.

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

@@ -115,10 +115,37 @@ func masterworkDefFor(activity AdvActivityType, tier int) *MasterworkDef {
return nil 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) ───────────────────────────────────────────────── // ── Drop Flavor Text (DM) ─────────────────────────────────────────────────
func masterworkDropFlavorText(activity AdvActivityType, tier int) string { func masterworkDropFlavorText(activity AdvActivityType, tier int) string {
switch activity { 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: case AdvActivityMining:
switch { switch {
case tier <= 2: case tier <= 2:
@@ -164,9 +191,15 @@ func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, equip map[Equipm
return 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 { 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) // 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") sb.WriteString("_This doesn't come from the shop._\n\n")
} }
// Flavor text // Flavor follows where the item was found, not which catalog line it came
flavor := masterworkDropFlavorText(def.Activity, def.Tier) // from — a dungeon drop must not open with a pickaxe.
flavor := masterworkDropFlavorText(loc.Activity, def.Tier)
if flavor != "" { if flavor != "" {
sb.WriteString(fmt.Sprintf("_%s_\n\n", 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

@@ -920,7 +920,6 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
sb.WriteString("\n") sb.WriteString("\n")
} }
// Standout // Standout
if bestPlayer != nil && bestPlayer.LootValue > 0 { if bestPlayer != nil && bestPlayer.LootValue > 0 {
pool := SummaryStandoutGood pool := SummaryStandoutGood

View File

@@ -389,6 +389,9 @@ func (p *AdventurePlugin) midnightTicker() {
slog.Error("adventure: midnight reset failed, will retry next tick", "err", err) slog.Error("adventure: midnight reset failed, will retry next tick", "err", err)
continue 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) db.MarkJobCompleted(jobName, dateKey)
lastRanDate = dateKey lastRanDate = dateKey
} }

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

@@ -246,12 +246,17 @@ var advAllTreasures = map[int][]AdvTreasureDef{
// and the effective drop rate, so callers can surface near-miss feedback // 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 // ("rolled 1.8% vs 1.5% chance — just missed"). Players never see treasure
// math otherwise, which makes rare drops feel mythical. // 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] r, ok := advTreasureDropRates[tier]
if !ok { if !ok {
return nil, 0, 0 return nil, 0, 0
} }
rate = r + chatLevelRareBonus(chatLevel) rate = r*weight + chatLevelRareBonus(chatLevel)
roll = rand.Float64() roll = rand.Float64()
if roll >= rate { 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 // rollAdvTreasureDrop is the legacy single-return variant used by call sites
// that don't surface near-miss feedback (auto-babysit, twinbee shares). // that don't surface near-miss feedback (auto-babysit, twinbee shares).
func rollAdvTreasureDrop(tier int, userID id.UserID, chatLevel int) *AdvTreasureDrop { func rollAdvTreasureDrop(tier int, userID id.UserID, chatLevel int) *AdvTreasureDrop {
d, _, _ := rollAdvTreasureDropDetailed(tier, userID, chatLevel) d, _, _ := rollAdvTreasureDropDetailed(tier, userID, chatLevel, 1)
return d 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

@@ -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) { func TestTurnAbilityFires(t *testing.T) {
enemy := baseEnemy() // MaxHP 60 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 { cases := []struct {
name string name string
@@ -78,10 +92,7 @@ func TestTurnAbilityFires(t *testing.T) {
// within applyAbility with no persistent state. // within applyAbility with no persistent state.
func TestApplyAbility_Slice2Effects(t *testing.T) { func TestApplyAbility_Slice2Effects(t *testing.T) {
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) { newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
st := &combatState{ st := testCombatState(playerHP, enemyHP, 1, rand.New(rand.NewPCG(7, 7)))
playerHP: playerHP, enemyHP: enemyHP, round: 1,
rng: rand.New(rand.NewPCG(7, 7)),
}
return st, basePlayer(), baseEnemy() return st, basePlayer(), baseEnemy()
} }
phase := &turnCombatPhase phase := &turnCombatPhase
@@ -136,10 +147,7 @@ func TestApplyAbility_Slice2Effects(t *testing.T) {
// all but evade), and the shared resolution primitives read that state. // all but evade), and the shared resolution primitives read that state.
func TestApplyAbility_Slice3Effects(t *testing.T) { func TestApplyAbility_Slice3Effects(t *testing.T) {
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) { newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
st := &combatState{ st := testCombatState(playerHP, enemyHP, 1, rand.New(rand.NewPCG(9, 9)))
playerHP: playerHP, enemyHP: enemyHP, round: 1,
rng: rand.New(rand.NewPCG(9, 9)),
}
return st, basePlayer(), baseEnemy() return st, basePlayer(), baseEnemy()
} }
phase := &turnCombatPhase phase := &turnCombatPhase
@@ -188,7 +196,8 @@ func TestApplyAbility_Slice3Effects(t *testing.T) {
} }
// enemyDown lets survive_at_1 cheat death exactly once. // 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") { if enemyDown(stS, "Duel") {
t.Error("survive_at_1: armed enemy at 0 HP should not be down") 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. // itself, and the shared resolution primitives / helpers read that state.
func TestApplyAbility_Slice4Effects(t *testing.T) { func TestApplyAbility_Slice4Effects(t *testing.T) {
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) { newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
st := &combatState{ st := testCombatState(playerHP, enemyHP, 1, rand.New(rand.NewPCG(11, 11)))
playerHP: playerHP, enemyHP: enemyHP, round: 1,
rng: rand.New(rand.NewPCG(11, 11)),
}
return st, basePlayer(), baseEnemy() return st, basePlayer(), baseEnemy()
} }
phase := &turnCombatPhase phase := &turnCombatPhase

View File

@@ -877,5 +877,3 @@ func (p *BlackjackPlugin) recordBJScore(userID id.UserID, net float64) {
recordBotDefeat(userID, "blackjack") recordBotDefeat(userID, "blackjack")
} }
} }

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 return result
} }

View File

@@ -34,7 +34,9 @@ func encounterIDForRoom(roomIdx int) string {
// state mutations (HP, XP, threat, run-clear) still happen; only the // state mutations (HP, XP, threat, run-clear) still happen; only the
// narration is dropped. Non-silent callers (manual !fight) are unchanged. // narration is dropped. Non-silent callers (manual !fight) are unchanged.
func (p *AdventurePlugin) replyDM(ctx MessageContext, text string) error { func (p *AdventurePlugin) replyDM(ctx MessageContext, text string) error {
if ctx.Silent { // 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 nil
} }
return p.SendDM(ctx.Sender, text) return p.SendDM(ctx.Sender, text)
@@ -43,11 +45,15 @@ func (p *AdventurePlugin) replyDM(ctx MessageContext, text string) error {
// ── !fight ────────────────────────────────────────────────────────────────── // ── !fight ──────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error { func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender) // Resolve the roster before locking — a member's `!fight` opens the leader's
userMu.Lock() // fight, under the leader's lock, and seat 0 names that leader. fightRoster
defer userMu.Unlock() // 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 { if err != nil {
return p.replyDM(ctx, "Couldn't read run state: "+err.Error()) return p.replyDM(ctx, "Couldn't read run state: "+err.Error())
} }
@@ -89,18 +95,19 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
return p.replyDM(ctx, "_(No bestiary entry for this encounter — file a bug. `!zone abandon` to bail.)_") 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) // Seat the whole party, leader first. A solo player is a one-seat roster and
if err != nil { // takes the path they always took: one build, one INSERT, no participant rows.
return p.replyDM(ctx, "Couldn't set up the fight: "+err.Error()) seats, enemy, senderSkip, refusal := p.buildFightSeats(ctx.Sender, roster, monster, int(zone.Tier), run.DMMood)
} if refusal != "" {
return p.replyDM(ctx, refusal)
playerHP, playerMax := dndHPSnapshot(ctx.Sender)
if playerHP <= 0 {
return p.replyDM(ctx, "You're in no shape to fight. `!rest` first.")
} }
enemyHP := enemy.Stats.MaxHP 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 != nil {
if err == ErrCombatSessionAlreadyActive { if err == ErrCombatSessionAlreadyActive {
return p.replyDM(ctx, "You're already in a fight. Finish it with `!attack` / `!flee`.") return p.replyDM(ctx, "You're already in a fight. Finish it with `!attack` / `!flee`.")
@@ -108,15 +115,6 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
return p.replyDM(ctx, "Couldn't start the fight: "+err.Error()) return p.replyDM(ctx, "Couldn't start the fight: "+err.Error())
} }
// Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto
// 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)
}
}
var b strings.Builder var b strings.Builder
if isBoss { if isBoss {
if line := composeBossEntry(zone.ID, run.RunID, run.CurrentRoom); line != "" { if line := composeBossEntry(zone.ID, run.RunID, run.CurrentRoom); line != "" {
@@ -129,13 +127,51 @@ 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("⚔️ **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(curios)
b.WriteString("\n") b.WriteString("\n")
} }
b.WriteString("\n") b.WriteString("\n")
b.WriteString(combatTurnPrompt(sess)) b.WriteString(combatTurnPrompt(sess))
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()) return p.replyDM(ctx, b.String())
} }
@@ -149,29 +185,43 @@ func (p *AdventurePlugin) handleFleeCmd(ctx MessageContext) error {
return p.handleCombatActionCmd(ctx, PlayerAction{Kind: ActionFlee}) 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 { func (p *AdventurePlugin) handleCombatActionCmd(ctx MessageContext, action PlayerAction) error {
userMu := p.advUserLock(ctx.Sender) ct, release, msg := p.beginCombatTurn(ctx.Sender, noFightMsg)
userMu.Lock() if ct == nil {
defer userMu.Unlock() return p.replyDM(ctx, msg)
sess, err := getActiveCombatSession(ctx.Sender)
if err != nil {
return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
} }
if sess == nil { defer release()
return p.replyDM(ctx, "You're not in a fight. `!fight` at an Elite or Boss room to start one.")
// 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.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
}
events, err := runCombatRound(sess, &player, &enemy, action)
if err != nil { if err != nil {
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error()) return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
} }
return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy)) return p.replyCombatRound(ctx, ct, events)
}
// 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))
}
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 // renderRoundResult turns a resolved round into the player-facing block: the
@@ -192,24 +242,64 @@ func (p *AdventurePlugin) renderRoundResult(userID id.UserID, sess *CombatSessio
return b.String() return b.String()
} }
// runCombatRound resolves one full round: the player's chosen action, then the // runCombatRound resolves one full round of a solo fight: the player's chosen
// enemy turn and the round-end status tick, advancing the session until it is // action, then the enemy turn and the round-end status tick, advancing the
// back at a player_turn or has reached a terminal status. Returns every event // session until it is back at a player_turn or has reached a terminal status.
// the round produced. Each advanceCombatSession call persists the session, so // Returns every event the round produced. Each advance persists the session, so
// a crash mid-round resumes cleanly from the last phase. // a crash mid-round resumes cleanly from the last phase.
func runCombatRound(sess *CombatSession, player, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) { 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 { if err != nil {
return events, err return events, err
} }
for sess.IsActive() && sess.Phase != CombatPhasePlayerTurn { more, err := settleCombatSession(sess, players, enemy)
more, merr := advanceCombatSession(sess, player, enemy, PlayerAction{}) 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 { if merr != nil {
return events, merr return events, merr
} }
events = append(events, more...) 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 // combatTurnPrompt is the "your move" footer shown after every non-terminal
@@ -226,10 +316,14 @@ func combatTurnPrompt(sess *CombatSession) string {
// wrong surface — point them at `!expedition run` instead. Standalone // wrong surface — point them at `!expedition run` instead. Standalone
// zone runs still advance with `!zone advance`. // zone runs still advance with `!zone advance`.
func continueHint(userID id.UserID) string { func continueHint(userID id.UserID) string {
if exp, err := getActiveExpedition(userID); err == nil && exp != nil { exp, isLeader, err := activeExpeditionFor(userID)
return "`!expedition run` to keep going." switch {
} case err != nil || exp == nil:
return "`!zone advance` to move on." return "`!zone advance` to move on."
case !isLeader:
return "Your leader marches the party on."
}
return "`!expedition run` to keep going."
} }
// finishCombatSession runs the post-fight side effects once a CombatSession // finishCombatSession runs the post-fight side effects once a CombatSession
@@ -287,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", b.WriteString(fmt.Sprintf("%s **%s** down. You finished at **%d/%d HP**.\n",
emoji, enemy.Name, sess.PlayerHP, sess.PlayerHPMax)) 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") b.WriteString(drop + "\n")
} }
if bossOnExpedition { if bossOnExpedition {
@@ -405,65 +499,87 @@ func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefin
} }
func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) error { func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) error {
userMu := p.advUserLock(ctx.Sender) // "not in a fight anymore" — this handler is only routed to when the caller
userMu.Lock() // already saw an active session, so a miss here means it closed under them.
defer userMu.Unlock() 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 err != nil {
return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
}
if sess == nil {
// Race: the fight closed between the route check and the lock.
return p.replyDM(ctx, "You're not in a fight anymore.")
}
advChar, _ := loadAdvCharacter(ctx.Sender)
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
if err != nil || c == nil {
return p.replyDM(ctx, "Couldn't load your Adv 2.0 sheet.")
}
if !isSpellcaster(c) {
return p.replyDM(ctx, fmt.Sprintf(
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class)))
}
spell, slotLevel, errMsg := parseCombatCast(ctx.Sender, c, strings.TrimSpace(args))
if errMsg != "" { if errMsg != "" {
return p.replyDM(ctx, errMsg) return p.replyDM(ctx, errMsg)
} }
if spell.Effect == EffectReaction { events, err := p.driveCombatRound(ct, action)
return p.replyDM(ctx, fmt.Sprintf( settle(err == nil)
"%s is a reaction spell — those still aren't usable. Pick a damage, healing, or control spell.", spell.Name)) if err != nil {
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
}
return p.replyCombatRound(ctx, ct, events)
}
// 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 PlayerAction{}, noop, "Couldn't load your Adv 2.0 sheet."
}
if !isSpellcaster(c) {
return PlayerAction{}, noop, fmt.Sprintf(
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class))
} }
player, enemy, err := p.combatantsForSession(sess) spell, slotLevel, errMsg := parseCombatCast(uid, c, strings.TrimSpace(args))
if err != nil { if errMsg != "" {
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error()) return PlayerAction{}, noop, errMsg
}
if spell.Effect == EffectReaction {
return PlayerAction{}, noop, fmt.Sprintf(
"%s is a reaction spell — those still aren't usable. Pick a damage, healing, or control spell.", spell.Name)
}
refund := func(ok bool) {
if !ok && spell.Level > 0 {
_ = refundSpellSlot(uid, slotLevel)
}
} }
var eff *turnActionEffect var eff *turnActionEffect
if spell.Effect == EffectBuffSelf || spell.Effect == EffectBuffAlly { if spell.Effect == EffectBuffSelf || spell.Effect == EffectBuffAlly {
// Buff path — resolve the buff against a throwaway combatant, fold the // Buff path — resolve the buff against a throwaway combatant, fold the
// marginal effect into the session's persisted state, then rebuild the // marginal effect into that seat's persisted state, then rebuild the
// pair so the buff is live for this round's enemy turn. // roster so the buff is live for this round's enemy turn.
player := ct.players[seat]
as, am := player.Stats, player.Mods as, am := player.Stats, player.Mods
applySpellBuff(spell, c, &as, &am, slotLevel) applySpellBuff(spell, c, &as, &am, slotLevel)
d := diffTurnBuff(player.Stats, as, player.Mods, am) d := diffTurnBuff(player.Stats, as, player.Mods, am)
if !d.any() { if !d.any() {
return p.replyDM(ctx, fmt.Sprintf( return PlayerAction{}, noop, fmt.Sprintf(
"%s has no effect the turn-based engine can apply yet.", spell.Name)) "%s has no effect the turn-based engine can apply yet.", spell.Name)
} }
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" { if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
return p.replyDM(ctx, msg) return PlayerAction{}, noop, msg
} }
sess.Statuses.applyBuffDelta(d) ct.sess.actorStatusesPtr(seat).applyBuffDelta(d)
player, enemy, err = p.combatantsForSession(sess) if rerr := p.rebuildRoster(ct); rerr != nil {
if err != nil { refund(false)
if spell.Level > 0 { return PlayerAction{}, noop, "Couldn't rebuild the fight: " + rerr.Error()
_ = refundSpellSlot(ctx.Sender, slotLevel)
}
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
} }
label := spell.Name + " — active" label := spell.Name + " — active"
if d.heal > 0 { if d.heal > 0 {
@@ -474,13 +590,13 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
PlayerHeal: d.heal, EnemySkip: d.enemySkip, PlayerHeal: d.heal, EnemySkip: d.enemySkip,
} }
} else { } else {
out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats) out, supported := resolveTurnSpell(c, spell, slotLevel, &ct.enemy.Stats)
if !supported { if !supported {
return p.replyDM(ctx, fmt.Sprintf( 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)) "%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 != "" { if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
return p.replyDM(ctx, msg) return PlayerAction{}, noop, msg
} }
eff = &turnActionEffect{ eff = &turnActionEffect{
Label: out.Desc, Label: out.Desc,
@@ -489,16 +605,27 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
PlayerHeal: out.PlayerHeal, PlayerHeal: out.PlayerHeal,
EnemySkip: out.EnemySkip, EnemySkip: out.EnemySkip,
} }
// Concentration AOE damage spells linger: the burst lands this round
// (EnemyDamage) and the same value re-ticks every round_end after, via
// the engine's concentration aura. spiritual_weapon already covers the
// cleric's bonus-action half of the combo; this restores the action half.
if spell.Concentration &&
(spell.Effect == EffectDamageAuto || spell.Effect == EffectDamageSave) {
eff.ConcentrationDmg = out.EnemyDamage
} }
}
return PlayerAction{Kind: ActionCast, Effect: eff}, refund, ""
}
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff}) // 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 { if err != nil {
if spell.Level > 0 { return err
_ = refundSpellSlot(ctx.Sender, slotLevel)
} }
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error()) ct.players, ct.enemy = players, enemy
} return nil
return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
} }
// chargeSpellCost debits a spell's material component and leveled slot for a // chargeSpellCost debits a spell's material component and leveled slot for a
@@ -563,21 +690,21 @@ func matchConsumable(inv []ConsumableItem, query string) (*ConsumableItem, strin
} }
func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) error { func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) error {
userMu := p.advUserLock(ctx.Sender) 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."
userMu.Lock()
defer userMu.Unlock()
sess, err := getActiveCombatSession(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 { if err != nil {
return p.replyDM(ctx, "Couldn't read combat state: "+err.Error()) return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
} }
if sess == nil { if probe == nil {
return p.replyDM(ctx, "`!consume <item>` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically.") return p.replyDM(ctx, notFighting)
} }
inv := p.loadConsumableInventory(ctx.Sender) inv := p.loadConsumableInventory(ctx.Sender)
args = strings.TrimSpace(args)
if args == "" {
if len(inv) == 0 { if len(inv) == 0 {
return p.replyDM(ctx, "You have no combat consumables. `!attack` / `!cast` / `!flee`.") return p.replyDM(ctx, "You have no combat consumables. `!attack` / `!cast` / `!flee`.")
} }
@@ -588,20 +715,45 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
return p.replyDM(ctx, "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) item, ambig := matchConsumable(inv, args)
if ambig != "" { if ambig != "" {
return p.replyDM(ctx, ambig) return PlayerAction{}, noop, ambig
} }
if item == nil { if item == nil {
return p.replyDM(ctx, 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 def := item.Def
player, enemy, err := p.combatantsForSession(sess)
if err != nil {
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
}
eff := &turnActionEffect{Action: "use_consumable"} eff := &turnActionEffect{Action: "use_consumable"}
switch def.Effect { switch def.Effect {
case EffectHeal: case EffectHeal:
@@ -612,34 +764,32 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
eff.Label = fmt.Sprintf("%s — %d damage", def.Name, eff.EnemyDamage) eff.Label = fmt.Sprintf("%s — %d damage", def.Name, eff.EnemyDamage)
default: default:
// Buff-type consumable — resolve the marginal effect against a // Buff-type consumable — resolve the marginal effect against a
// throwaway combatant, fold it into the session's persisted state, and // throwaway combatant, fold it into that seat's persisted state, and
// rebuild the pair so the buff is live for this round's enemy turn. // rebuild the roster so the buff is live for this round's enemy turn.
player := ct.players[seat]
as, am := player.Stats, player.Mods as, am := player.Stats, player.Mods
ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}}) ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}})
d := diffTurnBuff(player.Stats, as, player.Mods, am) d := diffTurnBuff(player.Stats, as, player.Mods, am)
if !d.any() { if !d.any() {
return p.replyDM(ctx, fmt.Sprintf( return PlayerAction{}, noop, fmt.Sprintf(
"**%s** has no effect the turn-based engine can apply yet.", def.Name)) "**%s** has no effect the turn-based engine can apply yet.", def.Name)
} }
sess.Statuses.applyBuffDelta(d) ct.sess.actorStatusesPtr(seat).applyBuffDelta(d)
player, enemy, err = p.combatantsForSession(sess) if rerr := p.rebuildRoster(ct); rerr != nil {
if err != nil { return PlayerAction{}, noop, "Couldn't rebuild the fight: " + rerr.Error()
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
} }
eff.Label = def.Name + " — active" eff.Label = def.Name + " — active"
eff.PlayerHeal = d.heal eff.PlayerHeal = d.heal
eff.EnemySkip = d.enemySkip eff.EnemySkip = d.enemySkip
} }
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionConsume, Effect: eff}) burn := func(ok bool) {
if err != nil { if !ok {
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error()) return
} }
// 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 { if rerr := removeAdvInventoryItem(item.InventoryID); rerr != nil {
slog.Error("combat: consume remove inventory item failed", "user", ctx.Sender, "item", def.Name, "err", rerr) slog.Error("combat: consume remove inventory item failed", "user", uid, "item", def.Name, "err", rerr)
} }
return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy)) }
return PlayerAction{Kind: ActionConsume, Effect: eff}, burn, ""
} }

View File

@@ -211,6 +211,15 @@ type CombatEvent struct {
// Roll is the raw d20 (1..20); RollAgainst is the target AC. // Roll is the raw d20 (1..20); RollAgainst is the target AC.
Roll int Roll int
RollAgainst 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 { type CombatResult struct {
@@ -292,10 +301,28 @@ var bossCombatPhases = []CombatPhase{
// ── Simulation ─────────────────────────────────────────────────────────────── // ── Simulation ───────────────────────────────────────────────────────────────
// combatState tracks mutable state during the simulation. // actor is the per-combatant half of the simulation state — everything that
type combatState struct { // 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 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 // Consumable one-shots
healChargesLeft int // remaining heal-at-<50% triggers healChargesLeft int // remaining heal-at-<50% triggers
@@ -304,13 +331,10 @@ type combatState struct {
reflectFrac float64 reflectFrac float64
autoCrit bool autoCrit bool
// Monster ability effects // Monster ability effects landing on this actor
poisonTicks int poisonTicks int
poisonDmg int poisonDmg int
stunPlayer bool stunPlayer bool
enraged bool
armorBroken bool
armorBreakAmt float64
// Sovereign reprieve // Sovereign reprieve
deathSaveUsed bool deathSaveUsed bool
@@ -320,10 +344,6 @@ type combatState struct {
raged bool // Orc Rage already triggered this fight raged bool // Orc Rage already triggered this fight
pendingRageAttack bool // next player attack gets +50% damage 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. // Phase 10 SUB2a-ii first-attack one-shots.
firstAttackBonusUsed bool firstAttackBonusUsed bool
assassinateRerollUsed bool assassinateRerollUsed bool
@@ -332,19 +352,56 @@ type combatState struct {
// Phase 10 SUB2b — Abjuration Arcane Ward HP buffer. // Phase 10 SUB2b — Abjuration Arcane Ward HP buffer.
arcaneWardHP int 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 // Phase 13 bestiary slice 3 — stateful monster-ability effects. Each is
// armed by applyAbility and read by the shared resolution primitives, so // armed by applyAbility and read by the shared resolution primitives, so
// both engines honour them; the turn-based engine additionally round-trips // both engines honour them; the turn-based engine additionally round-trips
// them through CombatStatuses so they survive a suspend/resume. // 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 enemyEvadeNext bool // evade: next player weapon attack auto-misses
enemyBlockUp bool // block: enemy holds a parry stance (~50% block on player hits) enemyBlockUp bool // block: enemy holds a parry stance (~50% block on player hits)
enemyAdvantage bool // advantage: enemy rolls its attacks with advantage enemyAdvantage bool // advantage: enemy rolls its attacks with advantage
enemyRetaliateFrac float64 // retaliate: fraction of a player hit reflected back enemyRetaliateFrac float64 // retaliate: fraction of a player hit reflected back
enemyRegen int // regenerate: enemy heals this much each round end enemyRegen int // regenerate: enemy heals this much each round end
enemySurviveArmed bool // survive_at_1: enemy cheats death once, dropping to 1 HP 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 // Phase 13 bestiary slice 4 — the former flavor-only placeholders, now
// backed by real state. // backed by real state.
@@ -369,6 +426,50 @@ type combatState struct {
// Auto-resolve leaves st.rng nil — behaviorally identical to the // Auto-resolve leaves st.rng nil — behaviorally identical to the
// pre-injection code; the turn-based engine and the timeout reaper seed // pre-injection code; the turn-based engine and the timeout reaper seed
// it per session so a fight can be resumed and replayed reproducibly. // 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) roll(n int) int { return rngIntN(st.rng, n) }
func (st *combatState) randFloat() float64 { return rngFloat(st.rng) } 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 // 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 // so a fight is fully reproducible. Passing nil is behaviorally identical to
// the pre-injection code. // 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 { func simulateCombatWithRNG(player, enemy Combatant, phases []CombatPhase, rng *rand.Rand) CombatResult {
playerStart := player.Stats.MaxHP return simulatePartyWithRNG([]Combatant{player}, enemy, phases, rng).Seats[0]
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
} }
// maybeTriggerOrcRage emits the "rage" event and arms pendingRageAttack // maybeTriggerOrcRage emits the "rage" event and arms pendingRageAttack
@@ -1523,25 +1298,3 @@ func trySave(st *combatState, player *Combatant, phaseName string) bool {
} }
return false 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 return e
}(), defaultCombatPhases}, }(), defaultCombatPhases},
{"weapon_profile", weaponPlayer(), baseEnemy(), 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 // charSeeds drives each scenario. Multiple seeds widen RNG-branch coverage
// without exploding the golden file. // without exploding the golden file.
var charSeeds = []uint64{1, 2, 3, 7, 42} 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": case "spirit_weapon_strike":
return fmt.Sprintf(pickRand(narrativeSpiritWeapon), e.Damage) return fmt.Sprintf(pickRand(narrativeSpiritWeapon), e.Damage)
case "concentration_tick":
return fmt.Sprintf(pickRand(narrativeConcentrationTick), e.Damage)
case "pet_deflect": case "pet_deflect":
return pickRand(narrativePetDeflect) 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.", "✨ 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{ var narrativePetDeflect = []string{
"🐾 Your pet intercepts the blow. Damage halved. Your pet is now your best piece of equipment.", "🐾 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.", "🐾 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 // handful of events, and across a long boss fight the picker resetting each
// round reads as variety rather than staleness. // round reads as variety rather than staleness.
func RenderTurnRound(events []CombatEvent, playerName, enemyName string) string { 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() picker := newActionPicker()
var lines []string var lines []string
for _, e := range events { 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 == "" { if line == "" {
continue continue
} }
if roll := rollAnnotation(e); roll != "" {
line += " " + roll
}
lines = append(lines, line) lines = append(lines, line)
} }
if len(lines) == 0 { if len(lines) == 0 {
@@ -882,6 +922,80 @@ func RenderTurnRound(events []CombatEvent, playerName, enemyName string) string
return strings.Join(lines, "\n") 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 { func renderTurnEvent(e CombatEvent, playerName, enemyName string, picker *actionPicker) string {
switch e.Action { switch e.Action {
case "flee": 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. // nothing to do.
func (p *AdventurePlugin) reapCombatSession(userIDStr, sessionID string) { func (p *AdventurePlugin) reapCombatSession(userIDStr, sessionID string) {
userID := id.UserID(userIDStr) userID := id.UserID(userIDStr)
userMu := p.advUserLock(userID) // The session's owner is seat 0, which is the user the sweep listed — so the
userMu.Lock() // fight lock and the user lock are the same mutex here, taken once.
defer userMu.Unlock() release := p.lockCombatFight(userID, userID)
defer release()
sess, err := getActiveCombatSession(userID) sess, err := getActiveCombatSession(userID)
if err != nil { if err != nil {
@@ -58,41 +59,51 @@ func (p *AdventurePlugin) reapCombatSession(userIDStr, sessionID string) {
return return
} }
player, enemy, err := p.combatantsForSession(sess) 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 { if err != nil {
// Can't reconstruct the fight — park it terminal so it isn't retried // Can't reconstruct the fight — park it terminal so it isn't retried
// every minute forever. // every minute forever.
slog.Warn("combat: reaper cannot rebuild session, marking expired", expire("cannot rebuild session, marking expired", "err", err)
"user", userID, "session", sess.SessionID, "err", err)
if merr := markCombatSessionExpired(sess.SessionID); merr != nil {
slog.Error("combat: reaper failed to mark session expired", "session", sess.SessionID, "err", merr)
}
return 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 rounds := 0
for sess.IsActive() { for sess.IsActive() {
if rounds >= reaperRoundCap { if rounds >= reaperRoundCap {
slog.Warn("combat: reaper hit round cap, marking expired", expire("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)
}
return return
} }
if _, rerr := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionAttack}); rerr != nil { if _, rerr := runPartyCombatRound(sess, players, enemy, PlayerAction{Kind: ActionAttack}); rerr != nil {
slog.Error("combat: reaper round failed", "user", userID, "session", sess.SessionID, "err", rerr) expire("round failed", "err", rerr)
if merr := markCombatSessionExpired(sess.SessionID); merr != nil {
slog.Error("combat: reaper failed to mark session expired", "session", sess.SessionID, "err", merr)
}
return return
} }
rounds++ 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) 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 { 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) 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" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"strings"
"time" "time"
"gogobee/internal/db" "gogobee/internal/db"
@@ -43,33 +44,34 @@ const (
// reaper sweeps it. Matches the started_at + 1h note in the schema. // reaper sweeps it. Matches the started_at + 1h note in the schema.
const combatSessionTTL = time.Hour const combatSessionTTL = time.Hour
// CombatStatuses is the serialized between-round effect state for a fight: the // ActorStatuses is the per-seat half of the persisted effect state: everything
// subset of combatState that genuinely persists round-to-round, plus the // that belongs to one character rather than to the fight. It mirrors the fields
// fight-scoped buffs a mid-fight !cast / !consume layers on. Everything here // of `actor` (combat_engine.go) that must survive a suspend/resume.
// round-trips combatState -> Statuses -> combatState across every engine step,
// so a fight resumes from exact mid-state after a suspend or bot restart.
// //
// 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 ==. // persistence round-trip can be asserted with ==.
type CombatStatuses struct { type ActorStatuses struct {
// Monster-ability effects — the original between-round persistence set. // Monster-ability effects landing on this character.
PoisonTicks int `json:"poison_ticks,omitempty"` PoisonTicks int `json:"poison_ticks,omitempty"`
PoisonDmg int `json:"poison_dmg,omitempty"` PoisonDmg int `json:"poison_dmg,omitempty"`
StunPlayer bool `json:"stun_player,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"`
// Fight-scoped depleting resources — mirror the combatState charges that // Depleting resources — mirror the actor charges that genuinely carry
// genuinely carry round-to-round. Seeded at fight start (Arcane Ward) or by // round-to-round. Seeded at fight start (Arcane Ward) or by a mid-fight
// a mid-fight !cast / !consume, restored into combatState on every resume, // !cast / !consume, restored into the actor on every resume, written back on
// written back on every commit so a depleting charge can't silently reset // every commit so a depleting charge can't silently reset when a fight is
// when a fight is suspended and resumed. // suspended and resumed.
WardCharges int `json:"ward_charges,omitempty"` WardCharges int `json:"ward_charges,omitempty"`
SporeRounds int `json:"spore_rounds,omitempty"` SporeRounds int `json:"spore_rounds,omitempty"`
ReflectFrac float64 `json:"reflect_frac,omitempty"` ReflectFrac float64 `json:"reflect_frac,omitempty"`
@@ -77,6 +79,15 @@ type CombatStatuses struct {
ArcaneWardHP int `json:"arcane_ward_hp,omitempty"` ArcaneWardHP int `json:"arcane_ward_hp,omitempty"`
HealChargesLeft int `json:"heal_charges_left,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. // Once-per-fight class/race/subclass one-shots: the "already used" flags.
// Without persistence these reset every round on resume, letting a Halfling // 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. // 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"` AssassinateReroll bool `json:"assassinate_reroll_used,omitempty"`
AssassinateBonus bool `json:"assassinate_bonus_used,omitempty"` AssassinateBonus bool `json:"assassinate_bonus_used,omitempty"`
// Slice-3 stateful monster-ability effects — armed by applyAbility, read by // Autopilot latches this seat onto the auto-picker for the rest of the
// the shared resolution primitives, round-tripped through combatState so a // fight, set when its turn deadline lapses once (see partyTurnDeadline).
// suspended/resumed fight (or a reaper auto-play) keeps the same effect // Without the latch an away member taxes the party the full deadline every
// state. EnemyEvadeNext is a one-shot; the rest persist for the fight. // single round; with it, they cost one wait and then resolve instantly. Any
EnemyEvadeNext bool `json:"enemy_evade_next,omitempty"` // combat command from that member clears it.
EnemyBlockUp bool `json:"enemy_block_up,omitempty"` //
EnemyAdvantage bool `json:"enemy_advantage,omitempty"` // Like the Buff* deltas it is session-layer state with no combatState
EnemyRetaliateFrac float64 `json:"enemy_retaliate_frac,omitempty"` // counterpart, so snapshotActor carries it over from the prior snapshot
EnemyRegen int `json:"enemy_regen,omitempty"` // rather than re-deriving it. omitempty keeps it off every solo row — a solo
EnemySurviveArmed bool `json:"enemy_survive_armed,omitempty"` // fight has no turn deadline, only the 1h session reaper.
Autopilot bool `json:"autopilot,omitempty"`
// Debuffs the enemy has stacked onto this character specifically.
PlayerAtkDrain int `json:"player_atk_drain,omitempty"` PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
PlayerACDebuff int `json:"player_ac_debuff,omitempty"` PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
MaxHPDrain int `json:"max_hp_drain,omitempty"` MaxHPDrain int `json:"max_hp_drain,omitempty"`
// Slice-4 monster-ability effects — the former flavor-only placeholders. // Persistent stat buffs from this character's mid-fight !cast / !consume,
// EnemyRevealNext is a one-shot; the other three persist for the fight. // accumulated as deltas against their freshly-rebuilt combatant.
EnemySpellResist bool `json:"enemy_spell_resist,omitempty"` // applySessionBuffs folds these back on every round; diffTurnBuff produces
EnemyRevealNext bool `json:"enemy_reveal_next,omitempty"` // them. BuffDamageReductMul is multiplicative (0 = none / 1.0 neutral).
EnemyFearImmune bool `json:"enemy_fear_immune,omitempty"`
EnemyAtkBuff int `json:"enemy_atk_buff,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).
BuffACBonus int `json:"buff_ac_bonus,omitempty"` BuffACBonus int `json:"buff_ac_bonus,omitempty"`
BuffAtkBonus int `json:"buff_atk_bonus,omitempty"` BuffAtkBonus int `json:"buff_atk_bonus,omitempty"`
BuffSpeedBonus int `json:"buff_speed_bonus,omitempty"` BuffSpeedBonus int `json:"buff_speed_bonus,omitempty"`
@@ -125,10 +132,65 @@ type CombatStatuses struct {
BuffSpiritDmg int `json:"buff_spirit_dmg,omitempty"` 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 // 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. // 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.BuffACBonus += d.dAC
s.BuffAtkBonus += d.dAtk s.BuffAtkBonus += d.dAtk
s.BuffSpeedBonus += d.dSpeed 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 — // 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. // but the full set is seeded for robustness. Returns true if anything was set.
func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) bool { 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.WardCharges = playerMods.WardCharges
st.SporeRounds = playerMods.SporeCloud st.SporeRounds = playerMods.SporeCloud
st.ReflectFrac = playerMods.ReflectNext st.ReflectFrac = playerMods.ReflectNext
@@ -171,6 +240,17 @@ func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) boo
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 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. // CombatSession is the in-memory shape of a combat_session row.
type CombatSession struct { type CombatSession struct {
SessionID string SessionID string
@@ -190,11 +270,116 @@ type CombatSession struct {
StartedAt time.Time StartedAt time.Time
LastActionAt time.Time LastActionAt time.Time
ExpiresAt 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. // IsActive reports whether the fight is still in flight.
func (s *CombatSession) IsActive() bool { return s.Status == CombatStatusActive } 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. // Errors returned by the combat session layer.
var ( var (
ErrCombatSessionAlreadyActive = errors.New("combat session already active for player") 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, session_id, user_id, run_id, encounter_id, enemy_id,
round, phase, player_hp, player_hp_max, enemy_hp, enemy_hp_max, round, phase, player_hp, player_hp_max, enemy_hp, enemy_hp_max,
statuses_json, turn_log_json, status, 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. // newCombatSessionID — 16-char hex token. Same scheme as zone runs / expeditions.
func newCombatSessionID() string { func newCombatSessionID() string {
@@ -268,6 +453,80 @@ func startCombatSession(userID id.UserID, runID, encounterID, enemyID string, pl
return s, nil 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). // getActiveCombatSession returns the player's in-flight fight, or (nil, nil).
func getActiveCombatSession(userID id.UserID) (*CombatSession, error) { func getActiveCombatSession(userID id.UserID) (*CombatSession, error) {
row := db.Get().QueryRow(`SELECT `+combatSessionCols+` row := db.Get().QueryRow(`SELECT `+combatSessionCols+`
@@ -279,7 +538,10 @@ func getActiveCombatSession(userID id.UserID) (*CombatSession, error) {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil 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 // 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) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
} }
return s, err if err != nil {
return nil, err
}
return s, hydrateCombatParticipants(s)
} }
// getCombatSessionForEncounter returns the most recent session for a // 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) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
} }
return s, err if err != nil {
return nil, err
}
return s, hydrateCombatParticipants(s)
} }
func scanCombatSession(row scanner) (*CombatSession, error) { 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.SessionID, &s.UserID, &s.RunID, &s.EncounterID, &s.EnemyID,
&s.Round, &s.Phase, &s.PlayerHP, &s.PlayerHPMax, &s.EnemyHP, &s.EnemyHPMax, &s.Round, &s.Phase, &s.PlayerHP, &s.PlayerHPMax, &s.EnemyHP, &s.EnemyHPMax,
&statusesJSON, &logJSON, &s.Status, &statusesJSON, &logJSON, &s.Status,
&s.StartedAt, &s.LastActionAt, &s.ExpiresAt, &s.StartedAt, &s.LastActionAt, &s.ExpiresAt, &s.rosterSize,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -360,17 +628,23 @@ func scanCombatSession(row scanner) (*CombatSession, error) {
// saveCombatSession persists the mutable fields after a state-machine step. // saveCombatSession persists the mutable fields after a state-machine step.
// session_id / user_id / run_id / encounter_id / enemy_id / *_hp_max are // session_id / user_id / run_id / encounter_id / enemy_id / *_hp_max are
// immutable for a fight's lifetime and are not rewritten. // 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 { func saveCombatSession(s *CombatSession) error {
statusesJSON, _ := json.Marshal(s.Statuses) statusesJSON, _ := json.Marshal(s.Statuses)
logJSON, _ := json.Marshal(s.TurnLog) logJSON, _ := json.Marshal(s.TurnLog)
now := time.Now().UTC() now := time.Now().UTC()
if _, err := db.Get().Exec(`
UPDATE combat_session if len(s.Participants) == 0 {
SET round = ?, phase = ?, if _, err := db.Get().Exec(saveCombatSessionSQL,
player_hp = ?, enemy_hp = ?,
statuses_json = ?, turn_log_json = ?,
status = ?, last_action_at = ?
WHERE session_id = ?`,
s.Round, s.Phase, s.PlayerHP, s.EnemyHP, s.Round, s.Phase, s.PlayerHP, s.EnemyHP,
string(statusesJSON), string(logJSON), s.Status, now, s.SessionID, string(statusesJSON), string(logJSON), s.Status, now, s.SessionID,
); err != nil { ); err != nil {
@@ -378,14 +652,44 @@ func saveCombatSession(s *CombatSession) error {
} }
s.LastActionAt = now s.LastActionAt = now
return nil 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. // listExpiredCombatSessions returns every active session past its expires_at.
// The timeout reaper (reapExpiredCombatSessions) auto-plays each of these to a // 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 // 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. // abandoned fight is finished by the engine, not flatly marked as a retreat.
func listExpiredCombatSessions() ([]*CombatSession, error) { func listExpiredCombatSessions() ([]*CombatSession, error) {
rows, err := db.Get().Query(`SELECT `+combatSessionCols+` rows, err := db.Get().Query(`SELECT ` + combatSessionCols + `
FROM combat_session FROM combat_session
WHERE status = 'active' WHERE status = 'active'
AND expires_at <= CURRENT_TIMESTAMP AND expires_at <= CURRENT_TIMESTAMP
@@ -403,9 +707,162 @@ func listExpiredCombatSessions() ([]*CombatSession, error) {
} }
out = append(out, s) 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() 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 // markCombatSessionExpired is the fallback terminal outcome for a stale session
// the reaper cannot auto-play (zone run gone, enemy missing from the bestiary, // 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' // 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 return player, enemy, dndChar, nil
} }
// combatantsForSession reconstructs the Combatant pair for an in-flight // combatantsForSession reconstructs the Combatant pair for an in-flight solo
// CombatSession. It reloads the zone run for the DM mood + tier and looks the // CombatSession — the shape every caller wanted before parties existed. It is
// enemy up in the bestiary by the session's EnemyID. The pair carries no HP — // partyCombatantsForSession reduced to seat 0, and it errors rather than
// the turn engine reads that from the session row. // silently dropping the rest of a party's roster on the floor.
func (p *AdventurePlugin) combatantsForSession(sess *CombatSession) (player Combatant, enemy Combatant, err error) { 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) run, rerr := getZoneRun(sess.RunID)
if rerr != nil { 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 { 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] monster, ok := dndBestiary[sess.EnemyID]
if !ok { 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) zone := zoneOrFallback(run.ZoneID)
player, enemy, _, err = p.buildZoneCombatants(id.UserID(sess.UserID), monster, int(zone.Tier), run.DMMood)
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 { if err != nil {
return player, enemy, err return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
} }
// Fold any fight-scoped buffs a mid-fight !cast / !consume layered on back // Fold any fight-scoped buffs this seat's mid-fight !cast / !consume
// onto the freshly-rebuilt player. The depleting one-shots (ward/spore/…) // layered on back onto their freshly-rebuilt combatant. The depleting
// live on the session's Statuses and flow through the turn engine's // one-shots (ward/spore/…) live on their persisted statuses and flow
// resume/commit cycle, so only the persistent stat deltas are applied here. // through the turn engine's resume/commit cycle, so only the persistent
applySessionBuffs(&player, sess.Statuses) // stat deltas are applied here — and only that seat's own.
return player, enemy, err 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
}
}
return players, &enemy, nil
} }
// applySessionBuffs re-derives the persistent stat effect of every mid-fight // applySessionBuffs re-derives the persistent stat effect of every mid-fight
// buff onto the rebuilt player. The buffs are stored as accumulated deltas // buff onto one rebuilt character. The buffs are stored as accumulated deltas
// (diffTurnBuff produced them against the player's state at cast time), so // (diffTurnBuff produced them against that character's state at cast time), so
// re-applying them to a deterministic rebuild reproduces the same totals every // re-applying them to a deterministic rebuild reproduces the same totals every
// round without double-counting. // round without double-counting. It takes ActorStatuses rather than the whole
func applySessionBuffs(player *Combatant, s CombatStatuses) { // 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.AC += s.BuffACBonus
player.Stats.AttackBonus += s.BuffAtkBonus player.Stats.AttackBonus += s.BuffAtkBonus
player.Stats.Speed += s.BuffSpeedBonus player.Stats.Speed += s.BuffSpeedBonus

View File

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

View File

@@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"math/rand/v2" "math/rand/v2"
"sort"
) )
// Phase 13 — Turn-based combat state machine. // Phase 13 — Turn-based combat state machine.
@@ -64,31 +65,76 @@ type turnActionEffect struct {
EnemyDamage int EnemyDamage int
PlayerHeal int PlayerHeal int
EnemySkip bool // control spell: enemy forfeits its attack this round 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 // turnEngine wraps a combatState reconstructed from a persisted CombatSession
// so a single phase can be resolved and then written back. // 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 { type turnEngine struct {
sess *CombatSession sess *CombatSession
player *Combatant players []*Combatant
enemy *Combatant enemy *Combatant
order []int
st *combatState st *combatState
result *CombatResult 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 // combatSessionRNG seeds a deterministic generator from the session id and the
// phase being resolved, so a fight replays reproducibly no matter how many bot // 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 // 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. // 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 { func combatSessionRNG(sess *CombatSession) *rand.Rand {
var seed uint64 = 1469598103934665603 // FNV-ish offset basis return combatSessionStepRNG(sess, 0)
for _, c := range sess.SessionID { }
seed = (seed ^ uint64(c)) * 1099511628211
// 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) stream := uint64(sess.Round)*4 + phaseOrdinal(sess.Phase)
return rand.New(rand.NewPCG(seed, stream)) 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 { func phaseOrdinal(phase string) uint64 {
switch phase { switch phase {
case CombatPhasePlayerTurn: 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. // resumeTurnEngine rebuilds the in-memory combatState from a persisted session.
// rng is the deterministic source for this step (see combatSessionRNG). // rng is the deterministic source for this step (see combatSessionStepRNG).
func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.Rand) *turnEngine { //
// 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{ st := &combatState{
playerHP: sess.PlayerHP, actor: seat0,
actors: actors,
enemyHP: sess.EnemyHP, enemyHP: sess.EnemyHP,
round: sess.Round, round: sess.Round,
poisonTicks: sess.Statuses.PoisonTicks,
poisonDmg: sess.Statuses.PoisonDmg,
stunPlayer: sess.Statuses.StunPlayer,
enraged: sess.Statuses.Enraged, enraged: sess.Statuses.Enraged,
armorBroken: sess.Statuses.ArmorBroken, armorBroken: sess.Statuses.ArmorBroken,
armorBreakAmt: sess.Statuses.ArmorBreakAmt, armorBreakAmt: sess.Statuses.ArmorBreakAmt,
enemySkipFirst: sess.Statuses.EnemySkipNext, 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, // Slice-3 stateful monster-ability effects — armed by applyAbility,
// round-tripped here so they survive a suspend/resume or reaper auto-play. // round-tripped here so they survive a suspend/resume or reaper auto-play.
enemyEvadeNext: sess.Statuses.EnemyEvadeNext, enemyEvadeNext: sess.Statuses.EnemyEvadeNext,
@@ -140,9 +350,6 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
enemyRetaliateFrac: sess.Statuses.EnemyRetaliateFrac, enemyRetaliateFrac: sess.Statuses.EnemyRetaliateFrac,
enemyRegen: sess.Statuses.EnemyRegen, enemyRegen: sess.Statuses.EnemyRegen,
enemySurviveArmed: sess.Statuses.EnemySurviveArmed, 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. // Slice-4 monster-ability effects — the former flavor-only placeholders.
enemySpellResist: sess.Statuses.EnemySpellResist, enemySpellResist: sess.Statuses.EnemySpellResist,
enemyRevealNext: sess.Statuses.EnemyRevealNext, enemyRevealNext: sess.Statuses.EnemyRevealNext,
@@ -150,19 +357,48 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
enemyAtkBuff: sess.Statuses.EnemyAtkBuff, enemyAtkBuff: sess.Statuses.EnemyAtkBuff,
rng: rng, rng: rng,
} }
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{ return &turnEngine{
sess: sess, sess: sess,
player: player, players: players,
enemy: enemy, enemy: enemy,
order: order,
st: st, st: st,
result: &CombatResult{}, 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 // step resolves exactly one phase of the fight and advances sess.Phase. The
// events generated this step are returned (also accumulated by commit into // events generated this step are returned (also accumulated by commit into
// sess.TurnLog). It does not persist — call commit then saveCombatSession, or // sess.TurnLog). It does not persist — call commit then saveCombatSession, or
// use advanceCombatSession which does both. // 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) { func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) {
if !te.sess.IsActive() { if !te.sess.IsActive() {
return nil, errCombatSessionOver return nil, errCombatSessionOver
@@ -170,9 +406,18 @@ func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) {
te.st.events = nil te.st.events = nil
switch te.sess.Phase { switch te.sess.Phase {
case CombatPhasePlayerTurn: 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.stepPlayerTurn(action)
te.stampSeat(0, acting)
case CombatPhaseEnemyTurn: case CombatPhaseEnemyTurn:
te.stepEnemyTurn() 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: case CombatPhaseRoundEnd:
te.stepRoundEnd() te.stepRoundEnd()
case CombatPhaseOver: case CombatPhaseOver:
@@ -184,6 +429,13 @@ func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) {
} }
func (te *turnEngine) stepPlayerTurn(action PlayerAction) { 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 { switch action.Kind {
case ActionFlee: case ActionFlee:
te.st.events = append(te.st.events, CombatEvent{ te.st.events = append(te.st.events, CombatEvent{
@@ -204,17 +456,24 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
// Extra Attack only fired in the auto-resolve SimulateCombat path; that // Extra Attack only fired in the auto-resolve SimulateCombat path; that
// left every elite/boss !fight at L11+ short two swings/turn, which the // left every elite/boss !fight at L11+ short two swings/turn, which the
// J1 boss-trace surfaced as Fighter's T3+ wall. // J1 boss-trace surfaced as Fighter's T3+ wall.
// Returns true once the fight is decided — usually the enemy is down, // Returns true once the swing decided something — usually the enemy is
// but a retaliate aura can drop the player on their own swing, so // down, but a retaliate aura can drop the swinger on their own attack. A
// disambiguate the outcome by HP. // downed swinger only ends the fight if nobody else is standing.
if resolvePlayerSwings(te.st, te.player, te.enemy, &turnCombatPhase, te.result) { // The outcome is read off HP rather than the bool: resolvePlayerSwings also
if te.st.playerHP <= 0 { // returns false when a retaliate aura kills the swinger between extra
te.finish(CombatStatusLost) // attacks, and the old code walked that corpse into the enemy's turn.
} else { resolvePlayerSwings(te.st, te.st.c, te.enemy, &turnCombatPhase, te.result)
if te.st.enemyHP <= 0 {
te.finish(CombatStatusWon) te.finish(CombatStatusWon)
}
return 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() { if te.petStrike() {
te.finish(CombatStatusWon) te.finish(CombatStatusWon)
return return
@@ -223,7 +482,8 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
te.finish(CombatStatusWon) te.finish(CombatStatusWon)
return return
} }
te.sess.Phase = CombatPhaseEnemyTurn }
te.advance()
} }
// petStrike resolves the player's pet attack for a turn-based fight. The pet // petStrike resolves the player's pet attack for a turn-based fight. The pet
@@ -236,10 +496,10 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
// the enemy. // the enemy.
func (te *turnEngine) petStrike() bool { func (te *turnEngine) petStrike() bool {
st := te.st st := te.st
if te.player.Mods.PetAttackProc <= 0 || st.randFloat() >= te.player.Mods.PetAttackProc { if st.c.Mods.PetAttackProc <= 0 || st.randFloat() >= st.c.Mods.PetAttackProc {
return false 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.enemyHP = max(0, st.enemyHP-petDmg)
st.events = append(st.events, CombatEvent{ st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "pet", Action: "pet_attack", 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. // pet flavor on a petless caster. Returns true if the strike dropped the enemy.
func (te *turnEngine) spiritWeaponStrike() bool { func (te *turnEngine) spiritWeaponStrike() bool {
st := te.st 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 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.enemyHP = max(0, st.enemyHP-dmg)
st.events = append(st.events, CombatEvent{ st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "spirit_weapon", Action: "spirit_weapon_strike", 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 st := te.st
if eff == nil { if eff == nil {
// Defensive: a kind with no payload just passes the turn. // Defensive: a kind with no payload just passes the turn.
te.sess.Phase = CombatPhaseEnemyTurn te.advance()
return return
} }
action := eff.Action action := eff.Action
@@ -296,9 +556,15 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
if eff.PlayerHeal > 0 { if eff.PlayerHeal > 0 {
// Respect any max_hp_drain monster ability — a drained player can't be // Respect any max_hp_drain monster ability — a drained player can't be
// healed back past the lowered ceiling. // 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) 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{ st.events = append(st.events, CombatEvent{
Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: action, Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: action,
Damage: enemyDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: eff.Label, Damage: enemyDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: eff.Label,
@@ -332,10 +598,23 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
st.enemySkipFirst = true 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() { 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 — // 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 // unless the enemy is fear_immune, in which case the control fizzled and it
// acts as normal. // acts as normal.
@@ -351,7 +630,7 @@ func (te *turnEngine) stepEnemyTurn() {
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held", Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held",
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP, PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
}) })
te.sess.Phase = CombatPhaseRoundEnd te.advance()
return return
} }
} }
@@ -363,8 +642,15 @@ func (te *turnEngine) stepEnemyTurn() {
// player went down without a save. // player went down without a save.
abilityDealtDamage := false abilityDealtDamage := false
if te.enemy.Ability != nil && turnAbilityFires(te.enemy.Ability, te.st, te.enemy) { if te.enemy.Ability != nil && turnAbilityFires(te.enemy.Ability, te.st, te.enemy) {
if applyAbility(te.st, te.player, te.enemy, &turnCombatPhase, te.result) { 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) te.finish(CombatStatusLost)
} else {
te.advance()
}
return return
} }
switch te.enemy.Ability.Effect { switch te.enemy.Ability.Effect {
@@ -380,8 +666,8 @@ func (te *turnEngine) stepEnemyTurn() {
// remaining swings resolve normally — a single proc shouldn't nullify a // remaining swings resolve normally — a single proc shouldn't nullify a
// boss's whole multiattack round. (This deliberately diverges from the // boss's whole multiattack round. (This deliberately diverges from the
// auto-resolve engine's apply-to-all model.) // auto-resolve engine's apply-to-all model.)
petWhiff := te.player.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.player.Mods.PetWhiffProc petWhiff := te.st.c.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.st.c.Mods.PetWhiffProc
petDeflect := te.player.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.player.Mods.PetDeflectProc petDeflect := te.st.c.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.st.c.Mods.PetDeflectProc
if petDeflect { if petDeflect {
te.result.PetDeflected = true te.result.PetDeflected = true
} }
@@ -399,18 +685,23 @@ func (te *turnEngine) stepEnemyTurn() {
// Spend the proc on the first swing only; later swings see false. // Spend the proc on the first swing only; later swings see false.
swingWhiff := petWhiff && i == 0 swingWhiff := petWhiff && i == 0
swingDeflect := petDeflect && 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 { if te.st.playerHP <= 0 {
// 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) te.finish(CombatStatusLost)
return return
} }
break
}
if decided || te.st.enemyHP <= 0 { if decided || te.st.enemyHP <= 0 {
te.finish(CombatStatusWon) te.finish(CombatStatusWon)
return return
} }
} }
} }
te.sess.Phase = CombatPhaseRoundEnd te.advance()
} }
// turnAbilityFires decides whether a monster ability triggers this enemy turn. // 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. // 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() { func (te *turnEngine) stepRoundEnd() {
st := te.st 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.playerHP = max(0, st.playerHP-st.poisonDmg)
st.poisonTicks-- st.poisonTicks--
st.events = append(st.events, CombatEvent{ st.events = append(st.events, CombatEvent{
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "poison_tick", Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "poison_tick",
Damage: st.poisonDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Damage: st.poisonDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
}) })
if st.playerHP <= 0 { if st.playerHP <= 0 && !trySave(st, st.c, CombatPhaseRoundEnd) && !st.anyAlive() {
if !trySave(st, te.player, CombatPhaseRoundEnd) { te.stampSeat(mark, i)
te.finish(CombatStatusLost) te.finish(CombatStatusLost)
return 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. // Regenerate (monster ability): the enemy knits its wounds at round end.
if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP { if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP {
st.enemyHP = min(te.enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen) st.enemyHP = min(te.enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen)
@@ -463,7 +782,11 @@ func (te *turnEngine) stepRoundEnd() {
}) })
} }
st.round++ 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. // 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 // 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 // by the command layer (a !cast / !consume folds them in) and applied to the
// rebuilt combatant by applySessionBuffs — so commit mutates Statuses in place // rebuilt combatant by applySessionBuffs — so snapshotActor carries them over
// rather than replacing it, leaving those deltas untouched. // 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() { func (te *turnEngine) commit() {
st := te.st st := te.st
te.sess.Round = st.round te.sess.Round = st.round
te.sess.PlayerHP = st.playerHP
te.sess.EnemyHP = st.enemyHP te.sess.EnemyHP = st.enemyHP
seat0 := st.actors[0]
te.sess.PlayerHP = seat0.playerHP
s := &te.sess.Statuses s := &te.sess.Statuses
s.PoisonTicks = st.poisonTicks s.ActorStatuses = snapshotActor(seat0, s.ActorStatuses)
s.PoisonDmg = st.poisonDmg
s.StunPlayer = st.stunPlayer 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.Enraged = st.enraged
s.ArmorBroken = st.armorBroken s.ArmorBroken = st.armorBroken
s.ArmorBreakAmt = st.armorBreakAmt s.ArmorBreakAmt = st.armorBreakAmt
s.EnemySkipNext = st.enemySkipFirst s.EnemySkipNext = st.enemySkipFirst
s.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.EnemyEvadeNext = st.enemyEvadeNext
s.EnemyBlockUp = st.enemyBlockUp s.EnemyBlockUp = st.enemyBlockUp
s.EnemyAdvantage = st.enemyAdvantage s.EnemyAdvantage = st.enemyAdvantage
s.EnemyRetaliateFrac = st.enemyRetaliateFrac s.EnemyRetaliateFrac = st.enemyRetaliateFrac
s.EnemyRegen = st.enemyRegen s.EnemyRegen = st.enemyRegen
s.EnemySurviveArmed = st.enemySurviveArmed s.EnemySurviveArmed = st.enemySurviveArmed
s.PlayerAtkDrain = st.playerAtkDrain
s.PlayerACDebuff = st.playerACDebuff
s.MaxHPDrain = st.maxHPDrain
s.EnemySpellResist = st.enemySpellResist s.EnemySpellResist = st.enemySpellResist
s.EnemyRevealNext = st.enemyRevealNext s.EnemyRevealNext = st.enemyRevealNext
s.EnemyFearImmune = st.enemyFearImmune s.EnemyFearImmune = st.enemyFearImmune
s.EnemyAtkBuff = st.enemyAtkBuff s.EnemyAtkBuff = st.enemyAtkBuff
te.sess.TurnLog = append(te.sess.TurnLog, st.events...) te.sess.TurnLog = append(te.sess.TurnLog, st.events...)
} }
// advanceCombatSession resolves one phase of the fight and persists the result. // advanceCombatSession resolves one phase of a solo fight and persists the
// It is the single entry point callers (commands, reaper) should use: it seeds // result. It is the single entry point callers (commands, reaper) should use.
// the deterministic RNG, resumes the engine, steps, commits, and saves. The
// passed sess is mutated in place. The events generated this step are returned.
func advanceCombatSession(sess *CombatSession, player, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) { 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 { if sess == nil {
return nil, ErrNoActiveCombatSession return nil, ErrNoActiveCombatSession
} }
rng := combatSessionRNG(sess) if len(players) == 0 {
te := resumeTurnEngine(sess, player, enemy, rng) 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) events, err := te.step(action)
if err != nil { if err != nil {
return nil, err 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

@@ -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) { func TestZoneRun_FreshRunNotAutoAbandoned(t *testing.T) {
setupZoneRunTestDB(t) setupZoneRunTestDB(t)
uid := id.UserID("@fresh-run:example") uid := id.UserID("@fresh-run:example")

View File

@@ -63,8 +63,12 @@ func decodePendingCast(s string) (PendingCast, bool) {
func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) error { func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) error {
// In a turn-based Elite/Boss fight, !cast resolves as the player's turn // In a turn-based Elite/Boss fight, !cast resolves as the player's turn
// for the round rather than queuing for "next combat". handleCombatCastCmd // for the round rather than queuing for "next combat". handleCombatCastCmd
// takes the per-user lock itself and re-checks the session under it. // takes the fight's locks itself and re-checks the session under them.
if sess, _ := getActiveCombatSession(ctx.Sender); sess != nil { //
// 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) return p.handleCombatCastCmd(ctx, args)
} }

View File

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

View File

@@ -137,6 +137,7 @@ const (
) )
// ArmorProfile is the spec's struct. MaxDEXBonus convention: // ArmorProfile is the spec's struct. MaxDEXBonus convention:
//
// -1 = unlimited (light armor takes full DEX mod) // -1 = unlimited (light armor takes full DEX mod)
// 2 = medium (cap at +2) // 2 = medium (cap at +2)
// 0 = heavy (no DEX bonus) // 0 = heavy (no DEX bonus)

View File

@@ -193,21 +193,29 @@ func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies Exp
return exp, nil return exp, nil
} }
// expeditionSelectCols is the column list scanExpedition reads, in the order it
// reads them. scanExpedition's Scan is positional, so every query that feeds it
// must project exactly this — hence one constant rather than a copy per query.
// The `e.` alias means a query can JOIN expedition_party without ambiguity; a
// query with no JOIN just aliases dnd_expedition to e.
const expeditionSelectCols = `
e.expedition_id, e.user_id, e.zone_id, e.run_id, e.status,
e.start_date, e.current_day, e.current_region, e.boss_defeated,
e.supplies_json, e.camp_json, e.threat_level, e.threat_siege,
e.threat_events, e.temporal_stack, e.region_state,
e.xp_earned, e.coins_earned, e.gm_mood,
e.last_briefing_at, e.last_recap_at, e.last_ambient_kind,
e.last_activity, e.completed_at`
// getActiveExpedition returns the player's in-flight expedition, or (nil, nil). // getActiveExpedition returns the player's in-flight expedition, or (nil, nil).
// 'extracting' rows are post-extraction (resumable) — see getResumableExpedition. // 'extracting' rows are post-extraction (resumable) — see getResumableExpedition.
func getActiveExpedition(userID id.UserID) (*Expedition, error) { func getActiveExpedition(userID id.UserID) (*Expedition, error) {
row := db.Get().QueryRow(` row := db.Get().QueryRow(`
SELECT expedition_id, user_id, zone_id, run_id, status, SELECT`+expeditionSelectCols+`
start_date, current_day, current_region, boss_defeated, FROM dnd_expedition e
supplies_json, camp_json, threat_level, threat_siege, WHERE e.user_id = ?
threat_events, temporal_stack, region_state, AND e.status = 'active'
xp_earned, coins_earned, gm_mood, ORDER BY e.start_date DESC
last_briefing_at, last_recap_at, last_ambient_kind,
last_activity, completed_at
FROM dnd_expedition
WHERE user_id = ?
AND status = 'active'
ORDER BY start_date DESC
LIMIT 1`, string(userID)) LIMIT 1`, string(userID))
e, err := scanExpedition(row) e, err := scanExpedition(row)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -219,14 +227,8 @@ func getActiveExpedition(userID id.UserID) (*Expedition, error) {
// getExpedition fetches by ID regardless of status. Test/admin use. // getExpedition fetches by ID regardless of status. Test/admin use.
func getExpedition(id string) (*Expedition, error) { func getExpedition(id string) (*Expedition, error) {
row := db.Get().QueryRow(` row := db.Get().QueryRow(`
SELECT expedition_id, user_id, zone_id, run_id, status, SELECT`+expeditionSelectCols+`
start_date, current_day, current_region, boss_defeated, FROM dnd_expedition e WHERE e.expedition_id = ?`, id)
supplies_json, camp_json, threat_level, threat_siege,
threat_events, temporal_stack, region_state,
xp_earned, coins_earned, gm_mood,
last_briefing_at, last_recap_at, last_ambient_kind,
last_activity, completed_at
FROM dnd_expedition WHERE expedition_id = ?`, id)
e, err := scanExpedition(row) e, err := scanExpedition(row)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil return nil, nil
@@ -356,24 +358,54 @@ func abandonExpedition(userID id.UserID) error {
if e == nil { if e == nil {
return ErrNoActiveExpedition return ErrNoActiveExpedition
} }
_, err = db.Get().Exec(` if _, err = db.Get().Exec(`
UPDATE dnd_expedition UPDATE dnd_expedition
SET status = 'abandoned', SET status = 'abandoned',
completed_at = CURRENT_TIMESTAMP, completed_at = CURRENT_TIMESTAMP,
last_activity = CURRENT_TIMESTAMP last_activity = CURRENT_TIMESTAMP
WHERE expedition_id = ?`, e.ID) WHERE expedition_id = ?`, e.ID); err != nil {
return err return err
}
releaseParty(e.ID)
return nil
} }
// completeExpedition marks the expedition complete (boss defeated or extracted). // completeExpedition marks the expedition complete (boss defeated or extracted).
func completeExpedition(expID string, status string) error { func completeExpedition(expID string, status string) error {
_, err := db.Get().Exec(` if _, err := db.Get().Exec(`
UPDATE dnd_expedition UPDATE dnd_expedition
SET status = ?, SET status = ?,
completed_at = CURRENT_TIMESTAMP, completed_at = CURRENT_TIMESTAMP,
last_activity = CURRENT_TIMESTAMP last_activity = CURRENT_TIMESTAMP
WHERE expedition_id = ?`, status, expID) WHERE expedition_id = ?`, status, expID); err != nil {
return err return err
}
releaseParty(expID)
return nil
}
// releaseParty clears the roster of an expedition that has reached a terminal
// status, freeing every member to start a run of their own. A member is barred
// from adventuring anywhere else while seated (assertNotAdventuring), so a
// roster that outlives its expedition strands the whole party.
//
// It is deliberately *not* called on the 'extracting' status: that is a
// seven-day resumable limbo, and `!resume` must bring the party back with it.
// The roster is cleared when the resume window lapses and the row flips to
// 'failed' — which routes through completeExpedition like everything else.
//
// A failure here is logged, not returned: the expedition is already terminal by
// the time we get here, and refusing to finish it over a stale roster row would
// leave the leader stuck instead of the members.
func releaseParty(expID string) {
if err := disbandParty(expID); err != nil {
slog.Warn("expedition: disband party", "expedition", expID, "err", err)
}
// An unanswered invite to a finished expedition would otherwise sit there
// until its TTL, letting someone accept their way onto a corpse.
if err := clearExpeditionInvites(expID); err != nil {
slog.Warn("expedition: clear invites", "expedition", expID, "err", err)
}
} }
// applyThreatDelta clamps the threat level to [0,100], records the event, // applyThreatDelta clamps the threat level to [0,100], records the event,
@@ -505,4 +537,3 @@ func nullableString(s string) any {
} }
return s return s
} }

View File

@@ -59,7 +59,7 @@ func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error {
"No Adv 2.0 character yet — run `!setup` first.") "No Adv 2.0 character yet — run `!setup` first.")
} }
exp, err := getActiveExpedition(ctx.Sender) exp, isLeader, err := activeExpeditionFor(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
} }
@@ -73,6 +73,12 @@ func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error {
if requested == "" { if requested == "" {
return p.SendDM(ctx.Sender, campHelpText(exp)) return p.SendDM(ctx.Sender, campHelpText(exp))
} }
// Bare `!camp` reads the party's tent; pitching and striking it are the
// leader's, since the camp is a property of the expedition everyone rides.
if !isLeader {
return p.SendDM(ctx.Sender,
"Your party leader pitches the camp. `!camp` on its own shows what's already up.")
}
if requested == "break" || requested == "down" || requested == "leave" { if requested == "break" || requested == "down" || requested == "leave" {
return p.campBreak(ctx, exp) return p.campBreak(ctx, exp)
} }

View File

@@ -2,6 +2,7 @@ package plugin
import ( import (
"fmt" "fmt"
"log/slog"
"math" "math"
"strconv" "strconv"
"strings" "strings"
@@ -47,8 +48,9 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
sub, rest := splitFirstWord(args) sub, rest := splitFirstWord(args)
switch strings.ToLower(sub) { switch strings.ToLower(sub) {
case "": case "":
// If active, show status; otherwise help. // If active, show status; otherwise help. A party member is on an
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil { // expedition they do not own, so ask the leader-or-member resolver.
if exp, _, _ := activeExpeditionFor(ctx.Sender); exp != nil {
return p.expeditionCmdStatus(ctx, "") return p.expeditionCmdStatus(ctx, "")
} }
return p.SendDM(ctx.Sender, expeditionHelpText()) return p.SendDM(ctx.Sender, expeditionHelpText())
@@ -64,8 +66,16 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
// player doesn't have to remember the !zone seam). Otherwise // player doesn't have to remember the !zone seam). Otherwise
// fall back to the historical alias for `!expedition start`. // fall back to the historical alias for `!expedition start`.
if rest != "" && isAllDigits(strings.TrimSpace(rest)) { if rest != "" && isAllDigits(strings.TrimSpace(rest)) {
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil { exp, isLeader, _ := activeExpeditionFor(ctx.Sender)
switch {
case exp != nil && isLeader:
return p.zoneCmdGo(ctx, rest) return p.zoneCmdGo(ctx, rest)
case exp != nil:
// A member reaching a fork must not silently fall through to
// `!expedition start` and be told their path number is an
// unknown zone. The leader picks the way (C1); P6c enforces
// that at the !zone seam too.
return p.SendDM(ctx.Sender, "The party leader picks the path.")
} }
} }
return p.expeditionCmdStart(ctx, c, rest) return p.expeditionCmdStart(ctx, c, rest)
@@ -75,6 +85,16 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
return p.expeditionCmdLog(ctx) return p.expeditionCmdLog(ctx)
case "abandon", "quit": case "abandon", "quit":
return p.expeditionCmdAbandon(ctx) return p.expeditionCmdAbandon(ctx)
case "invite":
return p.expeditionCmdInvite(ctx, rest)
case "accept", "join":
return p.expeditionCmdAccept(ctx, c, rest)
case "decline":
return p.expeditionCmdDecline(ctx)
case "party", "roster":
return p.expeditionCmdParty(ctx)
case "leave":
return p.expeditionCmdLeave(ctx)
case "extract": case "extract":
return p.handleExtractCmd(ctx, "") return p.handleExtractCmd(ctx, "")
case "resume": case "resume":
@@ -98,6 +118,11 @@ func expeditionHelpText() string {
b.WriteString("`!expedition start <zone>` — prompts a loadout: `lean` / `balanced` / `heavy`\n") b.WriteString("`!expedition start <zone>` — prompts a loadout: `lean` / `balanced` / `heavy`\n")
b.WriteString("`!expedition run` — start (or resume) the autopilot walk (alias `!explore`)\n") b.WriteString("`!expedition run` — start (or resume) the autopilot walk (alias `!explore`)\n")
b.WriteString("`!expedition status` — day, rooms, supplies, recent events\n\n") b.WriteString("`!expedition status` — day, rooms, supplies, recent events\n\n")
b.WriteString("**Bring someone** _(Day 1, up to three of you)_:\n")
b.WriteString("`!expedition invite @user` — they buy their own supplies into the party pool\n")
b.WriteString("`!expedition accept` / `!expedition decline` — answer an invite\n")
b.WriteString("`!expedition party` — who's with you\n")
b.WriteString("`!expedition leave` — turn back for town (members only)\n\n")
b.WriteString("**Mid-expedition:**\n") b.WriteString("**Mid-expedition:**\n")
b.WriteString("`!extract` — bail safely (resumable for 7 days)\n") b.WriteString("`!extract` — bail safely (resumable for 7 days)\n")
b.WriteString("`!resume [loadout]` — resume an extracted expedition\n") b.WriteString("`!resume [loadout]` — resume an extracted expedition\n")
@@ -129,10 +154,14 @@ func (p *AdventurePlugin) expeditionCmdList(ctx MessageContext, c *DnDCharacter)
i+1, z.Display, int(z.Tier), z.LevelMin, z.LevelMax, z.ID, suffix)) i+1, z.Display, int(z.Tier), z.LevelMin, z.LevelMax, z.ID, suffix))
b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere)) b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere))
} }
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil { if exp, isLeader, _ := activeExpeditionFor(ctx.Sender); exp != nil {
zone := zoneOrFallback(exp.ZoneID) zone := zoneOrFallback(exp.ZoneID)
b.WriteString(fmt.Sprintf("\n_⚠ Active expedition: **%s**, day %d. Use `!expedition status` or `!expedition abandon`._", tail := "Use `!expedition status` or `!expedition abandon`."
zone.Display, exp.CurrentDay)) if !isLeader {
tail = "Use `!expedition status` or `!expedition leave`."
}
b.WriteString(fmt.Sprintf("\n_⚠ Active expedition: **%s**, day %d. %s_",
zone.Display, exp.CurrentDay, tail))
} }
return p.SendDM(ctx.Sender, b.String()) return p.SendDM(ctx.Sender, b.String())
} }
@@ -273,6 +302,27 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
if err := purchase.Validate(zoneForCaps.Tier); err != nil { if err := purchase.Validate(zoneForCaps.Tier); err != nil {
return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error()) return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
} }
// Reject if any expedition or zone run already active. This runs before the
// price quote: a player who cannot leave doesn't need to hear what leaving
// would have cost.
//
// The seat check spans `extracting` as well as `active` — a member of an
// extracting party is still seated for the seven-day resume window, and
// letting them outfit a rival expedition double-books them the moment their
// leader types `!resume`.
if seated, _ := seatedExpeditionFor(ctx.Sender); seated != nil {
zone, _ := getZone(seated.ZoneID)
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You're riding a party expedition in **%s** (Day %d). `!expedition leave` before starting your own.",
zone.Display, seated.CurrentDay))
}
if existing, _ := getActiveExpedition(ctx.Sender); existing != nil {
zone, _ := getZone(existing.ZoneID)
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
zone.Display, existing.CurrentDay))
}
cost := float64(purchase.Cost()) cost := float64(purchase.Cost())
if p.euro == nil { if p.euro == nil {
return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.") return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
@@ -282,14 +332,6 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.", "Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
int(cost), balance)) int(cost), balance))
} }
// Reject if any expedition or zone run already active.
if existing, _ := getActiveExpedition(ctx.Sender); existing != nil {
zone, _ := getZone(existing.ZoneID)
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
zone.Display, existing.CurrentDay))
}
if existing, _ := getActiveZoneRun(ctx.Sender); existing != nil { if existing, _ := getActiveZoneRun(ctx.Sender); existing != nil {
zone, _ := getZone(existing.ZoneID) zone, _ := getZone(existing.ZoneID)
return p.SendDM(ctx.Sender, fmt.Sprintf( return p.SendDM(ctx.Sender, fmt.Sprintf(
@@ -392,7 +434,7 @@ func (p *AdventurePlugin) expeditionCmdStatus(ctx MessageContext, args string) e
} }
func (p *AdventurePlugin) expeditionCmdStatusImpl(ctx MessageContext, debug bool) error { func (p *AdventurePlugin) expeditionCmdStatusImpl(ctx MessageContext, debug bool) error {
exp, err := getActiveExpedition(ctx.Sender) exp, _, err := activeExpeditionFor(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
} }
@@ -416,7 +458,7 @@ func (p *AdventurePlugin) expeditionCmdStatusImpl(ctx MessageContext, debug bool
b.WriteString(fmt.Sprintf("🗺 **Region:** %s (%d/%d)%s\n", b.WriteString(fmt.Sprintf("🗺 **Region:** %s (%d/%d)%s\n",
r.Name, r.Order, len(regionsForZone(exp.ZoneID)), marker)) r.Name, r.Order, len(regionsForZone(exp.ZoneID)), marker))
} }
if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil && run.TotalRooms > 0 { if run, _, rerr := activeZoneRunFor(ctx.Sender); rerr == nil && run != nil && run.TotalRooms > 0 {
b.WriteString(fmt.Sprintf("🚪 **Rooms:** %d / %d in this region\n", b.WriteString(fmt.Sprintf("🚪 **Rooms:** %d / %d in this region\n",
run.CurrentRoom+1, run.TotalRooms)) run.CurrentRoom+1, run.TotalRooms))
} }
@@ -528,7 +570,7 @@ func depletionLabel(s SupplyDepletionState) string {
// ── log ───────────────────────────────────────────────────────────────────── // ── log ─────────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) expeditionCmdLog(ctx MessageContext) error { func (p *AdventurePlugin) expeditionCmdLog(ctx MessageContext) error {
exp, err := getActiveExpedition(ctx.Sender) exp, _, err := activeExpeditionFor(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
} }
@@ -564,13 +606,18 @@ func formatLogTimestamp(t time.Time) string {
// ── abandon ───────────────────────────────────────────────────────────────── // ── abandon ─────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error { func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
exp, err := getActiveExpedition(ctx.Sender) exp, isLeader, err := activeExpeditionFor(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
} }
if exp == nil { if exp == nil {
return p.SendDM(ctx.Sender, "No active expedition to abandon.") return p.SendDM(ctx.Sender, "No active expedition to abandon.")
} }
if !isLeader {
// Abandoning throws away everyone's day. A member leaves alone.
return p.SendDM(ctx.Sender,
"Only your party leader can abandon the expedition. `!expedition leave` to walk out alone.")
}
zone, _ := getZone(exp.ZoneID) zone, _ := getZone(exp.ZoneID)
if err := abandonExpedition(ctx.Sender); err != nil { if err := abandonExpedition(ctx.Sender); err != nil {
return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error())
@@ -642,6 +689,13 @@ type autopilotWalkResult struct {
// combat already auto-resolves inside resolveCombatRoom; elite/boss // combat already auto-resolves inside resolveCombatRoom; elite/boss
// doorways stop here so the player can choose !fight on their own terms. // doorways stop here so the player can choose !fight on their own terms.
func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error { func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
// runAutopilotWalk resolves through getActiveExpedition, so a member would
// be told they are not on an expedition at all. Same refusal `!zone advance`
// gives — this is the same walk, reached by its other name.
if isPartyMember(ctx.Sender) {
return p.SendDM(ctx.Sender,
"Your party leader sets the pace. `!map` to see where you're standing.")
}
r := p.runAutopilotWalk(ctx, autopilotRoomCap, false, false) r := p.runAutopilotWalk(ctx, autopilotRoomCap, false, false)
if r.initErr != "" { if r.initErr != "" {
return p.SendDM(ctx.Sender, r.initErr) return p.SendDM(ctx.Sender, r.initErr)
@@ -667,6 +721,45 @@ func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
// run graph / harvest tally / supplies / threat — same as before, just // run graph / harvest tally / supplies / threat — same as before, just
// no streamFlow here. compact==true switches the underlying combat // no streamFlow here. compact==true switches the underlying combat
// narration into terse mode and auto-resolves elite (not boss) rooms. // narration into terse mode and auto-resolves elite (not boss) rooms.
// forkAutoPickTimeout — how long a background fork may sit unanswered
// before the autopilot picks an available route itself. Short enough that
// the expedition keeps moving rather than idling out to the 24h stale-run
// reaper; long enough that a player away for the evening still gets first
// say on a genuine fork.
const forkAutoPickTimeout = 8 * time.Hour
// autoPickStaleFork commits the first unlocked option of a stale background
// fork, advancing the run to that node exactly as `!zone go <n>` would
// (advanceZoneRunNode + region-transition hook). Returns false — no pick —
// when every option is locked, so the caller re-emits the prompt and the
// run idles on toward the reaper. The choice is logged as a narrative entry
// so the end-of-day digest can surface the decision the player missed.
func (p *AdventurePlugin) autoPickStaleFork(exp *Expedition, run *DungeonRun, pf *pendingFork) bool {
var chosen *pendingChoice
for i := range pf.Options {
if pf.Options[i].Unlocked {
chosen = &pf.Options[i]
break
}
}
if chosen == nil {
return false // nothing unlocked — leave it for the player / reaper
}
if _, err := advanceZoneRunNode(run.RunID, chosen.To); err != nil {
slog.Warn("expedition: auto-pick stale fork",
"user", run.UserID, "run", run.RunID, "err", err)
return false
}
g, _ := loadZoneGraph(run.ZoneID)
fireGraphRegionTransition(run.UserID, g.Nodes[run.CurrentNode], g.Nodes[chosen.To])
if exp != nil {
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
fmt.Sprintf("autopilot took an available path after %dh idle at the fork: %s",
int(forkAutoPickTimeout.Hours()), chosen.Label), "")
}
return true
}
func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact, inlineBossCombat bool) autopilotWalkResult { func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact, inlineBossCombat bool) autopilotWalkResult {
exp, err := getActiveExpedition(ctx.Sender) exp, err := getActiveExpedition(ctx.Sender)
if err != nil { if err != nil {
@@ -679,12 +772,19 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
return autopilotWalkResult{initErr: "No active region run. Try `!region` to refresh."} return autopilotWalkResult{initErr: "No active region run. Try `!region` to refresh."}
} }
// Already standing at a pending fork: autopilot can't pick for the // Already standing at a pending fork. The autopilot can't pick for the
// player. Re-emit the prompt with rooms=0 so the background DM // player, so a fresh fork re-emits the prompt with rooms=0 (background
// suppression keeps quiet and we don't tick the rooms counter on a // DM suppression keeps quiet; the rooms counter doesn't tick on a no-op
// no-op walk. // walk). But a background fork left unanswered past forkAutoPickTimeout
// would otherwise idle all the way to the 24h stale-run reaper and end
// the expedition — so once it's stale, auto-pick the first available
// (unlocked) route and keep walking instead of stalling out.
if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil { if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil {
if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil { if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil {
picked := compact &&
time.Since(run.LastActionAt) > forkAutoPickTimeout &&
p.autoPickStaleFork(exp, run, pf)
if !picked {
zone := zoneOrFallback(run.ZoneID) zone := zoneOrFallback(run.ZoneID)
return autopilotWalkResult{ return autopilotWalkResult{
finalMsg: renderForkPrompt(zone, *pf), finalMsg: renderForkPrompt(zone, *pf),
@@ -692,6 +792,9 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com
reason: stopFork, reason: stopFork,
} }
} }
// Auto-picked: the run now points at the chosen node. Fall
// through into the walk loop so this same tick advances it.
}
} }
var stream []string var stream []string

View File

@@ -146,12 +146,16 @@ func (p *AdventurePlugin) runHarvestInterrupt(
} }
preCombatHP, _ := dndHPSnapshot(userID) preCombatHP, _ := dndHPSnapshot(userID)
result, err := p.runZoneCombat(userID, monster, int(zone.Tier), nil, run.DMMood) // P6e: the party fights the interrupt. The surprise nick above stays on the
// leader — it is one free swing at whoever is walking point.
pres, seated, err := p.runZoneCombatRoster(
fightRoster(userID), monster, int(zone.Tier), nil, run.DMMood)
if err != nil { if err != nil {
return fmt.Sprintf("_(Interrupt combat error: %v.)_", err), false return fmt.Sprintf("_(Interrupt combat error: %v.)_", err), false
} }
result := pres.Seats[0]
postHP, maxHP := dndHPSnapshot(userID) postHP, maxHP := dndHPSnapshot(userID)
scanMoodEventsFromCombat(run.RunID, result) scanMoodEventsFromEvents(run.RunID, pres.Events)
var b strings.Builder var b strings.Builder
if kind == InterruptPatrol { if kind == InterruptPatrol {
@@ -182,23 +186,34 @@ func (p *AdventurePlugin) runHarvestInterrupt(
_ = applyThreatDelta(exp.ID, retreatThreatBump, "combat retreat") _ = applyThreatDelta(exp.ID, retreatThreatBump, "combat retreat")
b.WriteString(fmt.Sprintf("⏳ **%s** outlasts you. You break off, wounded but alive. (Threat +%d.)", b.WriteString(fmt.Sprintf("⏳ **%s** outlasts you. You break off, wounded but alive. (Threat +%d.)",
monster.Name, retreatThreatBump)) monster.Name, retreatThreatBump))
// A party can run out the clock having lost somebody. The retreat is
// still a retreat — the run goes on — but the fallen are still fallen.
if line := partyCasualtyLine(closeOutZoneLoss(pres, seated, zone, "expedition")); line != "" {
b.WriteString("\n")
b.WriteString(line)
}
return b.String(), false return b.String(), false
} }
// True death. // True death. The engine only ends a fight the players lost once nobody is
// standing, so this branch is the whole roster.
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) _, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
_ = abandonZoneRun(userID) _ = abandonZoneRun(userID)
_ = retireAllRegionRuns(exp) _ = retireAllRegionRuns(exp)
_, _, _ = forcedExtractExpedition(exp.ID, "interrupt death") _, _, _ = forcedExtractExpedition(exp.ID, "interrupt death")
markAdventureDead(userID, "expedition", zone.Display) closeOutZoneLoss(pres, seated, zone, "expedition")
if line := flavor.Pick(flavor.PlayerDeath); line != "" { if line := flavor.Pick(flavor.PlayerDeath); line != "" {
b.WriteString(line) b.WriteString(line)
b.WriteString("\n") b.WriteString("\n")
} }
if len(seated) > 1 {
b.WriteString(fmt.Sprintf("💀 The party fell to **%s**. Run ended.", monster.Name))
} else {
b.WriteString(fmt.Sprintf("💀 You fell to **%s**. Run ended.", monster.Name)) b.WriteString(fmt.Sprintf("💀 You fell to **%s**. Run ended.", monster.Name))
}
return b.String(), true return b.String(), true
} }
// Win: kill-log writer. // Win: kill-log writer. Run-scoped once; loot and death fan out per seat.
_ = recordZoneKill(exp, monster.ID) _ = recordZoneKill(exp, monster.ID)
if line := flavor.Pick(flavor.CombatVictory); line != "" { if line := flavor.Pick(flavor.CombatVictory); line != "" {
b.WriteString(line) b.WriteString(line)
@@ -206,10 +221,15 @@ func (p *AdventurePlugin) runHarvestInterrupt(
} }
b.WriteString(fmt.Sprintf("✅ **%s** down (HP %d→%d / %d).", b.WriteString(fmt.Sprintf("✅ **%s** down (HP %d→%d / %d).",
monster.Name, preCombatHP, postHP, maxHP)) monster.Name, preCombatHP, postHP, maxHP))
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" { drop, downed := p.closeOutZoneWin(pres, seated, zone, monster, false, elite, "expedition")
if drop != "" {
b.WriteString("\n") b.WriteString("\n")
b.WriteString(drop) b.WriteString(drop)
} }
if line := partyCasualtyLine(downed); line != "" {
b.WriteString("\n")
b.WriteString(line)
}
return b.String(), false return b.String(), false
} }
@@ -471,13 +491,15 @@ func (p *AdventurePlugin) tryPatrolEncounter(
if !ok { if !ok {
return return
} }
result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier), nil, run.DMMood) pres, seated, rerr := p.runZoneCombatRoster(
fightRoster(userID), monster, int(zone.Tier), nil, run.DMMood)
if rerr != nil { if rerr != nil {
err = rerr err = rerr
return return
} }
result := pres.Seats[0]
postHP, maxHP := dndHPSnapshot(userID) postHP, maxHP := dndHPSnapshot(userID)
scanMoodEventsFromCombat(run.RunID, result) scanMoodEventsFromEvents(run.RunID, pres.Events)
// Intro: patrol-encounter flavor + creature stat block. // Intro: patrol-encounter flavor + creature stat block.
var ib strings.Builder var ib strings.Builder
@@ -503,6 +525,11 @@ func (p *AdventurePlugin) tryPatrolEncounter(
_ = applyThreatDelta(exp.ID, retreatThreatBump, "patrol retreat") _ = applyThreatDelta(exp.ID, retreatThreatBump, "patrol retreat")
ob.WriteString(fmt.Sprintf("⏳ The patrol drags on. You break off, wounded but alive. (Threat +%d.)", ob.WriteString(fmt.Sprintf("⏳ The patrol drags on. You break off, wounded but alive. (Threat +%d.)",
retreatThreatBump)) retreatThreatBump))
// The run continues, but a party may have left somebody behind.
if line := partyCasualtyLine(closeOutZoneLoss(pres, seated, zone, "patrol")); line != "" {
ob.WriteString("\n")
ob.WriteString(line)
}
if rollLine := dndRollSummaryLine(result); rollLine != "" { if rollLine := dndRollSummaryLine(result); rollLine != "" {
ob.WriteString("\n") ob.WriteString("\n")
ob.WriteString(rollLine) ob.WriteString(rollLine)
@@ -515,12 +542,16 @@ func (p *AdventurePlugin) tryPatrolEncounter(
_ = abandonZoneRun(userID) _ = abandonZoneRun(userID)
_ = retireAllRegionRuns(exp) _ = retireAllRegionRuns(exp)
_, _, _ = forcedExtractExpedition(exp.ID, "patrol death") _, _, _ = forcedExtractExpedition(exp.ID, "patrol death")
markAdventureDead(userID, "patrol", zone.Display) closeOutZoneLoss(pres, seated, zone, "patrol")
if line := flavor.Pick(flavor.PlayerDeath); line != "" { if line := flavor.Pick(flavor.PlayerDeath); line != "" {
ob.WriteString(line) ob.WriteString(line)
ob.WriteString("\n") ob.WriteString("\n")
} }
if len(seated) > 1 {
ob.WriteString("💀 The patrol takes the party down. Run ended.")
} else {
ob.WriteString("💀 The patrol takes you down. Run ended.") ob.WriteString("💀 The patrol takes you down. Run ended.")
}
if rollLine := dndRollSummaryLine(result); rollLine != "" { if rollLine := dndRollSummaryLine(result); rollLine != "" {
ob.WriteString("\n") ob.WriteString("\n")
ob.WriteString(rollLine) ob.WriteString(rollLine)
@@ -536,10 +567,15 @@ func (p *AdventurePlugin) tryPatrolEncounter(
ob.WriteString("\n") ob.WriteString("\n")
ob.WriteString(rollLine) ob.WriteString(rollLine)
} }
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" { drop, downed := p.closeOutZoneWin(pres, seated, zone, monster, false, false, "patrol")
if drop != "" {
ob.WriteString("\n") ob.WriteString("\n")
ob.WriteString(drop) ob.WriteString(drop)
} }
if line := partyCasualtyLine(downed); line != "" {
ob.WriteString("\n")
ob.WriteString(line)
}
outcome = ob.String() outcome = ob.String()
return return
} }

View File

@@ -96,7 +96,9 @@ func (p *AdventurePlugin) nightRolloverBurn(e *Expedition) (float32, error) {
var newSupplies ExpeditionSupplies var newSupplies ExpeditionSupplies
var burn float32 var burn float32
if burnOverride.Multiplier > 0 { if burnOverride.Multiplier > 0 {
burn = e.Supplies.DailyBurn * burnOverride.Multiplier * float32(phase5BDailyBurnRatePct) / 100 // The temporal override replaces the harsh/siege multiplier, not the
// roster's: a party still eats N × 0.8 rations inside a time-warped zone.
burn = e.Supplies.DailyBurn * burnOverride.Multiplier * float32(expeditionBurnRatePct(e.ID)) / 100
newSupplies = e.Supplies newSupplies = e.Supplies
newSupplies.Current -= burn newSupplies.Current -= burn
if newSupplies.Current < 0 { if newSupplies.Current < 0 {
@@ -105,7 +107,7 @@ func (p *AdventurePlugin) nightRolloverBurn(e *Expedition) (float32, error) {
newSupplies.ForagedToday = false newSupplies.ForagedToday = false
} else { } else {
harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e) harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e)
newSupplies, burn = applyDailyBurn(e.Supplies, harsh, e.SiegeMode) newSupplies, burn = applyExpeditionDailyBurn(e, harsh, e.SiegeMode)
} }
if err := updateSupplies(e.ID, newSupplies); err != nil { if err := updateSupplies(e.ID, newSupplies); err != nil {
return 0, err return 0, err
@@ -403,32 +405,14 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
body += "\n" + ml body += "\n" + ml
} }
if uid := id.UserID(e.UserID); uid != "" { p.fanOutExpeditionDM(e, body, p.briefingPetPrefix)
// The legacy overworld morning DM is skipped while underground, so // Emergence seam: a briefing-time forced extraction (starvation / abyss
// its 25% morning pet event fires here instead, granting the one-day // collapse) surfaces the players alive — roll pet arrival. Combat/patrol
// defense buff (reset at midnight via resetAllPetMorningDefense). // deaths never reach deliverBriefing (the row is already abandoned), so an
// Pet *arrival* is handled separately on the emergence seam below — // abandoned status here means a survived emergence; those death paths roll
// not queued here — so we only roll the morning event. // on respawn instead. Every member emerges, so every member rolls.
if pet, perr := loadPetState(uid); perr == nil {
if petEvent := petMorningEvent(pet); petEvent != "" {
if char, cerr := loadAdvCharacter(uid); cerr == nil {
char.PetMorningDefense = true
if serr := saveAdvCharacter(char); serr != nil {
slog.Warn("expedition: save pet morning defense", "user", uid, "err", serr)
}
}
body = fmt.Sprintf("🐾 *%s*\n\n%s", petEvent, body)
}
}
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: send briefing DM", "user", uid, "err", err)
}
// Emergence seam: a briefing-time forced extraction (starvation /
// abyss collapse) surfaces the player alive — roll pet arrival.
// Combat/patrol deaths never reach deliverBriefing (the row is
// already abandoned), so an abandoned status here means a survived
// emergence; those death paths roll on respawn instead.
if e.Status == ExpeditionStatusAbandoned { if e.Status == ExpeditionStatusAbandoned {
for _, uid := range expeditionAudience(e) {
p.maybeRollPetArrivalOnEmerge(uid) p.maybeRollPetArrivalOnEmerge(uid)
} }
} }
@@ -534,22 +518,9 @@ func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBrief
body += "\n" + ml body += "\n" + ml
} }
if uid := id.UserID(e.UserID); uid != "" { p.fanOutExpeditionDM(e, body, p.briefingPetPrefix)
if pet, perr := loadPetState(uid); perr == nil {
if petEvent := petMorningEvent(pet); petEvent != "" {
if char, cerr := loadAdvCharacter(uid); cerr == nil {
char.PetMorningDefense = true
if serr := saveAdvCharacter(char); serr != nil {
slog.Warn("expedition: save pet morning defense", "user", uid, "err", serr)
}
}
body = fmt.Sprintf("🐾 *%s*\n\n%s", petEvent, body)
}
}
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: send briefing DM", "user", uid, "err", err)
}
if forced && e.Status == ExpeditionStatusAbandoned { if forced && e.Status == ExpeditionStatusAbandoned {
for _, uid := range expeditionAudience(e) {
p.maybeRollPetArrivalOnEmerge(uid) p.maybeRollPetArrivalOnEmerge(uid)
} }
} }
@@ -624,11 +595,7 @@ func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
body += "\n" + renderNightCheck(*night) body += "\n" + renderNightCheck(*night)
} }
if uid := id.UserID(e.UserID); uid != "" { p.fanOutExpeditionDM(e, body, nil)
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: send recap DM", "user", uid, "err", err)
}
}
if err := appendExpeditionLog(e.ID, e.CurrentDay, "recap", if err := appendExpeditionLog(e.ID, e.CurrentDay, "recap",
fmt.Sprintf("evening recap — %d log entries today", len(dayEntries)), line); err != nil { fmt.Sprintf("evening recap — %d log entries today", len(dayEntries)), line); err != nil {
return err return err
@@ -636,6 +603,31 @@ func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
return nil return nil
} }
// briefingPetPrefix folds the reader's own 25% morning pet event onto the front
// of a briefing and grants the resulting one-day defense buff (reset at midnight
// by resetAllPetMorningDefense). The legacy overworld morning DM is skipped
// while underground, so this is where that roll lives.
//
// It is per-reader rather than per-expedition: each member of a party keeps
// their own pet, and the buff lands on their own character sheet.
func (p *AdventurePlugin) briefingPetPrefix(uid id.UserID, body string) string {
pet, err := loadPetState(uid)
if err != nil {
return body
}
petEvent := petMorningEvent(pet)
if petEvent == "" {
return body
}
if char, cerr := loadAdvCharacter(uid); cerr == nil {
char.PetMorningDefense = true
if serr := saveAdvCharacter(char); serr != nil {
slog.Warn("expedition: save pet morning defense", "user", uid, "err", serr)
}
}
return fmt.Sprintf("🐾 *%s*\n\n%s", petEvent, body)
}
// pickMorningBriefing returns a flavor line based on day-band: Day 1, 3, 7, // pickMorningBriefing returns a flavor line based on day-band: Day 1, 3, 7,
// 14, 21 each have their own pool; everything else uses the generic pool. // 14, 21 each have their own pool; everything else uses the generic pool.
func pickMorningBriefing(day int) string { func pickMorningBriefing(day int) string {

View File

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

View File

@@ -49,7 +49,7 @@ func voluntaryExtractExpedition(userID id.UserID) (*Expedition, error) {
return nil, ErrNoActiveExpedition return nil, ErrNoActiveExpedition
} }
harsh := e.ThreatLevel > 60 harsh := e.ThreatLevel > 60
newSupplies, _ := applyDailyBurn(e.Supplies, harsh, e.SiegeMode) newSupplies, _ := applyExpeditionDailyBurn(e, harsh, e.SiegeMode)
e.Supplies = newSupplies e.Supplies = newSupplies
supJSON, _ := json.Marshal(newSupplies) supJSON, _ := json.Marshal(newSupplies)
if _, err := db.Get().Exec(` if _, err := db.Get().Exec(`
@@ -90,6 +90,7 @@ func forcedExtractExpedition(expID, reason string) (*Expedition, int, error) {
} }
e.Status = ExpeditionStatusAbandoned e.Status = ExpeditionStatusAbandoned
_ = retireAllRegionRuns(e) _ = retireAllRegionRuns(e)
releaseParty(e.ID)
return e, tax, nil return e, tax, nil
} }
@@ -167,6 +168,7 @@ func (p *AdventurePlugin) finalizeExpeditionOnZoneClear(userID id.UserID, runID
} }
exp.Status = ExpeditionStatusComplete exp.Status = ExpeditionStatusComplete
_ = retireAllRegionRuns(exp) _ = retireAllRegionRuns(exp)
p.rollZoneTreasure(userID, exp.ZoneID, advTreasureWeightZoneClear)
return p.AwardCompletionMilestones(exp, false) return p.AwardCompletionMilestones(exp, false)
} }
@@ -237,13 +239,18 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error {
userMu.Lock() userMu.Lock()
defer userMu.Unlock() defer userMu.Unlock()
exp, err := getActiveExpedition(ctx.Sender) exp, isLeader, err := activeExpeditionFor(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
} }
if exp == nil { if exp == nil {
return p.SendDM(ctx.Sender, "No active expedition to extract from.") return p.SendDM(ctx.Sender, "No active expedition to extract from.")
} }
if !isLeader {
// Extraction ends the expedition for the whole roster, so it is the
// leader's call — the same reasoning that makes `!flee` leader-only.
return p.SendDM(ctx.Sender, "Only your party leader can call the extraction. Ask them to `!extract`, or `!expedition leave` to walk out alone.")
}
zone, _ := getZone(exp.ZoneID) zone, _ := getZone(exp.ZoneID)
updated, err := voluntaryExtractExpedition(ctx.Sender) updated, err := voluntaryExtractExpedition(ctx.Sender)
if err != nil { if err != nil {
@@ -261,14 +268,24 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error {
b.WriteString(line) b.WriteString(line)
b.WriteString("\n\n") b.WriteString("\n\n")
} }
b.WriteString(fmt.Sprintf("Loot, XP, and coins are kept. The dungeon stays where you left it — `!resume` within 7 days to come back. After %s the expedition expires.",
(time.Now().UTC().Add(extractResumeWindow)).Format("2006-01-02 15:04 MST"))) // The extraction ends the day for everyone standing in the dungeon, and the
if err := p.SendDM(ctx.Sender, b.String()); err != nil { // roster outlives a voluntary extract — `extracting` is a resumable limbo, so
return err // members are still seated and must hear about it. Only the leader can call
// `!resume`, so the closing line differs per reader.
expires := (time.Now().UTC().Add(extractResumeWindow)).Format("2006-01-02 15:04 MST")
p.fanOutExpeditionDM(updated, b.String(), func(uid id.UserID, body string) string {
if uid == id.UserID(updated.UserID) {
return body + fmt.Sprintf("Loot, XP, and coins are kept. The dungeon stays where you left it — `!resume` within 7 days to come back. After %s the expedition expires.", expires)
} }
return body + fmt.Sprintf("Loot, XP, and coins are kept. Your leader can `!resume` within 7 days, and you'll walk back in with them. After %s the expedition expires.", expires)
})
// Emergence seam: surfacing from a run is when an animal may have moved // Emergence seam: surfacing from a run is when an animal may have moved
// into the empty house. // into the empty house. Every member surfaced, so every member rolls.
p.maybeRollPetArrivalOnEmerge(ctx.Sender) for _, uid := range expeditionAudience(updated) {
p.maybeRollPetArrivalOnEmerge(uid)
}
return nil return nil
} }
@@ -287,8 +304,13 @@ func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error
return p.SendDM(ctx.Sender, "No Adv 2.0 character yet — run `!setup` first.") return p.SendDM(ctx.Sender, "No Adv 2.0 character yet — run `!setup` first.")
} }
if existing, _ := getActiveExpedition(ctx.Sender); existing != nil { if existing, isLeader, _ := activeExpeditionFor(ctx.Sender); existing != nil {
zone, _ := getZone(existing.ZoneID) zone, _ := getZone(existing.ZoneID)
if !isLeader {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You're riding a party expedition in **%s** (Day %d). Only its leader can `!resume`.",
zone.Display, existing.CurrentDay))
}
return p.SendDM(ctx.Sender, fmt.Sprintf( return p.SendDM(ctx.Sender, fmt.Sprintf(
"You already have an active expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.", "You already have an active expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
zone.Display, existing.CurrentDay)) zone.Display, existing.CurrentDay))

View File

@@ -492,7 +492,7 @@ func zoneTierFromID(zoneID ZoneID) int {
// handleResourcesCmd lists active nodes in the current room. // handleResourcesCmd lists active nodes in the current room.
func (p *AdventurePlugin) handleResourcesCmd(ctx MessageContext) error { func (p *AdventurePlugin) handleResourcesCmd(ctx MessageContext) error {
exp, err := getActiveExpedition(ctx.Sender) exp, isLeader, err := activeExpeditionFor(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't load expedition state.") return p.SendDM(ctx.Sender, "Couldn't load expedition state.")
} }
@@ -512,7 +512,15 @@ func (p *AdventurePlugin) handleResourcesCmd(ctx MessageContext) error {
} }
nodeID := harvestNodeIDFor(run) nodeID := harvestNodeIDFor(run)
nodes := loadHarvestNodes(exp, nodeID) nodes := loadHarvestNodes(exp, nodeID)
// Seed-persist only for the owner. saveHarvestNodes rewrites the whole
// region_state blob (persistRegionState is a last-write-wins UPDATE), and a
// member holds neither the expedition row nor the leader's lock — their
// `!resources` would clobber whatever the leader's walk wrote since the
// snapshot this command read. loadHarvestNodes re-derives the same nodes
// from the seed, so a member simply reads without persisting.
if isLeader {
_ = saveHarvestNodes(exp, nodeID, nodes) // persist seed if first touch _ = saveHarvestNodes(exp, nodeID, nodes) // persist seed if first touch
}
zone, _ := getZone(exp.ZoneID) zone, _ := getZone(exp.ZoneID)
regionLabel := exp.CurrentRegion regionLabel := exp.CurrentRegion

View File

@@ -96,7 +96,6 @@ func TestSaveHarvestNodes_DropsLegacyRoomKey(t *testing.T) {
} }
} }
func TestResolveHarvestOutcome_Brackets(t *testing.T) { func TestResolveHarvestOutcome_Brackets(t *testing.T) {
cases := []struct { cases := []struct {
roll, dc int roll, dc int

View File

@@ -89,7 +89,7 @@ func renderRegionLegend(e *Expedition) string {
// ── !map command ──────────────────────────────────────────────────────────── // ── !map command ────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleExpeditionMapCmd(ctx MessageContext, _ string) error { func (p *AdventurePlugin) handleExpeditionMapCmd(ctx MessageContext, _ string) error {
exp, err := getActiveExpedition(ctx.Sender) exp, _, err := activeExpeditionFor(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
} }
@@ -112,7 +112,7 @@ func (p *AdventurePlugin) handleExpeditionMapCmd(ctx MessageContext, _ string) e
// Per-run room map. The expedition's zone run is the per-region // Per-run room map. The expedition's zone run is the per-region
// dungeon instance; for single-region zones it's the only run. // dungeon instance; for single-region zones it's the only run.
run, _ := getActiveZoneRun(ctx.Sender) run, _, _ := activeZoneRunFor(ctx.Sender)
if run != nil { if run != nil {
if g, ok := loadZoneGraph(run.ZoneID); ok { if g, ok := loadZoneGraph(run.ZoneID); ok {
b.WriteString("```\n") b.WriteString("```\n")

View File

@@ -2,6 +2,7 @@ package plugin
import ( import (
"fmt" "fmt"
"log/slog"
"strings" "strings"
"gogobee/internal/flavor" "gogobee/internal/flavor"
@@ -14,12 +15,17 @@ import (
// is met (e.CurrentDay reflects the new day after rollover): // is met (e.CurrentDay reflects the new day after rollover):
// - First Night : day 2 (+50 XP) // - First Night : day 2 (+50 XP)
// - Week One : day 8 (+200 XP, Thom Krooke discount flag) // - Week One : day 8 (+200 XP, Thom Krooke discount flag)
// - Two Weeks : day 15 (+500 XP, +1 primary stat — deferred to char hookup) // - Two Weeks : day 15 (+500 XP, supply restock + consumable cache)
// //
// Completion milestones fire from the boss-kill path (combat-link hook): // Completion milestones fire from the boss-kill path (combat-link hook):
// - Patient Zero : status=complete && max_threat_seen ≤ 50 (+10% of XPEarned) // - Patient Zero : status=complete && max_threat_seen ≤ 50 (+10% of XPEarned)
// - Survivalist : status=complete && tier ≥ 3 && never forced-extracted (title flag) // - Survivalist : status=complete && tier ≥ 3 && never forced-extracted (title)
// - Long Game : status=complete && tier == 5 (legendary item — deferred) // - Long Game : status=complete && tier == 5 (guaranteed legendary item)
//
// Two Weeks pays out in supplies rather than a stat bump: combat MaxHP is
// built from gear/arena/housing in combat_stats.go with no expedition in
// scope, and threading one through would leak an expedition-only buff into
// the sim's balance corpus. A cache self-expires when the run ends.
// //
// Cartographer (search every room) is fully deferred — needs combat-link // Cartographer (search every room) is fully deferred — needs combat-link
// search-hook data not yet plumbed. // search-hook data not yet plumbed.
@@ -86,6 +92,7 @@ type milestoneAward struct {
XP int XP int
Flavor string Flavor string
Notes string // optional extra ("Thom Krooke discount flag set", etc.) Notes string // optional extra ("Thom Krooke discount flag set", etc.)
Extra string // grant narration (loot lines, cache contents)
} }
func (m milestoneAward) Render() string { func (m milestoneAward) Render() string {
@@ -100,6 +107,10 @@ func (m milestoneAward) Render() string {
b.WriteString(m.Notes) b.WriteString(m.Notes)
b.WriteString("_\n") b.WriteString("_\n")
} }
if m.Extra != "" {
b.WriteString(m.Extra)
b.WriteString("\n")
}
return b.String() return b.String()
} }
@@ -112,7 +123,7 @@ func (p *AdventurePlugin) checkDailyMilestones(e *Expedition) []string {
return nil return nil
} }
var out []string var out []string
award := func(key, title string, xp int, line, notes string) { award := func(key, title string, xp int, line, notes string, grant func() string) {
if HasMilestone(e, key) { if HasMilestone(e, key) {
return return
} }
@@ -122,33 +133,95 @@ func (p *AdventurePlugin) checkDailyMilestones(e *Expedition) []string {
if xp > 0 && p.xp != nil { if xp > 0 && p.xp != nil {
p.xp.GrantXP(id.UserID(e.UserID), xp, "expedition milestone: "+title) p.xp.GrantXP(id.UserID(e.UserID), xp, "expedition milestone: "+title)
} }
var extra string
if grant != nil {
extra = grant()
}
_ = appendExpeditionLog(e.ID, e.CurrentDay, "milestone", _ = appendExpeditionLog(e.ID, e.CurrentDay, "milestone",
"milestone awarded: "+title, line) "milestone awarded: "+title, line)
out = append(out, milestoneAward{ out = append(out, milestoneAward{
Key: key, Title: title, XP: xp, Flavor: line, Notes: notes, Key: key, Title: title, XP: xp, Flavor: line, Notes: notes, Extra: extra,
}.Render()) }.Render())
} }
// First Night — survived day 1, now waking on day 2. // First Night — survived day 1, now waking on day 2.
if e.CurrentDay >= 2 { if e.CurrentDay >= 2 {
award(MilestoneKeyFirstNight, "First Night", 50, award(MilestoneKeyFirstNight, "First Night", 50,
flavor.Pick(flavor.MilestoneFirstNight), "") flavor.Pick(flavor.MilestoneFirstNight), "", nil)
} }
// Week One — survived day 7, now waking on day 8. // Week One — survived day 7, now waking on day 8.
if e.CurrentDay >= 8 { if e.CurrentDay >= 8 {
award(MilestoneKeyWeekOne, "Week One", 200, award(MilestoneKeyWeekOne, "Week One", 200,
flavor.Pick(flavor.MilestoneWeekOne), flavor.Pick(flavor.MilestoneWeekOne),
"Thom Krooke discount flag set on next visit.") "Thom Krooke discount flag set on next visit.", nil)
} }
// Two Weeks — survived day 14, now waking on day 15. // Two Weeks — survived day 14, now waking on day 15.
if e.CurrentDay >= 15 { if e.CurrentDay >= 15 {
award(MilestoneKeyTwoWeeks, "Two Weeks", 500, award(MilestoneKeyTwoWeeks, "Two Weeks", 500,
flavor.Pick(flavor.MilestoneTwoWeeks), flavor.Pick(flavor.MilestoneTwoWeeks), "",
"Permanent +1 primary stat (deferred — applied at character hookup).") func() string { return p.grantTwoWeeksCache(e) })
} }
return out return out
} }
// twoWeeksRestockDays — days of rations the Two Weeks cache puts back in the
// pack. Never overfills past what the purchased packs can hold.
const twoWeeksRestockDays = 3
// twoWeeksCacheSize — consumables granted alongside the restock.
const twoWeeksCacheSize = 3
// grantTwoWeeksCache restocks supplies and drops a small consumable cache at
// the zone's tier. Returns the narration block for the briefing DM.
func (p *AdventurePlugin) grantTwoWeeksCache(e *Expedition) string {
var lines []string
if s := e.Supplies; s.DailyBurn > 0 {
s.Current += twoWeeksRestockDays * s.DailyBurn
if s.Max > 0 && s.Current > s.Max {
s.Current = s.Max
}
if s.Current > e.Supplies.Current {
if err := updateSupplies(e.ID, s); err != nil {
slog.Error("milestone: two weeks restock failed",
"expedition", e.ID, "err", err)
} else {
lines = append(lines, fmt.Sprintf(
"📦 Supplies restocked — %.1f of %.1f.", s.Current, s.Max))
e.Supplies = s
}
}
}
zone, _ := getZone(e.ZoneID)
userID := id.UserID(e.UserID)
counts := map[string]int{}
var order []string
for _, item := range consumableCache(int(zone.Tier), twoWeeksCacheSize) {
if err := addAdvInventoryItem(userID, item); err != nil {
slog.Error("milestone: two weeks cache grant failed",
"user", userID, "item", item.Name, "err", err)
continue
}
if counts[item.Name] == 0 {
order = append(order, item.Name)
}
counts[item.Name]++
}
if len(order) > 0 {
var parts []string
for _, name := range order {
if n := counts[name]; n > 1 {
parts = append(parts, fmt.Sprintf("%s ×%d", name, n))
} else {
parts = append(parts, name)
}
}
lines = append(lines, "🧪 "+strings.Join(parts, ", "))
}
return strings.Join(lines, "\n")
}
// AwardCompletionMilestones is called by the combat-link boss-kill path // AwardCompletionMilestones is called by the combat-link boss-kill path
// (deferred) once status flips to 'complete'. It checks Patient Zero, // (deferred) once status flips to 'complete'. It checks Patient Zero,
// Survivalist, and Long Game and grants the relevant XP. Returns the // Survivalist, and Long Game and grants the relevant XP. Returns the
@@ -163,7 +236,7 @@ func (p *AdventurePlugin) AwardCompletionMilestones(e *Expedition, forcedExtract
} }
zone, _ := getZone(e.ZoneID) zone, _ := getZone(e.ZoneID)
var out []string var out []string
award := func(key, title string, xp int, line, notes string) { award := func(key, title string, xp int, line, notes string, grant func() string) {
if HasMilestone(e, key) { if HasMilestone(e, key) {
return return
} }
@@ -173,10 +246,14 @@ func (p *AdventurePlugin) AwardCompletionMilestones(e *Expedition, forcedExtract
if xp > 0 && p.xp != nil { if xp > 0 && p.xp != nil {
p.xp.GrantXP(id.UserID(e.UserID), xp, "expedition milestone: "+title) p.xp.GrantXP(id.UserID(e.UserID), xp, "expedition milestone: "+title)
} }
var extra string
if grant != nil {
extra = grant()
}
_ = appendExpeditionLog(e.ID, e.CurrentDay, "milestone", _ = appendExpeditionLog(e.ID, e.CurrentDay, "milestone",
"milestone awarded: "+title, line) "milestone awarded: "+title, line)
out = append(out, milestoneAward{ out = append(out, milestoneAward{
Key: key, Title: title, XP: xp, Flavor: line, Notes: notes, Key: key, Title: title, XP: xp, Flavor: line, Notes: notes, Extra: extra,
}.Render()) }.Render())
} }
@@ -184,7 +261,7 @@ func (p *AdventurePlugin) AwardCompletionMilestones(e *Expedition, forcedExtract
bonus := e.XPEarned / 10 bonus := e.XPEarned / 10
award(MilestoneKeyPatientZero, "Patient Zero", bonus, award(MilestoneKeyPatientZero, "Patient Zero", bonus,
flavor.Pick(flavor.MilestonePatientZero), flavor.Pick(flavor.MilestonePatientZero),
"+10% XP bonus on the run.") "+10% XP bonus on the run.", nil)
} }
if int(zone.Tier) >= 3 && !forcedExtractedEver { if int(zone.Tier) >= 3 && !forcedExtractedEver {
line := flavor.Pick(flavor.MilestoneSurvivalist) line := flavor.Pick(flavor.MilestoneSurvivalist)
@@ -193,12 +270,54 @@ func (p *AdventurePlugin) AwardCompletionMilestones(e *Expedition, forcedExtract
fmt.Sprint(int(zone.Tier)) + " expedition without a single forced extraction." fmt.Sprint(int(zone.Tier)) + " expedition without a single forced extraction."
} }
award(MilestoneKeySurvivalist, "Survivalist", 0, line, award(MilestoneKeySurvivalist, "Survivalist", 0, line,
"Title flag recorded; cosmetic item deferred to item-grant hookup.") "Title set — shows on your sheet.",
func() string { return p.grantSurvivalistTitle(e, zone) })
} }
if int(zone.Tier) == 5 { if int(zone.Tier) == 5 {
award(MilestoneKeyLongGame, "The Long Game", 0, award(MilestoneKeyLongGame, "The Long Game", 0,
flavor.Pick(flavor.MilestoneTheLongGame), flavor.Pick(flavor.MilestoneTheLongGame), "",
"Guaranteed legendary item deferred to loot-grant hookup.") func() string { return p.grantLongGameLegendary(e) })
} }
return out return out
} }
// survivalistTitle — the title written to player_meta.title on a clean T3+ clear.
const survivalistTitle = "Survivalist"
// grantSurvivalistTitle writes the title to the character and posts the
// games-room notice. Returns "" — the milestone's own Notes line already
// tells the player what happened.
func (p *AdventurePlugin) grantSurvivalistTitle(e *Expedition, zone ZoneDefinition) string {
userID := id.UserID(e.UserID)
char, err := loadAdvCharacter(userID)
if err != nil || char == nil {
slog.Error("milestone: survivalist title load failed", "user", userID, "err", err)
return ""
}
if char.Title != survivalistTitle {
char.Title = survivalistTitle
if err := saveAdvCharacter(char); err != nil {
slog.Error("milestone: survivalist title save failed", "user", userID, "err", err)
return ""
}
}
gr := gamesRoom()
if gr == "" {
return ""
}
displayName, _ := loadDisplayName(userID)
p.SendMessage(id.RoomID(gr), fmt.Sprintf(
"🎖️ **%s** walked out of %s on their own two feet — Tier %d, no extraction. **Survivalist.**",
displayName, zone.Display, int(zone.Tier)))
return ""
}
// grantLongGameLegendary hands out the guaranteed Legendary for a T5 clear.
func (p *AdventurePlugin) grantLongGameLegendary(e *Expedition) string {
mi, ok := pickMagicItemForRarity(RarityLegendary, nil)
if !ok {
slog.Error("milestone: no legendary in registry", "expedition", e.ID)
return ""
}
return p.dropMagicItemLoot(id.UserID(e.UserID), mi, LootTierLegendary)
}

View File

@@ -1,6 +1,7 @@
package plugin package plugin
import ( import (
"strings"
"testing" "testing"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@@ -159,3 +160,163 @@ func TestAwardCompletionMilestones_NotCalledOnNonComplete(t *testing.T) {
t.Errorf("abandoned should award nothing, got %d", len(lines)) t.Errorf("abandoned should award nothing, got %d", len(lines))
} }
} }
// ── N1/A4 — milestone grants ────────────────────────────────────────────────
func TestCheckDailyMilestones_TwoWeeksGrantsSupplyCache(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-milestone-twoweeks-cache:example")
defer cleanupExpeditions(uid)
supplies := ExpeditionSupplies{Current: 5, Max: 30, DailyBurn: 2, HarshMod: 1}
exp, err := startExpedition(uid, ZoneManorBlackspire, "", supplies)
if err != nil {
t.Fatal(err)
}
exp.CurrentDay = 15
p := &AdventurePlugin{}
if lines := p.checkDailyMilestones(exp); len(lines) == 0 {
t.Fatal("expected milestones on day 15")
}
if !HasMilestone(exp, MilestoneKeyTwoWeeks) {
t.Fatal("two_weeks not recorded")
}
// 5 + 3 days × 2 SU/day = 11, well under the 30 cap.
if got := exp.Supplies.Current; got != 11 {
t.Errorf("supplies after restock = %v, want 11", got)
}
stored, err := getExpedition(exp.ID)
if err != nil {
t.Fatal(err)
}
if got := stored.Supplies.Current; got != 11 {
t.Errorf("persisted supplies = %v, want 11", got)
}
inv, err := loadAdvInventory(uid)
if err != nil {
t.Fatal(err)
}
var consumables int
for _, it := range inv {
if it.Type == "consumable" {
consumables++
}
}
if consumables != twoWeeksCacheSize {
t.Errorf("cache granted %d consumables, want %d", consumables, twoWeeksCacheSize)
}
}
func TestGrantTwoWeeksCache_RestockNeverExceedsMax(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-milestone-twoweeks-cap:example")
defer cleanupExpeditions(uid)
supplies := ExpeditionSupplies{Current: 29, Max: 30, DailyBurn: 2, HarshMod: 1}
exp, err := startExpedition(uid, ZoneManorBlackspire, "", supplies)
if err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
p.grantTwoWeeksCache(exp)
if got := exp.Supplies.Current; got != 30 {
t.Errorf("supplies = %v, want capped at 30", got)
}
}
func TestAwardCompletionMilestones_LongGameGrantsLegendary(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-milestone-longgame:example")
defer cleanupExpeditions(uid)
supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1}
exp, err := startExpedition(uid, ZoneDragonsLair, "", supplies)
if err != nil {
t.Fatal(err)
}
exp.Status = ExpeditionStatusComplete
p := &AdventurePlugin{}
p.AwardCompletionMilestones(exp, false)
if !HasMilestone(exp, MilestoneKeyLongGame) {
t.Fatal("long_game not recorded")
}
inv, err := loadAdvInventory(uid)
if err != nil {
t.Fatal(err)
}
var found bool
for _, it := range inv {
if strings.HasPrefix(it.SkillSource, "magic_item:") {
found = true
}
}
if !found {
t.Errorf("no magic item granted; inventory = %+v", inv)
}
}
func TestAwardCompletionMilestones_SurvivalistSetsTitle(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-milestone-survivalist:example")
defer cleanupExpeditions(uid)
supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1}
exp, err := startExpedition(uid, ZoneManorBlackspire, "", supplies)
if err != nil {
t.Fatal(err)
}
exp.Status = ExpeditionStatusComplete
p := &AdventurePlugin{}
p.AwardCompletionMilestones(exp, false)
if !HasMilestone(exp, MilestoneKeySurvivalist) {
t.Fatal("survivalist not recorded")
}
char, err := loadAdvCharacter(uid)
if err != nil {
t.Fatal(err)
}
if char.Title != survivalistTitle {
t.Errorf("title = %q, want %q", char.Title, survivalistTitle)
}
}
func TestAwardCompletionMilestones_SurvivalistSkippedOnForcedExtract(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-milestone-survivalist-forced:example")
defer cleanupExpeditions(uid)
supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1}
exp, err := startExpedition(uid, ZoneManorBlackspire, "", supplies)
if err != nil {
t.Fatal(err)
}
exp.Status = ExpeditionStatusComplete
p := &AdventurePlugin{}
p.AwardCompletionMilestones(exp, true)
if HasMilestone(exp, MilestoneKeySurvivalist) {
t.Error("survivalist awarded despite a forced extraction")
}
char, _ := loadAdvCharacter(uid)
if char != nil && char.Title == survivalistTitle {
t.Error("title set despite a forced extraction")
}
}
func TestConsumableCache_Tier1FallsBackToPoultice(t *testing.T) {
items := consumableCache(1, 3)
if len(items) != 3 {
t.Fatalf("got %d items, want 3", len(items))
}
for _, it := range items {
if it.Name != "Berry Poultice" {
t.Errorf("tier-1 cache yielded %q, want Berry Poultice", it.Name)
}
}
}

View File

@@ -33,7 +33,7 @@ func (p *AdventurePlugin) handleRegionCmd(ctx MessageContext, args string) error
userMu.Lock() userMu.Lock()
defer userMu.Unlock() defer userMu.Unlock()
exp, err := getActiveExpedition(ctx.Sender) exp, isLeader, err := activeExpeditionFor(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
} }
@@ -52,6 +52,11 @@ func (p *AdventurePlugin) handleRegionCmd(ctx MessageContext, args string) error
case "", "list", "ls", "status": case "", "list", "ls", "status":
return p.SendDM(ctx.Sender, renderRegionList(exp)) return p.SendDM(ctx.Sender, renderRegionList(exp))
case "travel", "advance", "go", "next": case "travel", "advance", "go", "next":
if !isLeader {
// Transit burns the shared supply pool and moves everyone.
return p.SendDM(ctx.Sender,
"Your party leader calls the march. `!region` to see where you are.")
}
return p.regionCmdTravel(ctx, exp) return p.regionCmdTravel(ctx, exp)
case "help", "?": case "help", "?":
return p.SendDM(ctx.Sender, regionHelpText()) return p.SendDM(ctx.Sender, regionHelpText())
@@ -140,7 +145,7 @@ func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition,
// Burn one day of supplies (transit day). // Burn one day of supplies (transit day).
siege := exp.SiegeMode siege := exp.SiegeMode
harsh := exp.ThreatLevel > 60 || zoneTemporalHarsh(exp) harsh := exp.ThreatLevel > 60 || zoneTemporalHarsh(exp)
newSupplies, burned := applyDailyBurn(exp.Supplies, harsh, siege) newSupplies, burned := applyExpeditionDailyBurn(exp, harsh, siege)
exp.Supplies = newSupplies exp.Supplies = newSupplies
if err := updateSupplies(exp.ID, exp.Supplies); err != nil { if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
return "", fmt.Errorf("apply transit supply burn: %w", err) return "", fmt.Errorf("apply transit supply burn: %w", err)

View File

@@ -2,6 +2,7 @@ package plugin
import ( import (
"fmt" "fmt"
"log/slog"
"math/rand/v2" "math/rand/v2"
"strings" "strings"
) )
@@ -298,6 +299,23 @@ func makeSupplies(tier ZoneTier, p SupplyPurchase) ExpeditionSupplies {
} }
} }
// addSupplyPurchase folds a joining member's packs into the party's pool
// (N3/P6b). Everyone carries their own rations in, so both Current and Max rise
// by what they bought.
//
// Raising Max alongside Current is what keeps supplyDepletion honest: it reads
// the ratio, and a member arriving with a full pack must not read as the party
// suddenly starving. On Day 1 — the only day an invite is legal — nothing has
// burned yet, so Current == Max and the fold is exact.
func addSupplyPurchase(s ExpeditionSupplies, p SupplyPurchase) ExpeditionSupplies {
total := p.Total()
s.Current += total
s.Max += total
s.PacksStandard += p.StandardPacks
s.PacksDeluxe += p.DeluxePacks
return s
}
// applyDailyBurn deducts one day's supplies from the snapshot (caller // applyDailyBurn deducts one day's supplies from the snapshot (caller
// persists). Returns the new snapshot and the SU consumed. // persists). Returns the new snapshot and the SU consumed.
// //
@@ -305,6 +323,7 @@ func makeSupplies(tier ZoneTier, p SupplyPurchase) ExpeditionSupplies {
// - siege overrides everything with a hard 2× floor (even for tier 1 // - siege overrides everything with a hard 2× floor (even for tier 1
// where HarshMod is 1×) — the dungeon is actively starving you out. // where HarshMod is 1×) — the dungeon is actively starving you out.
// - otherwise, harshActive applies HarshMod (zone-tier scaled). // - otherwise, harshActive applies HarshMod (zone-tier scaled).
//
// phase5BDailyBurnRatePct is the shipped daily-burn multiplier from // phase5BDailyBurnRatePct is the shipped daily-burn multiplier from
// Phase 3-B's sweep + Phase 5-B's post-buff re-validation. 50 means // Phase 3-B's sweep + Phase 5-B's post-buff re-validation. 50 means
// "half live burn" — needed because the Phase 5-B player power floor // "half live burn" — needed because the Phase 5-B player power floor
@@ -314,14 +333,57 @@ func makeSupplies(tier ZoneTier, p SupplyPurchase) ExpeditionSupplies {
// (re-validated under HP buff, shipped). // (re-validated under HP buff, shipped).
const phase5BDailyBurnRatePct = 50 const phase5BDailyBurnRatePct = 50
// applyDailyBurn is the solo-rate burn. Since P6b folded the roster in, the live
// callers all go through applyExpeditionDailyBurn instead; this survives as the
// fixture the supply tests pin the solo rate against.
func applyDailyBurn(s ExpeditionSupplies, harshActive, siege bool) (ExpeditionSupplies, float32) { func applyDailyBurn(s ExpeditionSupplies, harshActive, siege bool) (ExpeditionSupplies, float32) {
return applyDailyBurnP(s, harshActive, siege, phase5BDailyBurnRatePct) return applyDailyBurnP(s, harshActive, siege, phase5BDailyBurnRatePct)
} }
// partyBurnEfficiency is the per-head discount a party gets on rations: three
// people eat 2.4 days' worth per day, not 3. Sharing a fire, a pot and a watch
// rota is worth something, and it is the one place C1 asked for a party to be
// mechanically better off than three solo runs.
//
// It is an exact ratio rather than the literal 0.8 because the rate is truncated
// to an int: float32(50*3) * 0.8 evaluates a hair under 120, and int() would
// round it to 119 — a silent, permanent tax on every party of three.
const (
partyBurnEfficiencyNum = 4
partyBurnEfficiencyDen = 5
)
// applyExpeditionDailyBurn is applyDailyBurn with the roster folded in: a party
// of N burns N × 0.8 days of supplies per day, against a pool that P6b's
// addSupplyPurchase has already grown by everyone's packs.
//
// A solo expedition — every expedition that has ever run, and everything the
// balance corpus measured — resolves to phase5BDailyBurnRatePct exactly, so this
// is a no-op for it down to the float. On a roster read error it burns the solo
// rate: undercharging a party is a bug, starving them on a SQLite hiccup is a
// lost expedition.
func applyExpeditionDailyBurn(e *Expedition, harshActive, siege bool) (ExpeditionSupplies, float32) {
return applyDailyBurnP(e.Supplies, harshActive, siege, expeditionBurnRatePct(e.ID))
}
func expeditionBurnRatePct(expeditionID string) int {
n, err := partySize(expeditionID)
if err != nil {
slog.Warn("expedition: party size read failed, burning at the solo rate",
"expedition", expeditionID, "err", err)
return phase5BDailyBurnRatePct
}
if n <= 1 {
return phase5BDailyBurnRatePct
}
return phase5BDailyBurnRatePct * n * partyBurnEfficiencyNum / partyBurnEfficiencyDen
}
// applyDailyBurnP is the rate-parameterized form used by the Phase 3-B // applyDailyBurnP is the rate-parameterized form used by the Phase 3-B
// sim harness lever sweep. burnRatePct == 0 means "use live" (100%); // sim harness lever sweep. burnRatePct == 0 means "use live" (100%);
// any positive value scales the final per-day burn by that percent // any positive value scales the final per-day burn by that percent
// (e.g. 50 = half burn). Live callers always go through applyDailyBurn. // (e.g. 50 = half burn). Live callers reach it through applyExpeditionDailyBurn,
// which supplies the roster-scaled rate.
// See gogobee_expedition_difficulty.md Phase 3-B. // See gogobee_expedition_difficulty.md Phase 3-B.
func applyDailyBurnP(s ExpeditionSupplies, harshActive, siege bool, burnRatePct int) (ExpeditionSupplies, float32) { func applyDailyBurnP(s ExpeditionSupplies, harshActive, siege bool, burnRatePct int) (ExpeditionSupplies, float32) {
burn := s.DailyBurn burn := s.DailyBurn

View File

@@ -455,6 +455,7 @@ const AbyssInstabilityPerDay = 5
const AbyssMaxInstability = 100 const AbyssMaxInstability = 100
// AbyssInstabilityBandFor classifies the §7.6 instability tiers. // AbyssInstabilityBandFor classifies the §7.6 instability tiers.
//
// normal: 020 // normal: 020
// mild: 2140 (-1 WIS) // mild: 2140 (-1 WIS)
// warp: 4160 (rooms shift order) // warp: 4160 (rooms shift order)

View File

@@ -191,7 +191,7 @@ func appendThreatTransitionLog(e *Expedition, band ThreatBand) error {
// handleThreatCmd surfaces the current threat clock state — level, band, // handleThreatCmd surfaces the current threat clock state — level, band,
// per-band combat effects, and the last few ThreatEvent log entries. // per-band combat effects, and the last few ThreatEvent log entries.
func (p *AdventurePlugin) handleThreatCmd(ctx MessageContext) error { func (p *AdventurePlugin) handleThreatCmd(ctx MessageContext) error {
exp, err := getActiveExpedition(ctx.Sender) exp, _, err := activeExpeditionFor(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
} }

View File

@@ -102,6 +102,7 @@ func applyRacePassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacte
// BEFORE consumable application (so consumables can stack on top). // BEFORE consumable application (so consumables can stack on top).
// //
// Some passives ride on existing CombatModifiers fields: // Some passives ride on existing CombatModifiers fields:
//
// Rogue's Sneak Attack reuses AutoCritFirst (the consumable Crystal Berry // Rogue's Sneak Attack reuses AutoCritFirst (the consumable Crystal Berry
// field) — the engine already implements first-hit-auto-crit semantics. // field) — the engine already implements first-hit-auto-crit semantics.
// Cleric's Divine Favor reuses HealItem, the under-50%-HP heal trigger. // Cleric's Divine Favor reuses HealItem, the under-50%-HP heal trigger.

View File

@@ -51,14 +51,22 @@ func restingLockoutRemaining(c *DnDCharacter) time.Duration {
// cannot rest right now because they're mid-fight or mid-expedition. An // cannot rest right now because they're mid-fight or mid-expedition. An
// empty string means rest is allowed. Both !rest short and !rest long // empty string means rest is allowed. Both !rest short and !rest long
// honor this — the dungeon shouldn't be a campfire. // honor this — the dungeon shouldn't be a campfire.
//
// Both lookups resolve through the party: a seated member owns neither the
// session row nor the expedition row, so the owner-keyed reads would wave them
// through to a full heal mid boss fight.
func restBlockedReason(uid id.UserID) string { func restBlockedReason(uid id.UserID) string {
if hasActiveCombatSession(uid) { if sess, _ := activeCombatSessionFor(uid); sess != nil {
return "You're mid-fight. Finish it (or `!flee`) before resting." return "You're mid-fight. Finish it (or `!flee`) before resting."
} }
if exp, _ := getActiveExpedition(uid); exp != nil { exp, isLeader, _ := activeExpeditionFor(uid)
return "You can't rest while on an expedition. Use `!expedition extract` first." switch {
} case exp == nil:
return "" return ""
case !isLeader:
return "You can't rest while on an expedition. Ask your leader to `!extract`, or `!expedition leave` to walk out alone."
}
return "You can't rest while on an expedition. Use `!expedition extract` first."
} }
func (p *AdventurePlugin) handleDnDRestCmd(ctx MessageContext, args string) error { func (p *AdventurePlugin) handleDnDRestCmd(ctx MessageContext, args string) error {

View File

@@ -105,13 +105,13 @@ func (p *AdventurePlugin) handleDnDSetupCmd(ctx MessageContext, args string) err
case lower == "cancel": case lower == "cancel":
return p.dndSetupCancel(ctx) return p.dndSetupCancel(ctx)
} }
return p.SendDM(ctx.Sender,"Unknown !setup subcommand. Try !setup with no args for instructions.") return p.SendDM(ctx.Sender, "Unknown !setup subcommand. Try !setup with no args for instructions.")
} }
func (p *AdventurePlugin) dndSetupStatus(ctx MessageContext) error { func (p *AdventurePlugin) dndSetupStatus(ctx MessageContext) error {
c, err := LoadDnDCharacter(ctx.Sender) c, err := LoadDnDCharacter(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender,"Couldn't load your Adv 2.0 draft: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 draft: "+err.Error())
} }
// No row yet → fresh setup. Show suggestion + race menu. // No row yet → fresh setup. Show suggestion + race menu.
@@ -125,7 +125,7 @@ func (p *AdventurePlugin) dndSetupStatus(ctx MessageContext) error {
b.WriteString("**Step 1 — Race.** Pick one of:\n") b.WriteString("**Step 1 — Race.** Pick one of:\n")
b.WriteString(renderRaceMenu()) b.WriteString(renderRaceMenu())
b.WriteString("\nReply: `!setup race <name>` (e.g. `!setup race elf`)\n") b.WriteString("\nReply: `!setup race <name>` (e.g. `!setup race elf`)\n")
return p.SendDM(ctx.Sender,b.String()) return p.SendDM(ctx.Sender, b.String())
} }
// Confirmed already → reroute to !sheet, unless this is an auto-migrated // Confirmed already → reroute to !sheet, unless this is an auto-migrated
@@ -152,62 +152,62 @@ func (p *AdventurePlugin) dndSetupStatus(ctx MessageContext) error {
b.WriteString("**Next: Step 1 — Race.**\n") b.WriteString("**Next: Step 1 — Race.**\n")
b.WriteString(renderRaceMenu()) b.WriteString(renderRaceMenu())
b.WriteString("\nReply: `!setup race <name>`\n") b.WriteString("\nReply: `!setup race <name>`\n")
return p.SendDM(ctx.Sender,b.String()) return p.SendDM(ctx.Sender, b.String())
} }
b.WriteString(fmt.Sprintf("Race: **%s** ✓\n", titleRace(c.Race))) b.WriteString(fmt.Sprintf("Race: **%s** ✓\n", titleRace(c.Race)))
if c.Class == "" { if c.Class == "" {
b.WriteString("\n**Next: Step 2 — Class.**\n") b.WriteString("\n**Next: Step 2 — Class.**\n")
b.WriteString(renderClassMenu()) b.WriteString(renderClassMenu())
b.WriteString("\nReply: `!setup class <name>`\n") b.WriteString("\nReply: `!setup class <name>`\n")
return p.SendDM(ctx.Sender,b.String()) return p.SendDM(ctx.Sender, b.String())
} }
b.WriteString(fmt.Sprintf("Class: **%s** ✓\n", titleClass(c.Class))) b.WriteString(fmt.Sprintf("Class: **%s** ✓\n", titleClass(c.Class)))
if !statsAssigned(c) { if !statsAssigned(c) {
b.WriteString("\n**Next: Step 3 — Ability Scores.**\n") b.WriteString("\n**Next: Step 3 — Ability Scores.**\n")
b.WriteString("Assign these six values — **15 14 13 12 10 8** — to STR, DEX, CON, INT, WIS, CHA. Each value used exactly once.\n\n") b.WriteString("Assign these six values — **15 14 13 12 10 8** — to STR, DEX, CON, INT, WIS, CHA. Each value used exactly once.\n\n")
b.WriteString("Reply: `!setup stats 15 14 13 12 10 8` (rearrange to taste).\n") b.WriteString("Reply: `!setup stats 15 14 13 12 10 8` (rearrange to taste).\n")
return p.SendDM(ctx.Sender,b.String()) return p.SendDM(ctx.Sender, b.String())
} }
b.WriteString(fmt.Sprintf("Stats (pre-racial): STR %d DEX %d CON %d INT %d WIS %d CHA %d ✓\n", b.WriteString(fmt.Sprintf("Stats (pre-racial): STR %d DEX %d CON %d INT %d WIS %d CHA %d ✓\n",
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA)) c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA))
b.WriteString("\n**Step 4 — Confirm.** Reply `!setup confirm` to finalize, or `!setup cancel` to scrap and start over.\n") b.WriteString("\n**Step 4 — Confirm.** Reply `!setup confirm` to finalize, or `!setup cancel` to scrap and start over.\n")
b.WriteString(renderConfirmPreview(c)) b.WriteString(renderConfirmPreview(c))
return p.SendDM(ctx.Sender,b.String()) return p.SendDM(ctx.Sender, b.String())
} }
func (p *AdventurePlugin) dndSetupRace(ctx MessageContext, raceArg string) error { func (p *AdventurePlugin) dndSetupRace(ctx MessageContext, raceArg string) error {
r, ok := parseRace(raceArg) r, ok := parseRace(raceArg)
if !ok { if !ok {
return p.SendDM(ctx.Sender,"Unknown race. Options: human, elf, dwarf, halfling, orc, tiefling, half-elf") return p.SendDM(ctx.Sender, "Unknown race. Options: human, elf, dwarf, halfling, orc, tiefling, half-elf")
} }
c, err := loadOrInitDraft(ctx.Sender) c, err := loadOrInitDraft(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender,"Couldn't open your draft: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't open your draft: "+err.Error())
} }
c.Race = r c.Race = r
if err := SaveDnDCharacter(c); err != nil { if err := SaveDnDCharacter(c); err != nil {
return p.SendDM(ctx.Sender,"Couldn't save: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't save: "+err.Error())
} }
ri, _ := raceInfo(r) ri, _ := raceInfo(r)
return p.SendDM(ctx.Sender,fmt.Sprintf("Race set: **%s**. _%s_\n\nNext: `!setup class <name>` — see options with `!setup`.", return p.SendDM(ctx.Sender, fmt.Sprintf("Race set: **%s**. _%s_\n\nNext: `!setup class <name>` — see options with `!setup`.",
ri.Display, ri.Passive)) ri.Display, ri.Passive))
} }
func (p *AdventurePlugin) dndSetupClass(ctx MessageContext, classArg string) error { func (p *AdventurePlugin) dndSetupClass(ctx MessageContext, classArg string) error {
cl, ok := parseClass(classArg) cl, ok := parseClass(classArg)
if !ok { if !ok {
return p.SendDM(ctx.Sender,"Unknown class. Options: fighter, rogue, mage, cleric, ranger") return p.SendDM(ctx.Sender, "Unknown class. Options: fighter, rogue, mage, cleric, ranger")
} }
c, err := loadOrInitDraft(ctx.Sender) c, err := loadOrInitDraft(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender,"Couldn't open your draft: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't open your draft: "+err.Error())
} }
c.Class = cl c.Class = cl
if err := SaveDnDCharacter(c); err != nil { if err := SaveDnDCharacter(c); err != nil {
return p.SendDM(ctx.Sender,"Couldn't save: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't save: "+err.Error())
} }
ci, _ := classInfo(cl) ci, _ := classInfo(cl)
return p.SendDM(ctx.Sender,fmt.Sprintf("Class set: **%s** — leans on %s & %s.\n\n"+ return p.SendDM(ctx.Sender, fmt.Sprintf("Class set: **%s** — leans on %s & %s.\n\n"+
"Next: assign your stats. The standard array is **15 14 13 12 10 8** — six numbers, each used once.\n"+ "Next: assign your stats. The standard array is **15 14 13 12 10 8** — six numbers, each used once.\n"+
"Order: STR DEX CON INT WIS CHA.\n\n"+ "Order: STR DEX CON INT WIS CHA.\n\n"+
"Example: `!setup stats 15 14 13 12 10 8` (all rolled into STR-first; rearrange to taste).", "Example: `!setup stats 15 14 13 12 10 8` (all rolled into STR-first; rearrange to taste).",
@@ -227,29 +227,29 @@ func (p *AdventurePlugin) dndSetupStats(ctx MessageContext, statsArg string) err
} }
c, err := loadOrInitDraft(ctx.Sender) c, err := loadOrInitDraft(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender,"Couldn't open your draft: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't open your draft: "+err.Error())
} }
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = scores[0], scores[1], scores[2], scores[3], scores[4], scores[5] c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = scores[0], scores[1], scores[2], scores[3], scores[4], scores[5]
if err := SaveDnDCharacter(c); err != nil { if err := SaveDnDCharacter(c); err != nil {
return p.SendDM(ctx.Sender,"Couldn't save: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't save: "+err.Error())
} }
var b strings.Builder var b strings.Builder
b.WriteString("Stats saved (pre-racial bonuses).\n") b.WriteString("Stats saved (pre-racial bonuses).\n")
b.WriteString(renderConfirmPreview(c)) b.WriteString(renderConfirmPreview(c))
b.WriteString("\nReply `!setup confirm` to finalize.") b.WriteString("\nReply `!setup confirm` to finalize.")
return p.SendDM(ctx.Sender,b.String()) return p.SendDM(ctx.Sender, b.String())
} }
func (p *AdventurePlugin) dndSetupConfirm(ctx MessageContext) error { func (p *AdventurePlugin) dndSetupConfirm(ctx MessageContext) error {
c, err := LoadDnDCharacter(ctx.Sender) c, err := LoadDnDCharacter(ctx.Sender)
if err != nil || c == nil { if err != nil || c == nil {
return p.SendDM(ctx.Sender,"No setup draft found. Run `!setup` to start.") return p.SendDM(ctx.Sender, "No setup draft found. Run `!setup` to start.")
} }
if !c.PendingSetup { if !c.PendingSetup {
return p.SendDM(ctx.Sender,"Already confirmed. Use `!sheet` to view your character.") return p.SendDM(ctx.Sender, "Already confirmed. Use `!sheet` to view your character.")
} }
if c.Race == "" || c.Class == "" || !statsAssigned(c) { if c.Race == "" || c.Class == "" || !statsAssigned(c) {
return p.SendDM(ctx.Sender,"Draft incomplete. Run `!setup` to see what's missing.") return p.SendDM(ctx.Sender, "Draft incomplete. Run `!setup` to see what's missing.")
} }
// Apply racial modifiers to the assigned scores. // Apply racial modifiers to the assigned scores.
@@ -283,28 +283,28 @@ func (p *AdventurePlugin) dndSetupConfirm(ctx MessageContext) error {
c.UpdatedAt = time.Now().UTC() c.UpdatedAt = time.Now().UTC()
if err := SaveDnDCharacter(c); err != nil { if err := SaveDnDCharacter(c); err != nil {
return p.SendDM(ctx.Sender,"Couldn't save: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't save: "+err.Error())
} }
_ = initResources(ctx.Sender, c.Class) _ = initResources(ctx.Sender, c.Class)
// Phase 9: caster classes get a default known-spell list and slot pool. // Phase 9: caster classes get a default known-spell list and slot pool.
// Idempotent — !setup confirm after a respec wipe will repopulate. // Idempotent — !setup confirm after a respec wipe will repopulate.
_ = ensureSpellsForCharacter(c) _ = ensureSpellsForCharacter(c)
return p.SendDM(ctx.Sender,renderSetupComplete(c)) return p.SendDM(ctx.Sender, renderSetupComplete(c))
} }
func (p *AdventurePlugin) dndSetupCancel(ctx MessageContext) error { func (p *AdventurePlugin) dndSetupCancel(ctx MessageContext) error {
c, err := LoadDnDCharacter(ctx.Sender) c, err := LoadDnDCharacter(ctx.Sender)
if err != nil || c == nil { if err != nil || c == nil {
return p.SendDM(ctx.Sender,"No draft to cancel.") return p.SendDM(ctx.Sender, "No draft to cancel.")
} }
if !c.PendingSetup { if !c.PendingSetup {
return p.SendDM(ctx.Sender,"Your character is already finalized — use `!respec` instead.") return p.SendDM(ctx.Sender, "Your character is already finalized — use `!respec` instead.")
} }
if _, err := dbExecCancel(ctx.Sender); err != nil { if _, err := dbExecCancel(ctx.Sender); err != nil {
return p.SendDM(ctx.Sender,"Couldn't cancel: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't cancel: "+err.Error())
} }
return p.SendDM(ctx.Sender,"Draft scrapped. Run `!setup` to start over.") return p.SendDM(ctx.Sender, "Draft scrapped. Run `!setup` to start over.")
} }
// ── !respec ────────────────────────────────────────────────────────────────── // ── !respec ──────────────────────────────────────────────────────────────────

View File

@@ -161,9 +161,14 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta,
status = " — **(inactive)** _unbonded_" status = " — **(inactive)** _unbonded_"
} }
} }
b.WriteString(fmt.Sprintf(" %s %-9s %s _(%s)_%s\n %s\n", mi := e.Effective()
rarityIcon(e.Item.Rarity), string(ds), e.Item.Name, e.Item.Rarity, temperMark := ""
status, magicItemEffectSummary(e.Item))) if e.Temper > 0 {
temperMark = " 🔨"
}
b.WriteString(fmt.Sprintf(" %s %-9s %s%s _(%s)_%s\n %s\n",
rarityIcon(mi.Rarity), string(ds), mi.Name, temperMark, mi.Rarity,
status, magicItemEffectSummary(mi)))
} }
} }

View File

@@ -1,6 +1,7 @@
package plugin package plugin
import ( import (
"math/rand/v2"
"testing" "testing"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@@ -653,9 +654,12 @@ func TestSimulateCombat_FirstAttackBonusImprovesEarlyHits(t *testing.T) {
// Probabilistic: a +4 on the opening swing should noticeably lift the // Probabilistic: a +4 on the opening swing should noticeably lift the
// player's win rate against a stiff enemy. The lift is small (a single // player's win rate against a stiff enemy. The lift is small (a single
// hit's worth), so the trial count needs to be high enough that // hit's worth), so the trial count needs to be high enough that
// variance doesn't swamp the signal — at 1500 trials we were seeing // variance doesn't swamp the signal. At 6000 trials it still did: sweeping
// ~14 wins of difference on bad seeds vs. the 25-win threshold. // 40 seeds, the mean margin was +127 wins but the worst was -42, against a
const trials = 6000 // +50 threshold — so this test failed for roughly one seed in ten and had
// been doing so on a clean tree. At 24000 every one of those 40 seeds
// clears by at least +267.
const trials = 24000
hardEnemy := Combatant{ hardEnemy := Combatant{
Name: "Wall", Name: "Wall",
Stats: CombatStats{ Stats: CombatStats{
@@ -664,12 +668,17 @@ func TestSimulateCombat_FirstAttackBonusImprovesEarlyHits(t *testing.T) {
}, },
Mods: CombatModifiers{DamageReduct: 1.0}, Mods: CombatModifiers{DamageReduct: 1.0},
} }
// Seeded, not global: SimulateCombat(nil rng) draws from the package-global
// stream, so this test's verdict used to depend on how much randomness every
// test declared before it happened to consume. It failed intermittently on a
// clean tree and re-flaked whenever an unrelated change shifted the stream.
rng := statCompareRNG()
plainWins, bmWins := 0, 0 plainWins, bmWins := 0, 0
for i := 0; i < trials; i++ { for i := 0; i < trials; i++ {
if SimulateCombat(plainBMPlayer(), hardEnemy, defaultCombatPhases).PlayerWon { if simulateCombatWithRNG(plainBMPlayer(), hardEnemy, defaultCombatPhases, rng).PlayerWon {
plainWins++ plainWins++
} }
if SimulateCombat(bmPrecisionPlayer(), hardEnemy, defaultCombatPhases).PlayerWon { if simulateCombatWithRNG(bmPrecisionPlayer(), hardEnemy, defaultCombatPhases, rng).PlayerWon {
bmWins++ bmWins++
} }
} }
@@ -678,13 +687,25 @@ func TestSimulateCombat_FirstAttackBonusImprovesEarlyHits(t *testing.T) {
} }
} }
// statCompareRNG seeds the two statistical A/B subclass tests below. They
// compare win counts between two builds over thousands of trials with a fixed
// threshold, which is only meaningful if the stream is theirs alone. The seed
// is arbitrary — any value where the true effect clears the threshold works;
// this one does, and pinning it is what makes the tests reproducible.
func statCompareRNG() *rand.Rand { return rand.New(rand.NewPCG(0x5eed, 0xc0ffee)) }
// Surface check: AssassinateBonusDmg only consumed once. // Surface check: AssassinateBonusDmg only consumed once.
func TestResolvePlayerAttack_AssassinateBonusFirstHitOnly(t *testing.T) { func TestResolvePlayerAttack_AssassinateBonusFirstHitOnly(t *testing.T) {
// Drive resolvePlayerAttack via a SimulateCombat run and confirm the // Drive resolvePlayerAttack via a SimulateCombat run and confirm the
// total enemy damage taken on hit is the *base* damage on subsequent // total enemy damage taken on hit is the *base* damage on subsequent
// hits (i.e. only the first hit got the +bonus). Statistical compare // hits (i.e. only the first hit got the +bonus). Statistical compare
// against an Assassin L5 vs. an identical player without the bonus. // against an Assassin L5 vs. an identical player without the bonus.
const trials = 800 //
// 800 trials was too few for the "any lift at all" threshold: over 40 seeds
// the mean margin was only +12.8 wins and two seeds came out negative. The
// bonus is genuinely small — one hit's worth, once per fight — so the trial
// count carries the signal. At 6000 the worst of those seeds clears by +68.
const trials = 6000
build := func(bonus int) Combatant { build := func(bonus int) Combatant {
return Combatant{ return Combatant{
Name: "Assn", IsPlayer: true, Name: "Assn", IsPlayer: true,
@@ -707,16 +728,18 @@ func TestResolvePlayerAttack_AssassinateBonusFirstHitOnly(t *testing.T) {
Mods: CombatModifiers{DamageReduct: 1.0}, Mods: CombatModifiers{DamageReduct: 1.0},
} }
} }
// Seeded for the same reason as the Precision Attack test above.
rng := statCompareRNG()
plainWins, assnWins := 0, 0 plainWins, assnWins := 0, 0
for i := 0; i < trials; i++ { for i := 0; i < trials; i++ {
if SimulateCombat(build(0), enemy(), defaultCombatPhases).PlayerWon { if simulateCombatWithRNG(build(0), enemy(), defaultCombatPhases, rng).PlayerWon {
plainWins++ plainWins++
} }
if SimulateCombat(build(8), enemy(), defaultCombatPhases).PlayerWon { if simulateCombatWithRNG(build(8), enemy(), defaultCombatPhases, rng).PlayerWon {
assnWins++ assnWins++
} }
} }
if assnWins <= plainWins { if assnWins-plainWins < 25 {
t.Errorf("Assassinate bonus damage didn't help: plain=%d assn=%d", plainWins, assnWins) t.Errorf("Assassinate bonus damage didn't help: plain=%d assn=%d", plainWins, assnWins)
} }
} }

View File

@@ -59,6 +59,8 @@ func (p *AdventurePlugin) handleDnDZoneCmd(ctx MessageContext, args string) erro
return p.zoneCmdMap(ctx) return p.zoneCmdMap(ctx)
case "advance", "next", "a": case "advance", "next", "a":
return p.zoneCmdAdvance(ctx) return p.zoneCmdAdvance(ctx)
case "revisit", "back":
return p.handleRevisitCmd(ctx, rest)
case "abandon", "leave", "quit": case "abandon", "leave", "quit":
return p.zoneCmdAbandon(ctx) return p.zoneCmdAbandon(ctx)
case "taunt": case "taunt":
@@ -81,6 +83,7 @@ func zoneHelpText() string {
b.WriteString("`!zone map` — show the room layout\n") b.WriteString("`!zone map` — show the room layout\n")
b.WriteString("`!zone advance` — resolve the current room and move on\n") b.WriteString("`!zone advance` — resolve the current room and move on\n")
b.WriteString("`!zone go <n>` — at a fork, take path #n\n") b.WriteString("`!zone go <n>` — at a fork, take path #n\n")
b.WriteString("`!revisit <n>` — walk back to a room you've already cleared\n")
b.WriteString("`!zone abandon` — end the active run (no rewards)\n") b.WriteString("`!zone abandon` — end the active run (no rewards)\n")
b.WriteString("`!zone taunt` — poke TwinBee (they'll remember)\n") b.WriteString("`!zone taunt` — poke TwinBee (they'll remember)\n")
b.WriteString("`!zone compliment` — flatter TwinBee (they'll like that)\n") b.WriteString("`!zone compliment` — flatter TwinBee (they'll like that)\n")
@@ -115,10 +118,14 @@ func (p *AdventurePlugin) zoneCmdList(ctx MessageContext, c *DnDCharacter) error
i+1, z.Display, int(z.Tier), z.LevelMin, z.LevelMax, z.ID)) i+1, z.Display, int(z.Tier), z.LevelMin, z.LevelMax, z.ID))
b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere)) b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere))
} }
if active, _ := getActiveZoneRun(ctx.Sender); active != nil { if active, isLeader, _ := activeZoneRunFor(ctx.Sender); active != nil {
zone := zoneOrFallback(active.ZoneID) zone := zoneOrFallback(active.ZoneID)
b.WriteString(fmt.Sprintf("\n_⚠ Active run: **%s**, room %d/%d. Use `!zone status` or `!zone abandon`._", tail := "Use `!zone status` or `!zone abandon`."
zone.Display, active.CurrentRoom+1, active.TotalRooms)) if !isLeader {
tail = "Use `!zone status`; your leader ends it."
}
b.WriteString(fmt.Sprintf("\n_⚠ Active run: **%s**, room %d/%d. %s_",
zone.Display, active.CurrentRoom+1, active.TotalRooms, tail))
} }
return p.SendDM(ctx.Sender, b.String()) return p.SendDM(ctx.Sender, b.String())
} }
@@ -171,9 +178,19 @@ func atoiSafe(s string) int {
} }
func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest string) error { func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest string) error {
if cs, _ := getActiveCombatSession(ctx.Sender); cs != nil { if cs, _ := activeCombatSessionFor(ctx.Sender); cs != nil {
return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.") return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.")
} }
// startZoneRun only refuses a player who owns an active run. A seated party
// member owns neither run nor expedition row, so without this they could
// open a private dungeon while riding the leader's — and activeZoneRunFor
// would then hand every party read their solo run instead of the party's.
// seatedExpeditionFor, not activeExpeditionFor: the seat outlives an
// `extracting` expedition, and so must the refusal.
if seated, _ := seatedExpeditionFor(ctx.Sender); seated != nil {
return p.SendDM(ctx.Sender,
"You're already in your party's dungeon. `!expedition leave` to strike out on your own.")
}
if rest == "" { if rest == "" {
return p.SendDM(ctx.Sender, return p.SendDM(ctx.Sender,
"`!zone enter <id|#>` — pick from `!zone list`. Example: `!zone enter goblin_warrens` or `!zone enter 1`.") "`!zone enter <id|#>` — pick from `!zone list`. Example: `!zone enter goblin_warrens` or `!zone enter 1`.")
@@ -235,14 +252,14 @@ func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest
// ── status ────────────────────────────────────────────────────────────────── // ── status ──────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) zoneCmdStatus(ctx MessageContext) error { func (p *AdventurePlugin) zoneCmdStatus(ctx MessageContext) error {
run, err := getActiveZoneRun(ctx.Sender) run, isLeader, err := activeZoneRunFor(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
} }
if run == nil { if run == nil {
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone list` then `!zone enter <id>`.") return p.SendDM(ctx.Sender, "No active zone run. Use `!zone list` then `!zone enter <id>`.")
} }
_ = applyMoodDecayIfStale(run) _ = applyMoodDecayIfStale(run, isLeader)
zone := zoneOrFallback(run.ZoneID) zone := zoneOrFallback(run.ZoneID)
var b strings.Builder var b strings.Builder
b.WriteString(fmt.Sprintf("**%s** — room %d/%d (%s)\n", b.WriteString(fmt.Sprintf("**%s** — room %d/%d (%s)\n",
@@ -294,7 +311,7 @@ func prettyRoomType(rt RoomType) string {
// ── map ───────────────────────────────────────────────────────────────────── // ── map ─────────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error { func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error {
run, err := getActiveZoneRun(ctx.Sender) run, isLeader, err := activeZoneRunFor(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
} }
@@ -311,6 +328,16 @@ func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error {
if path := renderVisitedPath(g, run); path != "" { if path := renderVisitedPath(g, run); path != "" {
b.WriteString("\n**Path:** " + path) b.WriteString("\n**Path:** " + path)
} }
// A member reads the same map but can't walk it — `!revisit` is the
// leader's call, like the fork. Offering them the numbers would only
// earn a refusal.
if targets := revisitTargets(g, run); len(targets) > 0 && isLeader {
nums := make([]string, len(targets))
for i, n := range targets {
nums[i] = fmt.Sprintf("`!revisit %d`", n)
}
b.WriteString("\n**Back to:** " + strings.Join(nums, " · "))
}
return p.SendDM(ctx.Sender, b.String()) return p.SendDM(ctx.Sender, b.String())
} }
// No registered graph (defensive — every zone has one post-G8). // No registered graph (defensive — every zone has one post-G8).
@@ -454,6 +481,16 @@ type advanceResult struct {
// aborts the run with a mood penalty and player-death flavor; boss win // aborts the run with a mood penalty and player-death flavor; boss win
// drops the zone Loot table. // drops the zone Loot table.
func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error { func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
// The party walks on the leader's row. advanceOnce would tell a member they
// have no run at all — right outcome, wrong story. Ask membership directly
// rather than `run != nil && !isLeader`: that spelling misses the window
// where the leader has an expedition but has not taken the first step (no
// run row yet), and it would re-resolve the run advanceOnce is about to
// resolve anyway, firing the §4.3 idle reap twice per keystroke.
if isPartyMember(ctx.Sender) {
return p.SendDM(ctx.Sender,
"Your party leader sets the pace. `!map` to see where you're standing.")
}
res, err := p.advanceOnce(ctx) res, err := p.advanceOnce(ctx)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, err.Error()) return p.SendDM(ctx.Sender, err.Error())
@@ -498,7 +535,9 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin
reason: stopBlocked, reason: stopBlocked,
}, nil }, nil
} }
_ = applyMoodDecayIfStale(run) // getActiveZoneRun above resolves the sender's own row, so the walker is
// always this run's owner.
_ = applyMoodDecayIfStale(run, true)
zone := zoneOrFallback(run.ZoneID) zone := zoneOrFallback(run.ZoneID)
// A pending fork means advanceTransitionGraph already cleared the // A pending fork means advanceTransitionGraph already cleared the
// current room and stopped — re-running resolveRoom would re-fire // current room and stopped — re-running resolveRoom would re-fire
@@ -933,6 +972,21 @@ func (p *AdventurePlugin) streamFlowThen(userID id.UserID, phaseMessages []strin
// non-combat rooms (entry, trap), phases is nil and outcome carries the // non-combat rooms (entry, trap), phases is nil and outcome carries the
// resolution narration. // resolution narration.
func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, compact bool) (intro string, phases []string, outcome string, ended bool, err error) { func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, compact bool) (intro string, phases []string, outcome string, ended bool, err error) {
// Revisit R2 — a cleared room stays cleared. Advancing out of a room the
// player backtracked into must not re-roll its combat or re-arm its trap.
//
// The revisit plan assumed terminal CombatSession rows already gated this.
// They only gate Elite/Boss (checked at the doorway in advanceOnceWithOpts);
// an Exploration room re-enters resolveCombatRoom unconditionally, and a
// Trap room re-arms. Without this, `!revisit` would be a loot exploit:
// walk back one room, advance, farm the same enemy forever.
//
// A no-op under forward-only navigation — advance clears the current room
// only after resolving it, so the room being resolved is never already in
// RoomsCleared.
if run.RoomIsCleared(run.CurrentRoom) {
return
}
switch run.CurrentRoomType() { switch run.CurrentRoomType() {
case RoomEntry: case RoomEntry:
return return
@@ -989,13 +1043,18 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
return return
} }
preHP, _ := dndHPSnapshot(userID) preHP, _ := dndHPSnapshot(userID)
result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier), nil, run.DMMood) // P6e: the whole party fights the room, not just whoever owns the run.
// Seats[0] is the leader — their view drives this narration, which is the
// leader's stream. The mood scan reads the whole fight's log.
pres, seated, rerr := p.runZoneCombatRoster(
fightRoster(userID), monster, int(zone.Tier), nil, run.DMMood)
if rerr != nil { if rerr != nil {
err = rerr err = rerr
return return
} }
result := pres.Seats[0]
postHP, maxHP := dndHPSnapshot(userID) postHP, maxHP := dndHPSnapshot(userID)
nat20s, nat1s := scanMoodEventsFromCombat(run.RunID, result) nat20s, nat1s := scanMoodEventsFromEvents(run.RunID, pres.Events)
// Compact mode: skip TwinBee banter, skip the multi-beat play-by-play. // Compact mode: skip TwinBee banter, skip the multi-beat play-by-play.
// Render a single outcome line. Still records kills, threat, and drops. // Render a single outcome line. Still records kills, threat, and drops.
@@ -1014,12 +1073,19 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
ob.WriteString(fmt.Sprintf(" (%d)", hpDelta)) ob.WriteString(fmt.Sprintf(" (%d)", hpDelta))
} }
ob.WriteString(".") ob.WriteString(".")
// Run-scoped once, for the owner: the kill record and the threat one kill
// costs. Character-scoped effects fan out per seat.
recordZoneKillForUser(userID, monster.ID) recordZoneKillForUser(userID, monster.ID)
applyRoomCombatThreatForUser(userID, elite || isBoss) applyRoomCombatThreatForUser(userID, elite || isBoss)
if drop := p.dropZoneLoot(userID, zone.ID, monster, isBoss); drop != "" { drop, downed := p.closeOutZoneWin(pres, seated, zone, monster, isBoss, elite, "zone")
if drop != "" {
ob.WriteString(" ") ob.WriteString(" ")
ob.WriteString(drop) ob.WriteString(drop)
} }
if line := partyCasualtyLine(downed); line != "" {
ob.WriteString("\n")
ob.WriteString(line)
}
outcome = ob.String() outcome = ob.String()
return return
} }
@@ -1084,11 +1150,12 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
// dnd_expedition_combat.go. // dnd_expedition_combat.go.
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) _, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
_ = abandonZoneRun(userID) _ = abandonZoneRun(userID)
// Timeout loss = retreat; player took wounds but isn't actually // Timeout loss = retreat; the fighters took wounds but nobody actually
// dead. Don't fire markAdventureDead — that would trigger the 6h // died. Don't fire markAdventureDead — that would trigger the 6h respawn
// respawn timer for what is mechanically "ran out the clock". // timer for what is mechanically "ran out the clock". Death is read per
// seat off HP, which for a solo walker is the same `!TimedOut` rule.
closeOutZoneLoss(pres, seated, zone, "zone")
if !result.TimedOut { if !result.TimedOut {
markAdventureDead(userID, "zone", zone.Display)
forceExtractExpeditionForRunLoss(userID, "combat death") forceExtractExpeditionForRunLoss(userID, "combat death")
} else { } else {
forceExtractExpeditionForRunLoss(userID, "combat retreat") forceExtractExpeditionForRunLoss(userID, "combat retreat")
@@ -1121,10 +1188,15 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
} }
recordZoneKillForUser(userID, monster.ID) recordZoneKillForUser(userID, monster.ID)
applyRoomCombatThreatForUser(userID, elite) applyRoomCombatThreatForUser(userID, elite)
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" { drop, downed := p.closeOutZoneWin(pres, seated, zone, monster, false, elite, "zone")
if drop != "" {
ob.WriteString("\n") ob.WriteString("\n")
ob.WriteString(drop) ob.WriteString(drop)
} }
if line := partyCasualtyLine(downed); line != "" {
ob.WriteString("\n")
ob.WriteString(line)
}
outcome = ob.String() outcome = ob.String()
return return
} }
@@ -1220,14 +1292,19 @@ func (p *AdventurePlugin) zoneMoodInteraction(
render func(runID string, roomIdx int) string, render func(runID string, roomIdx int) string,
icon string, icon string,
) error { ) error {
run, err := getActiveZoneRun(ctx.Sender) // Mood is a property of the run, not of the player who typed at TwinBee — so
// a member's taunt or compliment moves the party's gauge. That is only safe
// because applyMoodEvent lands an atomic `gm_mood + delta`; the decay write
// it sits next to is absolute, and applyMoodDecayIfStale refuses it for a
// non-owner for exactly that reason.
run, isLeader, err := activeZoneRunFor(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
} }
if run == nil { if run == nil {
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.") return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
} }
_ = applyMoodDecayIfStale(run) _ = applyMoodDecayIfStale(run, isLeader)
newMood, err := applyMoodEvent(run.RunID, ev) newMood, err := applyMoodEvent(run.RunID, ev)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't apply mood event: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't apply mood event: "+err.Error())
@@ -1257,7 +1334,7 @@ func (p *AdventurePlugin) zoneMoodInteraction(
// repeated `!zone lore` in the same room returns the same prose, but // repeated `!zone lore` in the same room returns the same prose, but
// cross-room calls vary. // cross-room calls vary.
func (p *AdventurePlugin) zoneCmdLore(ctx MessageContext) error { func (p *AdventurePlugin) zoneCmdLore(ctx MessageContext) error {
run, err := getActiveZoneRun(ctx.Sender) run, _, err := activeZoneRunFor(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
} }
@@ -1275,6 +1352,10 @@ func (p *AdventurePlugin) zoneCmdLore(ctx MessageContext) error {
// ── abandon ───────────────────────────────────────────────────────────────── // ── abandon ─────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) zoneCmdAbandon(ctx MessageContext) error { func (p *AdventurePlugin) zoneCmdAbandon(ctx MessageContext) error {
if isPartyMember(ctx.Sender) {
return p.SendDM(ctx.Sender,
"Only your party leader can call off the run. `!expedition leave` to walk out alone.")
}
run, _ := getActiveZoneRun(ctx.Sender) run, _ := getActiveZoneRun(ctx.Sender)
if err := abandonZoneRun(ctx.Sender); err != nil { if err := abandonZoneRun(ctx.Sender); err != nil {
if err == ErrNoActiveRun { if err == ErrNoActiveRun {

View File

@@ -62,7 +62,7 @@ func (p *AdventurePlugin) advanceTransitionGraph(run *DungeonRun, zone ZoneDefin
if len(choices) == 1 && len(unlocked) == 1 { if len(choices) == 1 && len(unlocked) == 1 {
// Plain auto-advance — no fork to prompt for. // Plain auto-advance — no fork to prompt for.
chosen := choices[0] chosen := choices[0]
if aerr := advanceZoneRunNode(run.RunID, chosen.To); aerr != nil { if _, aerr := advanceZoneRunNode(run.RunID, chosen.To); aerr != nil {
err = aerr err = aerr
return return
} }
@@ -154,14 +154,18 @@ func nodeKindToRoomType(k ZoneNodeKind) RoomType {
// arrival teaser. The player still has to !zone advance to resolve // arrival teaser. The player still has to !zone advance to resolve
// the new room — same cadence as auto-advance. // the new room — same cadence as auto-advance.
func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error { func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
run, err := getActiveZoneRun(ctx.Sender) run, isLeader, err := activeZoneRunFor(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
} }
if run == nil { if run == nil {
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.") return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
} }
if cs, _ := getActiveCombatSession(ctx.Sender); cs != nil { if !isLeader {
// The fork is one choice for one party, and the run is the leader's row.
return p.SendDM(ctx.Sender, msgLeaderPicksPath)
}
if cs, _ := activeCombatSessionFor(ctx.Sender); cs != nil {
return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.") return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.")
} }
pf, derr := decodePendingFork(run.NodeChoices) pf, derr := decodePendingFork(run.NodeChoices)
@@ -184,7 +188,8 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
if cerr != nil { if cerr != nil {
return p.SendDM(ctx.Sender, cerr.Error()) return p.SendDM(ctx.Sender, cerr.Error())
} }
if aerr := advanceZoneRunNode(run.RunID, chosen.To); aerr != nil { nextIdx, aerr := advanceZoneRunNode(run.RunID, chosen.To)
if aerr != nil {
return p.SendDM(ctx.Sender, "Couldn't advance: "+aerr.Error()) return p.SendDM(ctx.Sender, "Couldn't advance: "+aerr.Error())
} }
markActedToday(ctx.Sender) markActedToday(ctx.Sender)
@@ -194,7 +199,6 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
nextNode := g.Nodes[chosen.To] nextNode := g.Nodes[chosen.To]
fireGraphRegionTransition(run.UserID, fromNode, nextNode) fireGraphRegionTransition(run.UserID, fromNode, nextNode)
nextRoom := nodeKindToRoomType(nextNode.Kind) nextRoom := nodeKindToRoomType(nextNode.Kind)
nextIdx := run.CurrentRoom + 1
var b strings.Builder var b strings.Builder
if kind := autoBreakCampOnMove(ctx.Sender); kind != "" { if kind := autoBreakCampOnMove(ctx.Sender); kind != "" {
b.WriteString(fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind)) b.WriteString(fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind))

View File

@@ -6,7 +6,6 @@ import (
"log/slog" "log/slog"
"math/rand/v2" "math/rand/v2"
"strings" "strings"
"time"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
) )
@@ -117,6 +116,11 @@ func zoneSelectorHash(runID string, roomIdx int) uint64 {
// //
// Returns the engine result so the caller can branch on PlayerWon and // Returns the engine result so the caller can branch on PlayerWon and
// fold combat events into the per-room narration. // fold combat events into the per-room narration.
// runZoneCombat resolves a zone encounter for exactly one player. It is the
// one-seat case of runZoneCombatRoster, and the entry point for every fight
// that is not on an expedition — the arena's encounter bouts included, which is
// why the roster is not resolved here: an arena fighter must never drag their
// party into the ring.
func (p *AdventurePlugin) runZoneCombat( func (p *AdventurePlugin) runZoneCombat(
userID id.UserID, userID id.UserID,
monster DnDMonsterTemplate, monster DnDMonsterTemplate,
@@ -124,61 +128,11 @@ func (p *AdventurePlugin) runZoneCombat(
phases []CombatPhase, phases []CombatPhase,
dmMood int, dmMood int,
) (CombatResult, error) { ) (CombatResult, error) {
if phases == nil { res, _, err := p.runZoneCombatRoster([]id.UserID{userID}, monster, tier, phases, dmMood)
phases = dungeonCombatPhases
}
char, err := loadAdvCharacter(userID)
if err != nil || char == nil {
return CombatResult{}, fmt.Errorf("load adv character: %w", err)
}
equip, err := loadAdvEquipment(userID)
if err != nil {
return CombatResult{}, fmt.Errorf("load equipment: %w", err)
}
// Player/enemy Combatant pair — shared with the turn-based engine. The
// builder folds in the D&D layer, tier scaling, and the DM-mood tilt.
player, enemy, dndChar, err := p.buildZoneCombatants(userID, monster, tier, dmMood)
if err != nil { if err != nil {
return CombatResult{}, err return CombatResult{}, err
} }
return res.Seats[0], nil
// Pre-combat one-shots that the turn-based path does NOT share: a queued
// spell and the panic-heal consumable trigger. Both mutate the Combatant
// pair once, before the fight runs.
applyPendingCast(userID, dndChar, &player.Stats, &player.Mods, &enemy.Stats)
consumables := p.loadConsumableInventory(userID)
setupAutoHealFromInventory(consumables, &player.Mods)
result := SimulateCombat(player, enemy, phases)
dumpCombatEventsIfDebug(fmt.Sprintf("zone:%s vs %s", monster.ID, player.Name), result)
// Remove the actual heal items consumed during combat (one inventory
// item per heal_item event fired). Cheapest-tier first.
consumeFiredHealingItems(userID, countHealEventsFired(result))
// Misty condition repair (post-combat, same 20% chance as arena/encounter
// paths in combat_bridge.go). Mirrors the buff's intent — gourmet food
// keeps her gear in shape on long expeditions, not just in the arena.
if char.MistyBuffExpires != nil && time.Now().UTC().Before(*char.MistyBuffExpires) {
if rand.Float64() < 0.20 {
npcRepairMostDamaged(userID, equip, 5)
}
}
p.grantCombatAchievements(userID, result)
persistDnDHPAfterCombat(userID, result.PlayerEndHP)
if err := persistDnDPostCombatSubclass(dndChar, player.Mods.BerserkerRage, result, player.Mods); err != nil {
slog.Error("dnd: post-combat subclass persist (zone)", "user", userID, "err", err)
}
if xp := zoneCombatXP(result, monster.CR, tier); xp > 0 {
if _, err := p.grantDnDXP(userID, xp); err != nil {
slog.Error("dnd: grantDnDXP zone", "user", userID, "err", err)
}
}
return result, nil
} }
// zoneCombatXP — CR-weighted XP per kill, with a tier-based floor so // zoneCombatXP — CR-weighted XP per kill, with a tier-based floor so

View File

@@ -382,31 +382,166 @@ func rngIntN(rng *rand.Rand, n int) int {
// the biome slate item. Keeps magic items a treat, not the norm. // the biome slate item. Keeps magic items a treat, not the norm.
const magicItemDropChance = 0.15 const magicItemDropChance = 0.15
func (p *AdventurePlugin) dropZoneLoot(userID id.UserID, zoneID ZoneID, monster DnDMonsterTemplate, isBoss bool) string { // Treasure-roll weights by the kind of fight that earned the roll. The base
// rates (advTreasureDropRates, 1.5%→0.15%) are tuned for one roll per day of
// legacy activity; expedition combat rolls far more often, so standard kills
// stay at ×1 and the weight goes to the moments a player remembers.
const (
advTreasureWeightStandard = 1.0
advTreasureWeightElite = 2.0
advTreasureWeightBoss = 4.0
advTreasureWeightZoneClear = 1.0
)
// advLocForZone adapts a zone into the AdvLocation shape the treasure and
// masterwork systems were written against, back when every drop came from a
// legacy daily activity. Zones are dungeons.
func advLocForZone(zoneID ZoneID) *AdvLocation {
name := string(zoneID)
if z, ok := getZone(zoneID); ok {
name = z.Display
}
return &AdvLocation{
Name: name,
Activity: AdvActivityDungeon,
Tier: zoneTierFromID(zoneID),
}
}
// rollZoneTreasure gives the treasure table a shot at a zone moment. Silent
// on the overwhelmingly common no-drop path; checkTreasureDrop owns all
// player-facing output (discovery DM, auto-swap, T5 room announce).
func (p *AdventurePlugin) rollZoneTreasure(userID id.UserID, zoneID ZoneID, weight float64) {
char, err := loadAdvCharacter(userID)
if err != nil || char == nil {
return
}
p.checkTreasureDrop(userID, char, advLocForZone(zoneID), weight)
}
// rollZoneMasterwork gives the masterwork catalog a shot at an elite or boss
// kill. Arena gear stays the premium line (×1.5 effective tier vs ×1.25), so
// this competes with the shop, not with the arena.
func (p *AdventurePlugin) rollZoneMasterwork(userID id.UserID, zoneID ZoneID, isBoss bool) {
equip, err := loadAdvEquipment(userID)
if err != nil {
return
}
outcome := AdvOutcomeSuccess
if isBoss {
outcome = AdvOutcomeExceptional
}
p.checkMasterworkDrop(userID, equip, advLocForZone(zoneID), outcome)
}
// advIngredientDropChance — per-win chance a crafting ingredient drops.
//
// Every recipe ingredient in adventure_consumables.go lives in the legacy
// gathering tables (advDungeonLoot/advMiningLoot/advForagingLoot/
// advFishingLoot), which the Phase R transition left with no live caller:
// generateAdvLoot is only reachable from resolveDungeonAction, and nothing
// calls that. Rather than re-key 24 ingredients into the per-zone slates,
// zone combat draws from the legacy tables directly at the zone's tier —
// their values are already tuned, and every recipe becomes craftable again.
const advIngredientDropChance = 0.15
// advIngredientActivities are the four legacy tables a zone kill can draw an
// ingredient from. Zone tier indexes the table tier directly.
var advIngredientActivities = []AdvActivityType{
AdvActivityDungeon, AdvActivityMining, AdvActivityForaging, AdvActivityFishing,
}
// rollZoneIngredient draws one crafting ingredient from a random legacy
// gathering table at the zone's tier. Returns nil on the common no-drop path.
func rollZoneIngredient(zoneTier int) *AdvItem {
if rand.Float64() >= advIngredientDropChance {
return nil
}
act := advIngredientActivities[rand.IntN(len(advIngredientActivities))]
defs := advLootTable(act)[zoneTier]
if len(defs) == 0 {
return nil
}
d := defs[rand.IntN(len(defs))]
value := d.MinValue
if d.MaxValue > d.MinValue {
value += rand.Int64N(d.MaxValue - d.MinValue + 1)
}
return &AdvItem{
Name: d.Name,
Type: d.Type,
Tier: zoneTier,
Value: value,
SkillSource: "zone_ingredient:" + string(act),
}
}
// grantZoneItem deposits a rolled item and returns its narration line.
func (p *AdventurePlugin) grantZoneItem(userID id.UserID, item *AdvItem, icon string) string {
if err := addAdvInventoryItem(userID, *item); err != nil {
slog.Error("zone loot: failed to add item", "user", userID, "item", item.Name, "err", err)
return ""
}
return fmt.Sprintf("%s **%s** — %d coin baseline.", icon, item.Name, item.Value)
}
func (p *AdventurePlugin) dropZoneLoot(userID id.UserID, zoneID ZoneID, monster DnDMonsterTemplate, isBoss, isElite bool) string {
// Treasure rolls on every win, independent of whether the zone loot
// table produced an item — the two systems are separate rewards.
weight := advTreasureWeightStandard
switch {
case isBoss:
weight = advTreasureWeightBoss
case isElite:
weight = advTreasureWeightElite
}
p.rollZoneTreasure(userID, zoneID, weight)
// Masterwork is an elite/boss reward only — it's a permanent gear
// upgrade, not a per-kill trinket.
if isBoss || isElite {
p.rollZoneMasterwork(userID, zoneID, isBoss)
}
// Consumables and ingredients roll on any win, and independently of the
// zone slate below — a dry slate roll shouldn't cost the player these.
zTier := zoneTierFromID(zoneID)
var extra []string
if item := RollConsumableDrop(AdvActivityDungeon, zTier); item != nil {
if line := p.grantZoneItem(userID, item, "🧪"); line != "" {
extra = append(extra, line)
}
}
if item := rollZoneIngredient(zTier); item != nil {
if line := p.grantZoneItem(userID, item, "🌿"); line != "" {
extra = append(extra, line)
}
}
trailer := strings.Join(extra, "\n")
entry, tier, ok := rollZoneLoot(zoneID, monster.CR, isBoss, nil) entry, tier, ok := rollZoneLoot(zoneID, monster.CR, isBoss, nil)
if !ok { if !ok {
return "" return trailer
} }
// Magic-item substitution: swap the biome slate item for a registry // Magic-item substitution: swap the biome slate item for a registry
// magic item of the same rarity tier (see magic_items_gameplay.go). // magic item of the same rarity tier (see magic_items_gameplay.go).
if rngFloat(nil) < magicItemDropChance { if rngFloat(nil) < magicItemDropChance {
if mi, miOK := pickMagicItemForRarity(tier.rarity(), nil); miOK { if mi, miOK := pickMagicItemForRarity(tier.rarity(), nil); miOK {
return p.dropMagicItemLoot(userID, mi, tier) return joinLootLines(p.dropMagicItemLoot(userID, mi, tier), trailer)
} }
} }
tierVal := tier tierVal := tier
zoneTier := zoneTierFromID(zoneID)
item := AdvItem{ item := AdvItem{
Name: entry.Name, Name: entry.Name,
Type: entry.ItemType, Type: entry.ItemType,
Tier: zoneTier, Tier: zTier,
Value: int64(entry.BaseValue), Value: int64(entry.BaseValue),
SkillSource: fmt.Sprintf("zone_loot:%s:%s", zoneID, tierVal), SkillSource: fmt.Sprintf("zone_loot:%s:%s", zoneID, tierVal),
} }
if err := addAdvInventoryItem(userID, item); err != nil { if err := addAdvInventoryItem(userID, item); err != nil {
return fmt.Sprintf("_(Loot drop persistence error: %v.)_", err) return joinLootLines(fmt.Sprintf("_(Loot drop persistence error: %v.)_", err), trailer)
} }
var b strings.Builder var b strings.Builder
if line := lootFlavorLine(tierVal); line != "" { if line := lootFlavorLine(tierVal); line != "" {
@@ -422,7 +557,19 @@ func (p *AdventurePlugin) dropZoneLoot(userID id.UserID, zoneID ZoneID, monster
b.WriteString(fmt.Sprintf("%s **%s** (%s) — %d coin baseline.", b.WriteString(fmt.Sprintf("%s **%s** (%s) — %d coin baseline.",
icon, entry.Name, tierVal, entry.BaseValue)) icon, entry.Name, tierVal, entry.BaseValue))
} }
return b.String() return joinLootLines(b.String(), trailer)
}
// joinLootLines stitches the zone-slate narration together with the
// consumable/ingredient trailer, tolerating either being empty.
func joinLootLines(parts ...string) string {
var kept []string
for _, s := range parts {
if s != "" {
kept = append(kept, s)
}
}
return strings.Join(kept, "\n")
} }
// dropMagicItemLoot deposits a registry magic item as a combat-victory drop // dropMagicItemLoot deposits a registry magic item as a combat-victory drop

View File

@@ -330,16 +330,26 @@ func eliteRoomEntryLine(zoneID ZoneID, runID string, roomIdx int) string {
return "🎭 **TwinBee:** " + line return "🎭 **TwinBee:** " + line
} }
// narrationCadence returns the salt index used to seed narration // narrationCadence returns the salt index used to seed narration pickers —
// pickers — the count of nodes visited so far (0-based, matching the // the number of rooms walked so far, 0-based. This is a *progress* read, not
// pre-G6 CurrentRoom semantics). Both linear-mode and graph-mode // a position read (revisit R1's audit split): flavor should keep moving
// advance bump len(VisitedNodes) in lockstep with CurrentRoom, so this // forward as the player does, so a backtrack draws fresh lines rather than
// preserves existing pick determinism while letting G9 drop the legacy // replaying the ones they already read on the way in.
// current_room column without re-keying every flavor pool seed. //
// Forward-only runs have RoomsTraversed == len(VisitedNodes), so this stays
// numerically identical to the pre-R1 derivation and every existing pick
// keeps landing on the same line. It is stable while the player stands
// still, which is what lets a re-read of `!status` print the same prose.
//
// The len(VisitedNodes) fallback covers rows that predate the R1 column and
// have yet to be swept by bootstrapRoomsTraversed.
func narrationCadence(run *DungeonRun) int { func narrationCadence(run *DungeonRun) int {
if run == nil { if run == nil {
return 0 return 0
} }
if run.RoomsTraversed > 0 {
return run.RoomsTraversed - 1
}
if n := len(run.VisitedNodes); n > 0 { if n := len(run.VisitedNodes); n > 0 {
return n - 1 return n - 1
} }
@@ -551,8 +561,17 @@ func dmMoodCombatTilt(mood int) DMMoodCombatTilt {
// applyMoodDecayIfStale persists a passive-decay correction when the // applyMoodDecayIfStale persists a passive-decay correction when the
// run has been idle long enough to drift. Cheap no-op on fresh runs. // run has been idle long enough to drift. Cheap no-op on fresh runs.
func applyMoodDecayIfStale(r *DungeonRun) error { //
if r == nil { // N3/P6d: owner-only, and the `owner` argument is not a convenience — the
// UPDATE below writes gm_mood as an *absolute* value derived from the snapshot
// the caller read, while adjustGMMood writes an atomic `gm_mood + delta`. Every
// command in the module takes the *sender's* advUserLock, so a party member
// running this against the leader's run holds the wrong mutex and would drop
// whatever the leader's concurrent walk banked between the read and this write.
// Decay is time-based maintenance: skipping it on a member's read loses nothing,
// because the owner's next command applies exactly the same correction.
func applyMoodDecayIfStale(r *DungeonRun, owner bool) error {
if r == nil || !owner {
return nil return nil
} }
newMood := passiveDecayMood(r.DMMood, r.LastActionAt, time.Now().UTC()) newMood := passiveDecayMood(r.DMMood, r.LastActionAt, time.Now().UTC())

View File

@@ -76,6 +76,14 @@ type DungeonRun struct {
CurrentNode string CurrentNode string
VisitedNodes []string VisitedNodes []string
NodeChoices map[string]any NodeChoices map[string]any
// Revisit R1 — monotonic count of node entries, including the entry
// room. Distinct from CurrentRoom: CurrentRoom answers "where am I on
// the path" and moves backwards when the player backtracks, while
// RoomsTraversed answers "how much walking has this run cost" and only
// ever climbs. Forward-only navigation keeps them locked together at
// RoomsTraversed == CurrentRoom+1; revisit is what pulls them apart.
RoomsTraversed int
} }
// IsActive reports whether this run is still ongoing (not boss-defeated, // IsActive reports whether this run is still ongoing (not boss-defeated,
@@ -105,6 +113,18 @@ func (r *DungeonRun) CurrentRoomType() RoomType {
return r.RoomSeq[r.CurrentRoom] return r.RoomSeq[r.CurrentRoom]
} }
// RoomIsCleared reports whether the player has already resolved the room at
// the given path index. Sticky: once cleared, a room stays cleared for the
// life of the run, however many times the player walks back through it.
func (r *DungeonRun) RoomIsCleared(idx int) bool {
for _, c := range r.RoomsCleared {
if c == idx {
return true
}
}
return false
}
// generateRoomSequence builds the deterministic-but-seeded room layout // generateRoomSequence builds the deterministic-but-seeded room layout
// for a run of the given zone. The boss room is always last; one Entry // for a run of the given zone. The boss room is always last; one Entry
// is always first; one Trap and one Elite room sit between explorations. // is always first; one Trap and one Elite room sit between explorations.
@@ -244,12 +264,16 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
run.CurrentNode = entryNode run.CurrentNode = entryNode
run.VisitedNodes = []string{entryNode} run.VisitedNodes = []string{entryNode}
run.NodeChoices = map[string]any{} run.NodeChoices = map[string]any{}
// Standing in the entry room is one traversal — the player walked in.
// Starting at 1 also keeps `rooms_traversed = 0` free as the
// "never backfilled" sentinel bootstrapRoomsTraversed keys on.
run.RoomsTraversed = 1
if _, err := db.Get().Exec(` if _, err := db.Get().Exec(`
INSERT INTO dnd_zone_run INSERT INTO dnd_zone_run
(run_id, user_id, zone_id, total_rooms, (run_id, user_id, zone_id, total_rooms,
rooms_cleared, gm_mood, rooms_cleared, gm_mood,
current_node, visited_nodes, node_choices) current_node, visited_nodes, node_choices, rooms_traversed)
VALUES (?, ?, ?, ?, '[]', ?, ?, ?, '{}')`, VALUES (?, ?, ?, ?, '[]', ?, ?, ?, '{}', 1)`,
run.RunID, run.UserID, string(zoneID), run.TotalRooms, startMood, run.RunID, run.UserID, string(zoneID), run.TotalRooms, startMood,
entryNode, string(visitedJSON), entryNode, string(visitedJSON),
); err != nil { ); err != nil {
@@ -272,7 +296,7 @@ func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) {
SELECT run_id, user_id, zone_id, total_rooms, SELECT run_id, user_id, zone_id, total_rooms,
rooms_cleared, boss_defeated, abandoned, rooms_cleared, boss_defeated, abandoned,
loot_collected, gm_mood, started_at, last_action_at, completed_at, loot_collected, gm_mood, started_at, last_action_at, completed_at,
current_node, visited_nodes, node_choices current_node, visited_nodes, node_choices, rooms_traversed
FROM dnd_zone_run FROM dnd_zone_run
WHERE user_id = ? WHERE user_id = ?
AND completed_at IS NULL AND completed_at IS NULL
@@ -290,6 +314,17 @@ func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) {
} }
if time.Since(r.LastActionAt) > zoneRunInactivityTimeout { if time.Since(r.LastActionAt) > zoneRunInactivityTimeout {
_ = abandonZoneRunByID(r.RunID) _ = abandonZoneRunByID(r.RunID)
// A run reaped by the §4.3 idle timeout must also terminate the
// wrapping active expedition. Without this, the expedition is left
// status='active' pointing at a now-abandoned run: the autopilot's
// runAutopilotWalk reads run==nil and bails, but the briefing/recap
// ambient tickers keep firing — the player soft-locks at the last
// fork, "stuck" with no way to route on. Mirror the run-loss seam,
// but only when this run is the active expedition's current run so
// a standalone (non-expedition) stale run still reaps cleanly.
if exp, _ := getActiveExpedition(userID); exp != nil && exp.RunID == r.RunID {
forceExtractExpeditionForRunLoss(userID, "run idle-timeout (§4.3 stale-run reap)")
}
return nil, nil return nil, nil
} }
return r, nil return r, nil
@@ -301,7 +336,7 @@ func getZoneRun(runID string) (*DungeonRun, error) {
SELECT run_id, user_id, zone_id, total_rooms, SELECT run_id, user_id, zone_id, total_rooms,
rooms_cleared, boss_defeated, abandoned, rooms_cleared, boss_defeated, abandoned,
loot_collected, gm_mood, started_at, last_action_at, completed_at, loot_collected, gm_mood, started_at, last_action_at, completed_at,
current_node, visited_nodes, node_choices current_node, visited_nodes, node_choices, rooms_traversed
FROM dnd_zone_run WHERE run_id = ?`, runID) FROM dnd_zone_run WHERE run_id = ?`, runID)
r, err := scanZoneRun(row) r, err := scanZoneRun(row)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -332,7 +367,7 @@ func scanZoneRun(row scanner) (*DungeonRun, error) {
&r.RunID, &r.UserID, &zoneID, &r.TotalRooms, &r.RunID, &r.UserID, &zoneID, &r.TotalRooms,
&clearedJSON, &bossDefeatedI, &abandonedI, &clearedJSON, &bossDefeatedI, &abandonedI,
&lootJSON, &r.DMMood, &r.StartedAt, &r.LastActionAt, &completedAtRaw, &lootJSON, &r.DMMood, &r.StartedAt, &r.LastActionAt, &completedAtRaw,
&currentNode, &visitedJSON, &choicesJSON, &currentNode, &visitedJSON, &choicesJSON, &r.RoomsTraversed,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -379,14 +414,16 @@ func scanZoneRun(row scanner) (*DungeonRun, error) {
r.NodeChoices = map[string]any{} r.NodeChoices = map[string]any{}
} }
r.CurrentNode = currentNode r.CurrentNode = currentNode
// G9b: CurrentRoom is now derived from VisitedNodes — no longer // G9b: CurrentRoom is derived from VisitedNodes — no longer persisted
// persisted in current_room. The downstream callers that key on // in current_room. Revisit R1 re-derives it as the *first-entry index
// CurrentRoom (combat enemy/trap salts, status renders, harvest // of CurrentNode* rather than len(VisitedNodes)-1. The two agree for
// keys) stay numerically identical to the dual-write era because // every forward-only run (each advance appends the node it moves to,
// VisitedNodes was bumped in lockstep with current_room. // so the newest node is always the current one), but they diverge the
if n := len(r.VisitedNodes); n > 0 { // moment a player backtracks. Keying on the node — not the tail — is
r.CurrentRoom = n - 1 // what makes a revisited room resolve to its original identity: the
} // enemy/trap salts, harvest keys and encounter IDs downstream all
// hash CurrentRoom, so room 3 must stay room 3 on the way back.
r.CurrentRoom = pathIndexOf(r.VisitedNodes, r.CurrentNode)
if r.CurrentNode == "" { if r.CurrentNode == "" {
// Defensive: a row from before the G4 dual-write deploy that // Defensive: a row from before the G4 dual-write deploy that
// somehow survived 24h inactivity timeouts. Pin to the linear // somehow survived 24h inactivity timeouts. Pin to the linear
@@ -396,6 +433,53 @@ func scanZoneRun(row scanner) (*DungeonRun, error) {
return &r, nil return &r, nil
} }
// appendVisited records a node entry in first-entry order. A node already
// in the path is not re-appended: VisitedNodes is an ordered *set*, and the
// player's room numbers must not renumber themselves when they walk back
// through a room they've already seen. The step cost of that walk is
// carried by rooms_traversed, not by this slice.
func appendVisited(visited []string, node string) []string {
for _, n := range visited {
if n == node {
return visited
}
}
return append(visited, node)
}
// appendClearedRoom marks a room index resolved. Idempotent: walking out of
// a room a second time doesn't re-append. "Cleared" is a sticky property of
// the room, not a count of exits — re-appending would let a backtracking
// player inflate RoomsCleared past TotalRooms and skew every "N/M rooms"
// render that reads its length.
func appendClearedRoom(cleared []int, room int) []int {
for _, r := range cleared {
if r == room {
return cleared
}
}
return append(cleared, room)
}
// pathIndexOf returns the first-entry index of node within visited — the
// 0-based room number the player sees in `!map`'s Path strip. Revisits do
// not append, so a node's index is stable for the life of the run.
//
// A node missing from visited means a row whose current_node was hot-swapped
// in without a matching visit record (pre-G4 rows, defensive only). Fall back
// to the tail, which is what the pre-R1 derivation would have produced.
func pathIndexOf(visited []string, node string) int {
for i, n := range visited {
if n == node {
return i
}
}
if n := len(visited); n > 0 {
return n - 1
}
return 0
}
// deriveLegacyNodeID returns the node id a linear graph would assign // deriveLegacyNodeID returns the node id a linear graph would assign
// to position roomIdx (0-based) for the given zone. Mirrors // to position roomIdx (0-based) for the given zone. Mirrors
// BuildLinearGraph's "<zone>.r<n>" scheme so hot-swapped rows align // BuildLinearGraph's "<zone>.r<n>" scheme so hot-swapped rows align
@@ -428,7 +512,7 @@ func markRoomCleared(runID string) (RoomType, error) {
if !ok { if !ok {
return "", fmt.Errorf("no graph for zone %q", r.ZoneID) return "", fmt.Errorf("no graph for zone %q", r.ZoneID)
} }
cleared := append(r.RoomsCleared, r.CurrentRoom) cleared := appendClearedRoom(r.RoomsCleared, r.CurrentRoom)
clearedJSON, _ := json.Marshal(cleared) clearedJSON, _ := json.Marshal(cleared)
edges := g.outgoingEdges(r.CurrentNode) edges := g.outgoingEdges(r.CurrentNode)
@@ -450,13 +534,14 @@ func markRoomCleared(runID string) (RoomType, error) {
return "", nil return "", nil
} }
nextNode := edges[0].To nextNode := edges[0].To
visited := append(r.VisitedNodes, nextNode) visited := appendVisited(r.VisitedNodes, nextNode)
visitedJSON, _ := json.Marshal(visited) visitedJSON, _ := json.Marshal(visited)
if _, err := db.Get().Exec(` if _, err := db.Get().Exec(`
UPDATE dnd_zone_run UPDATE dnd_zone_run
SET rooms_cleared = ?, SET rooms_cleared = ?,
current_node = ?, current_node = ?,
visited_nodes = ?, visited_nodes = ?,
rooms_traversed = rooms_traversed + 1,
last_action_at = CURRENT_TIMESTAMP last_action_at = CURRENT_TIMESTAMP
WHERE run_id = ?`, WHERE run_id = ?`,
string(clearedJSON), nextNode, string(visitedJSON), runID, string(clearedJSON), nextNode, string(visitedJSON), runID,
@@ -465,6 +550,8 @@ func markRoomCleared(runID string) (RoomType, error) {
} }
r.CurrentNode = nextNode r.CurrentNode = nextNode
r.VisitedNodes = visited r.VisitedNodes = visited
r.RoomsTraversed++
r.CurrentRoom = pathIndexOf(visited, nextNode)
return nodeKindToRoomType(g.Nodes[nextNode].Kind), nil return nodeKindToRoomType(g.Nodes[nextNode].Kind), nil
} }
@@ -536,4 +623,3 @@ func addLoot(runID string, itemID string) error {
WHERE run_id = ?`, string(lootJSON), runID) WHERE run_id = ?`, string(lootJSON), runID)
return err return err
} }

View File

@@ -0,0 +1,525 @@
package plugin
import (
"bytes"
"context"
"crypto/rand"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"math/big"
"net/http"
"os"
"regexp"
"strings"
"sync"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// EmailNagPlugin DMs a fixed set of local users who have no email on file in
// Authentik, collects an address, verifies inbox ownership with an emailed
// 6-digit code, and only then writes the address to Authentik via its API.
//
// Nothing is written to Authentik until the code is confirmed, so a mistyped
// or hostile address never becomes a live recovery address. State lives in
// SQLite so restarts don't re-nag anyone already done or mid-flow. Modeled on
// SpaceInviterPlugin.
type EmailNagPlugin struct {
Base
enabled bool
cfg emailNagConfig
http *http.Client
mu sync.Mutex // serializes per-user FSM transitions
sendLog map[id.UserID][]time.Time // per-user code-send timestamps (rolling 1h window)
}
type emailNagConfig struct {
AuthentikURL string
AuthentikToken string
ResendKey string
MailFrom string
HomeDomain string
Targets []string // Authentik usernames == Matrix localparts
CodeTTL time.Duration
MaxAttempts int
SweepDelay time.Duration
RepromptCooldown time.Duration // 0 = nag once, never re-nag
MaxSendsPerHour int // cap on verification emails per user per rolling hour
}
const (
nagStageEmail = "awaiting_email"
nagStageCode = "awaiting_code"
nagStageDone = "done"
)
var emailNagRe = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
func loadEmailNagConfig() emailNagConfig {
targets := []string{}
for _, t := range strings.Split(os.Getenv("EMAIL_NAG_TARGETS"), ",") {
if t = strings.TrimSpace(t); t != "" {
targets = append(targets, t)
}
}
maxAttempts := 5
if v := os.Getenv("EMAIL_NAG_MAX_ATTEMPTS"); v != "" {
var n int
if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n > 0 {
maxAttempts = n
}
}
maxSends := 5
if v := os.Getenv("EMAIL_NAG_MAX_SENDS_PER_HOUR"); v != "" {
var n int
if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n > 0 {
maxSends = n
}
}
return emailNagConfig{
AuthentikURL: strings.TrimRight(os.Getenv("EMAIL_NAG_AUTHENTIK_URL"), "/"),
AuthentikToken: os.Getenv("EMAIL_NAG_AUTHENTIK_TOKEN"),
ResendKey: os.Getenv("EMAIL_NAG_RESEND_KEY"),
MailFrom: os.Getenv("EMAIL_NAG_MAIL_FROM"),
HomeDomain: os.Getenv("EMAIL_NAG_HOME_DOMAIN"),
Targets: targets,
CodeTTL: envDur("EMAIL_NAG_CODE_TTL", 30*time.Minute),
MaxAttempts: maxAttempts,
SweepDelay: envDur("EMAIL_NAG_SWEEP_DELAY", 2*time.Second),
RepromptCooldown: envDur("EMAIL_NAG_REPROMPT_COOLDOWN", 0),
MaxSendsPerHour: maxSends,
}
}
func NewEmailNagPlugin(client *mautrix.Client) *EmailNagPlugin {
enabled := strings.EqualFold(os.Getenv("FEATURE_EMAIL_NAG"), "true")
if !enabled {
slog.Info("email_nag: disabled (set FEATURE_EMAIL_NAG=true to enable)")
}
return &EmailNagPlugin{
Base: NewBase(client),
enabled: enabled,
cfg: loadEmailNagConfig(),
http: &http.Client{Timeout: 30 * time.Second},
sendLog: make(map[id.UserID][]time.Time),
}
}
func (p *EmailNagPlugin) Name() string { return "email_nag" }
func (p *EmailNagPlugin) Commands() []CommandDef { return nil }
func (p *EmailNagPlugin) OnReaction(ReactionContext) error { return nil }
func (p *EmailNagPlugin) Init() error {
if !p.enabled {
return nil
}
if err := p.validateConfig(); err != nil {
// Don't take the whole bot down over a misconfigured one-shot helper —
// just disable this plugin and keep everything else running.
slog.Error("email_nag: disabled — invalid config", "err", err)
p.enabled = false
return nil
}
slog.Info("email_nag: ready",
"authentik", p.cfg.AuthentikURL,
"home_domain", p.cfg.HomeDomain,
"targets", len(p.cfg.Targets),
)
go p.runStartupSweep()
return nil
}
func (p *EmailNagPlugin) validateConfig() error {
missing := []string{}
if p.cfg.AuthentikURL == "" {
missing = append(missing, "EMAIL_NAG_AUTHENTIK_URL")
}
if p.cfg.AuthentikToken == "" {
missing = append(missing, "EMAIL_NAG_AUTHENTIK_TOKEN")
}
if p.cfg.ResendKey == "" {
missing = append(missing, "EMAIL_NAG_RESEND_KEY")
}
if p.cfg.MailFrom == "" {
missing = append(missing, "EMAIL_NAG_MAIL_FROM")
}
if p.cfg.HomeDomain == "" {
missing = append(missing, "EMAIL_NAG_HOME_DOMAIN")
}
if len(p.cfg.Targets) == 0 {
missing = append(missing, "EMAIL_NAG_TARGETS")
}
if len(missing) > 0 {
return fmt.Errorf("missing required config: %s", strings.Join(missing, ", "))
}
return nil
}
func (p *EmailNagPlugin) mxid(username string) id.UserID {
return id.UserID(fmt.Sprintf("@%s:%s", username, p.cfg.HomeDomain))
}
// ---------------------------------------------------------------------------
// Authentik API
// ---------------------------------------------------------------------------
type authentikUser struct {
PK int `json:"pk"`
Username string `json:"username"`
Email string `json:"email"`
IsActive bool `json:"is_active"`
}
// lookupUser returns the Authentik user for an exact username, or nil if absent.
func (p *EmailNagPlugin) lookupUser(ctx context.Context, username string) (*authentikUser, error) {
u := fmt.Sprintf("%s/api/v3/core/users/?username=%s", p.cfg.AuthentikURL, username)
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
req.Header.Set("Authorization", "Bearer "+p.cfg.AuthentikToken)
resp, err := p.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("authentik users status %d: %s", resp.StatusCode, string(body))
}
var page struct {
Results []authentikUser `json:"results"`
}
if err := json.Unmarshal(body, &page); err != nil {
return nil, fmt.Errorf("authentik decode: %w", err)
}
for i := range page.Results {
if page.Results[i].Username == username {
return &page.Results[i], nil
}
}
return nil, nil
}
// setEmail writes an email to an Authentik user by pk.
func (p *EmailNagPlugin) setEmail(ctx context.Context, pk int, email string) error {
payload, _ := json.Marshal(map[string]string{"email": email})
u := fmt.Sprintf("%s/api/v3/core/users/%d/", p.cfg.AuthentikURL, pk)
req, _ := http.NewRequestWithContext(ctx, http.MethodPatch, u, bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+p.cfg.AuthentikToken)
req.Header.Set("Content-Type", "application/json")
resp, err := p.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("authentik patch status %d: %s", resp.StatusCode, string(body))
}
return nil
}
// ---------------------------------------------------------------------------
// Resend API
// ---------------------------------------------------------------------------
func (p *EmailNagPlugin) sendCode(ctx context.Context, to, code string) error {
payload, _ := json.Marshal(map[string]any{
"from": p.cfg.MailFrom,
"to": []string{to},
"subject": "Your parodia.dev verification code",
"text": fmt.Sprintf(
"Your parodia.dev email-verification code is: %s\n\n"+
"Reply to the bot with this code within %s to confirm this address.\n"+
"If you didn't request this, you can ignore this message.",
code, formatCooldown(p.cfg.CodeTTL)),
})
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.resend.com/emails", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+p.cfg.ResendKey)
req.Header.Set("Content-Type", "application/json")
resp, err := p.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return fmt.Errorf("resend status %d: %s", resp.StatusCode, string(body))
}
return nil
}
// allowSend enforces a per-user cap on verification emails over a rolling hour,
// so a target can't make the bot spray branded code emails at arbitrary
// addresses. Caller must hold p.mu (sendLog is not separately synchronized).
func (p *EmailNagPlugin) allowSend(mxid id.UserID, now time.Time) bool {
cutoff := now.Add(-time.Hour)
kept := p.sendLog[mxid][:0]
for _, t := range p.sendLog[mxid] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
p.sendLog[mxid] = kept
if len(kept) >= p.cfg.MaxSendsPerHour {
return false
}
p.sendLog[mxid] = append(kept, now)
return true
}
func gen6() string {
n, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
if err != nil {
return "000000"
}
return fmt.Sprintf("%06d", n.Int64())
}
// ---------------------------------------------------------------------------
// Startup sweep
// ---------------------------------------------------------------------------
func (p *EmailNagPlugin) runStartupSweep() {
ctx := context.Background()
sent, skipped := 0, 0
for _, username := range p.cfg.Targets {
mxid := p.mxid(username)
// Already finished in a prior run? Never re-nag.
if st, _, _, ok := p.rowStatus(mxid); ok && st == nagStageDone {
skipped++
continue
}
// Email already set in Authentik (set by us before, or out-of-band)?
u, err := p.lookupUser(ctx, username)
if err != nil {
slog.Warn("email_nag: authentik lookup failed", "user", username, "err", err)
continue
}
if u == nil {
slog.Warn("email_nag: no Authentik user", "user", username)
continue
}
if !u.IsActive {
continue
}
if strings.TrimSpace(u.Email) != "" {
p.markDone(mxid, username, "", u.Email) // record so we stop checking
skipped++
continue
}
// In-flight already (DM sent, awaiting reply)? Re-nag only if a cooldown
// is configured and the last prompt is older than it; otherwise leave alone.
if st, promptAt, dmRoom, ok := p.rowStatus(mxid); ok && (st == nagStageEmail || st == nagStageCode) {
if p.cfg.RepromptCooldown > 0 && time.Since(time.Unix(promptAt, 0)) > p.cfg.RepromptCooldown {
if err := p.renag(id.RoomID(dmRoom), mxid, st); err != nil {
slog.Warn("email_nag: renag failed", "user", username, "err", err)
} else {
sent++
time.Sleep(p.cfg.SweepDelay)
}
} else {
skipped++
}
continue
}
if err := p.startNag(ctx, username); err != nil {
slog.Warn("email_nag: start nag failed", "user", username, "err", err)
continue
}
sent++
time.Sleep(p.cfg.SweepDelay)
}
slog.Info("email_nag: startup sweep complete", "nagged", sent, "skipped", skipped)
}
func (p *EmailNagPlugin) startNag(ctx context.Context, username string) error {
mxid := p.mxid(username)
room, err := p.GetDMRoom(mxid)
if err != nil {
return fmt.Errorf("get dm room: %w", err)
}
now := time.Now().Unix()
_, err = db.Get().Exec(`
INSERT INTO email_nag_prompts (user_id, username, dm_room_id, stage, prompt_sent_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
dm_room_id=excluded.dm_room_id, stage=excluded.stage, updated_at=excluded.updated_at`,
string(mxid), username, string(room), nagStageEmail, now, now)
if err != nil {
return fmt.Errorf("record prompt: %w", err)
}
return p.SendMessage(room,
"Hi! We're migrating parodia.dev to a new login system, and your account "+
"has no email on file — it's needed for password recovery. "+
"Please reply here with your email address and I'll verify it.")
}
// ---------------------------------------------------------------------------
// Reply handling — FSM driven from DM replies
// ---------------------------------------------------------------------------
type nagRow struct {
Username string
DMRoom string
Stage string
PendingEmail sql.NullString
Code sql.NullString
CodeExpires sql.NullInt64
Attempts int
}
func (p *EmailNagPlugin) rowStatus(mxid id.UserID) (stage string, promptAt int64, dmRoom string, ok bool) {
err := db.Get().QueryRow(
`SELECT stage, prompt_sent_at, dm_room_id FROM email_nag_prompts WHERE user_id = ?`,
string(mxid)).Scan(&stage, &promptAt, &dmRoom)
return stage, promptAt, dmRoom, err == nil
}
// renag sends a stage-appropriate reminder and bumps prompt_sent_at so the
// cooldown restarts. Used by the sweep when EMAIL_NAG_REPROMPT_COOLDOWN > 0.
func (p *EmailNagPlugin) renag(room id.RoomID, mxid id.UserID, stage string) error {
msg := "Just a reminder — we still need an email on your account for the login " +
"migration. Please reply here with your email address."
if stage == nagStageCode {
msg = "Reminder: I'm still waiting for the verification code I emailed you. " +
"Reply with it, or send your email again for a fresh code."
}
now := time.Now().Unix()
if _, err := db.Get().Exec(
`UPDATE email_nag_prompts SET prompt_sent_at=?, updated_at=? WHERE user_id=?`,
now, now, string(mxid)); err != nil {
return err
}
return p.SendMessage(room, msg)
}
func (p *EmailNagPlugin) load(mxid id.UserID, room id.RoomID) (*nagRow, bool) {
var r nagRow
err := db.Get().QueryRow(`
SELECT username, dm_room_id, stage, pending_email, code, code_expires, attempts
FROM email_nag_prompts WHERE user_id = ? AND dm_room_id = ?`,
string(mxid), string(room)).Scan(
&r.Username, &r.DMRoom, &r.Stage, &r.PendingEmail, &r.Code, &r.CodeExpires, &r.Attempts)
if err != nil {
return nil, false
}
return &r, true
}
func (p *EmailNagPlugin) markDone(mxid id.UserID, username, room, email string) {
now := time.Now().Unix()
_, _ = db.Get().Exec(`
INSERT INTO email_nag_prompts (user_id, username, dm_room_id, stage, prompt_sent_at, verified_email, verified_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
stage=excluded.stage, verified_email=excluded.verified_email,
verified_at=excluded.verified_at, updated_at=excluded.updated_at`,
string(mxid), username, room, nagStageDone, now, email, now, now)
}
// OnMessage drives the per-user FSM. Only acts on messages in a tracked DM room
// sent by that room's target user.
func (p *EmailNagPlugin) OnMessage(ctx MessageContext) error {
if !p.enabled {
return nil
}
r, ok := p.load(ctx.Sender, ctx.RoomID)
if !ok || r.Stage == nagStageDone {
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
// Re-read under lock in case a concurrent transition already advanced us.
r, ok = p.load(ctx.Sender, ctx.RoomID)
if !ok || r.Stage == nagStageDone {
return nil
}
bgctx := context.Background()
body := strings.TrimSpace(ctx.Body)
now := time.Now()
// An email anywhere in the message (re)starts verification for that address,
// whether we're awaiting the first email or a code-with-corrected-address.
if m := emailNagRe.FindString(body); m != "" {
email := strings.ToLower(m)
if !p.allowSend(ctx.Sender, now) {
return p.SendMessage(ctx.RoomID, fmt.Sprintf(
"That's a lot of attempts in a short window — please wait a bit before "+
"requesting another code (max %d per hour).", p.cfg.MaxSendsPerHour))
}
code := gen6()
exp := now.Add(p.cfg.CodeTTL).Unix()
if _, err := db.Get().Exec(`
UPDATE email_nag_prompts
SET stage=?, pending_email=?, code=?, code_expires=?, attempts=0, updated_at=?
WHERE user_id=?`,
nagStageCode, email, code, exp, now.Unix(), string(ctx.Sender)); err != nil {
return err
}
if err := p.sendCode(bgctx, email, code); err != nil {
slog.Error("email_nag: send code failed", "to", email, "err", err)
return p.SendMessage(ctx.RoomID,
"I couldn't send a code to that address just now — please double-check it and send it again.")
}
return p.SendMessage(ctx.RoomID, fmt.Sprintf(
"I emailed a 6-digit code to %s. Reply with the code to confirm (valid %s). "+
"Wrong address? Just send the correct one.", email, formatCooldown(p.cfg.CodeTTL)))
}
if r.Stage == nagStageEmail {
return p.SendMessage(ctx.RoomID,
"That doesn't look like an email address. Please send just your address, e.g. you@example.com")
}
// Stage awaiting_code: expect the digits.
entered := regexp.MustCompile(`\D`).ReplaceAllString(body, "")
if entered == "" {
return nil // not a code and not an email — ignore chatter
}
if !r.CodeExpires.Valid || now.Unix() > r.CodeExpires.Int64 {
_, _ = db.Get().Exec(`UPDATE email_nag_prompts SET stage=?, updated_at=? WHERE user_id=?`,
nagStageEmail, now.Unix(), string(ctx.Sender))
return p.SendMessage(ctx.RoomID, "That code expired. Send your email again and I'll issue a new one.")
}
if !r.Code.Valid || entered != r.Code.String {
attempts := r.Attempts + 1
if attempts >= p.cfg.MaxAttempts {
_, _ = db.Get().Exec(`UPDATE email_nag_prompts SET stage=?, attempts=0, updated_at=? WHERE user_id=?`,
nagStageEmail, now.Unix(), string(ctx.Sender))
return p.SendMessage(ctx.RoomID, "Too many wrong codes. Send your email again to restart.")
}
_, _ = db.Get().Exec(`UPDATE email_nag_prompts SET attempts=?, updated_at=? WHERE user_id=?`,
attempts, now.Unix(), string(ctx.Sender))
return p.SendMessage(ctx.RoomID, "That code doesn't match. Try again.")
}
// Verified — write to Authentik (re-check it's still empty to avoid clobbering).
u, err := p.lookupUser(bgctx, r.Username)
if err != nil {
slog.Error("email_nag: verify lookup failed", "user", r.Username, "err", err)
return p.SendMessage(ctx.RoomID, "Something went wrong on my end — please try the code again in a moment.")
}
if u == nil {
return p.SendMessage(ctx.RoomID, "I can't find your account anymore — please contact an admin.")
}
if strings.TrimSpace(u.Email) == "" {
if err := p.setEmail(bgctx, u.PK, r.PendingEmail.String); err != nil {
slog.Error("email_nag: set email failed", "user", r.Username, "err", err)
return p.SendMessage(ctx.RoomID, "I verified the code but couldn't save it — please try again shortly.")
}
}
p.markDone(ctx.Sender, r.Username, string(ctx.RoomID), r.PendingEmail.String)
slog.Info("email_nag: verified", "user", r.Username, "email", r.PendingEmail.String)
return p.SendMessage(ctx.RoomID, fmt.Sprintf(
"Verified ✓ %s is now on your account. Thanks — that's all we needed!", r.PendingEmail.String))
}

View File

@@ -583,4 +583,3 @@ func (p *EuroPlugin) handleGrant(ctx MessageContext) error {
return p.SendReply(ctx.RoomID, ctx.EventID, return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("✅ Granted €%d to **%s**. New balance: €%d.", int(amount), targetName, int(newBalance))) fmt.Sprintf("✅ Granted €%d to **%s**. New balance: €%d.", int(amount), targetName, int(newBalance)))
} }

View File

@@ -116,13 +116,21 @@ func (p *AdventurePlugin) fireExpeditionAutoRuns(now time.Time) {
// is older than ageCutoff so a freshly-started expedition isn't yanked // is older than ageCutoff so a freshly-started expedition isn't yanked
// out from under the player on tick 1. // out from under the player on tick 1.
func loadExpeditionsForAutoRun(runCutoff, ageCutoff time.Time) ([]string, error) { func loadExpeditionsForAutoRun(runCutoff, ageCutoff time.Time) ([]string, error) {
// N3/P6b: an expedition with an unanswered invite does not walk. The
// leader must not be dragged into a boss room while their friend is still
// reading the DM. Bounded by expeditionInviteTTL, which the invited_at
// comparison enforces here rather than trusting the purge to have run.
rows, err := db.Get().Query(` rows, err := db.Get().Query(`
SELECT expedition_id SELECT expedition_id
FROM dnd_expedition FROM dnd_expedition e
WHERE status = 'active' WHERE status = 'active'
AND start_date < ? AND start_date < ?
AND (last_autorun_at IS NULL OR last_autorun_at < ?)`, AND (last_autorun_at IS NULL OR last_autorun_at < ?)
ageCutoff, runCutoff) AND NOT EXISTS (
SELECT 1 FROM expedition_invite i
WHERE i.expedition_id = e.expedition_id
AND i.invited_at > ?)`,
ageCutoff, runCutoff, inviteCutoff())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -295,8 +303,14 @@ func (p *AdventurePlugin) tryAutoRun(e *Expedition, now time.Time) error {
// mode: a Night-camp pitch flushes the accumulated day as a digest; // mode: a Night-camp pitch flushes the accumulated day as a digest;
// every other quiet path stays silent until something interactive fires. // every other quiet path stays silent until something interactive fires.
if body, ok := buildAutoRunDM(e.ID, r, campBlock, campDecision); ok { if body, ok := buildAutoRunDM(e.ID, r, campBlock, campDecision); ok {
if err := p.SendDM(uid, body); err != nil { p.fanOutExpeditionDM(e, body, nil)
slog.Warn("expedition: autorun DM", "user", uid, "err", err) // N1/A6 — the end-of-day digest is the primary mid-day event anchor.
// The anchor is a per-player roll against a per-player daily slot, so
// each member rolls their own; a party does not share one event.
if campDecision.Night {
for _, member := range expeditionAudience(e) {
p.maybeFireAnchoredEvent(member, advEventChanceDigest)
}
} }
} }

View File

@@ -1281,6 +1281,7 @@ func TestExpeditionBalance_Phase5A_TierWideSensitivity(t *testing.T) {
// what flat delta on top of the ladder lands T1-T5 in band. // what flat delta on top of the ladder lands T1-T5 in band.
// //
// Bands (gogobee_expedition_difficulty.md): // Bands (gogobee_expedition_difficulty.md):
//
// T1 80% (70-90%) T2 72% (62-82%) T3 65% (55-75%) // T1 80% (70-90%) T2 72% (62-82%) T3 65% (55-75%)
// T4 55% (45-65%) T5 45% (35-55%) // T4 55% (45-65%) T5 45% (35-55%)
// //

View File

@@ -0,0 +1,279 @@
package plugin
import (
"database/sql"
"errors"
"fmt"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// N3/P4 — expedition party membership.
//
// A co-op expedition is one dnd_expedition row plus an expedition_party roster.
// The leader's row stays the single source of truth for the shared clock, the
// threat track, and the supply pool; members reference it rather than owning a
// row of their own. There is no party_id column — expedition_id already
// identifies the party, and a second key would be a second answer to "who is in
// this party".
//
// Absence means solo. Every expedition that existed before N3 has no rows here,
// which is exactly the right reading, so nothing needs backfilling. A solo
// expedition started after N3 writes no rows either: the roster materializes on
// the first successful invite (P6), and until then partyMembers returns empty.
// Party roles (expedition_party.role).
const (
PartyRoleLeader = "leader"
PartyRoleMember = "member"
)
// expeditionPartyMax is the roster ceiling including the leader. C1 scopes v1
// to 23 players; the combat roster and the supply pool are both sized off this.
const expeditionPartyMax = 3
// ExpeditionPartyMax exports the ceiling for cmd/expedition-sim's -party flag,
// so the harness cannot ask for a roster the seating layer will refuse.
const ExpeditionPartyMax = expeditionPartyMax
// Errors returned by the party layer.
var (
ErrPartyFull = errors.New("expedition party is full")
ErrAlreadyInParty = errors.New("player is already in this party")
ErrNotPartyLeader = errors.New("only the party leader may do that")
ErrPlayerBusyElsewhere = errors.New("player already has an active expedition")
)
// PartyMember is one seat on an expedition roster.
type PartyMember struct {
ExpeditionID string
UserID string
Role string
JoinedAt time.Time
}
// IsLeader reports whether this member owns the expedition row.
func (m PartyMember) IsLeader() bool { return m.Role == PartyRoleLeader }
// partyMembers returns the roster in join order, leader first. A solo
// expedition has no rows and returns an empty slice — callers should treat
// len(roster) == 0 and len(roster) == 1 alike, as "one player".
func partyMembers(expeditionID string) ([]PartyMember, error) {
rows, err := db.Get().Query(`
SELECT expedition_id, user_id, role, joined_at
FROM expedition_party
WHERE expedition_id = ?
ORDER BY (role <> 'leader'), joined_at ASC`, expeditionID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PartyMember
for rows.Next() {
var m PartyMember
if err := rows.Scan(&m.ExpeditionID, &m.UserID, &m.Role, &m.JoinedAt); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// partyMemberIDs is partyMembers reduced to user ids, leader first. It is what
// the fan-out seams (digest, briefing, recap) want: a solo expedition yields
// just the owner, so a caller can loop unconditionally.
func partyMemberIDs(expeditionID, ownerID string) ([]id.UserID, error) {
members, err := partyMembers(expeditionID)
if err != nil {
return nil, err
}
if len(members) == 0 {
return []id.UserID{id.UserID(ownerID)}, nil
}
out := make([]id.UserID, 0, len(members))
for _, m := range members {
out = append(out, id.UserID(m.UserID))
}
return out, nil
}
// partySize is the number of seated players: 1 for a solo expedition.
func partySize(expeditionID string) (int, error) {
var n int
err := db.Get().QueryRow(
`SELECT COUNT(*) FROM expedition_party WHERE expedition_id = ?`,
expeditionID).Scan(&n)
if err != nil {
return 0, err
}
if n == 0 {
return 1, nil
}
return n, nil
}
// openParty seats the leader, converting a solo expedition row into a party of
// one. It is idempotent: a second call on the same expedition is a no-op, so
// the invite path can call it unconditionally before adding a member.
func openParty(expeditionID string, leaderID id.UserID) error {
_, err := db.Get().Exec(`
INSERT INTO expedition_party (expedition_id, user_id, role)
VALUES (?, ?, 'leader')
ON CONFLICT (expedition_id, user_id) DO NOTHING`,
expeditionID, string(leaderID))
return err
}
// joinParty seats a member. It refuses a full roster, a duplicate, and a player
// who already leads or rides an expedition of their own — the "one active
// expedition per user" rule that startExpedition enforces in code, extended to
// cover membership. The check and the insert share a transaction so two
// simultaneous invites cannot both find the last seat free.
//
// It seats the leader first if nobody has. A roster whose leader is missing
// would quietly drop them from every fan-out (partyMemberIDs reads the roster,
// not the expedition row), so the ordering is enforced here rather than left to
// each caller to remember.
func joinParty(expeditionID string, userID id.UserID) error {
tx, err := db.Get().Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := seatLeader(tx, expeditionID); err != nil {
return err
}
var n int
if err := tx.QueryRow(
`SELECT COUNT(*) FROM expedition_party WHERE expedition_id = ?`,
expeditionID).Scan(&n); err != nil {
return err
}
if n >= expeditionPartyMax {
return ErrPartyFull
}
if err := assertNotAdventuring(tx, expeditionID, userID); err != nil {
return err
}
if _, err := tx.Exec(`
INSERT INTO expedition_party (expedition_id, user_id, role)
VALUES (?, ?, 'member')`,
expeditionID, string(userID)); err != nil {
return fmt.Errorf("seat %s: %w", userID, err)
}
return tx.Commit()
}
// seatLeader is openParty inside a caller's transaction: it reads the owner off
// the expedition row rather than trusting a passed-in id, so the roster's leader
// can never disagree with dnd_expedition.user_id.
func seatLeader(tx *sql.Tx, expeditionID string) error {
var owner string
err := tx.QueryRow(
`SELECT user_id FROM dnd_expedition WHERE expedition_id = ?`,
expeditionID).Scan(&owner)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("expedition %s not found", expeditionID)
}
if err != nil {
return err
}
_, err = tx.Exec(`
INSERT INTO expedition_party (expedition_id, user_id, role)
VALUES (?, ?, 'leader')
ON CONFLICT (expedition_id, user_id) DO NOTHING`, expeditionID, owner)
return err
}
// assertNotAdventuring fails if the player is already committed elsewhere:
// seated on this or any other party, or owning an active expedition row. Runs
// inside joinParty's transaction so the guard and the insert are atomic.
func assertNotAdventuring(tx *sql.Tx, expeditionID string, userID id.UserID) error {
var seatedIn string
err := tx.QueryRow(`
SELECT expedition_id FROM expedition_party WHERE user_id = ? LIMIT 1`,
string(userID)).Scan(&seatedIn)
switch {
case err == nil && seatedIn == expeditionID:
return ErrAlreadyInParty
case err == nil:
return ErrPlayerBusyElsewhere
case !errors.Is(err, sql.ErrNoRows):
return err
}
var owned int
if err := tx.QueryRow(`
SELECT COUNT(*) FROM dnd_expedition
WHERE user_id = ? AND status IN ('active', 'extracting')`,
string(userID)).Scan(&owned); err != nil {
return err
}
if owned > 0 {
return ErrPlayerBusyElsewhere
}
return nil
}
// leaveParty removes one member. The leader cannot leave — an expedition
// without its owner has no row to hang the shared clock on; the leader ends the
// run for everyone with !extract instead.
func leaveParty(expeditionID string, userID id.UserID) error {
res, err := db.Get().Exec(`
DELETE FROM expedition_party
WHERE expedition_id = ? AND user_id = ? AND role <> 'leader'`,
expeditionID, string(userID))
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotPartyLeader
}
return nil
}
// disbandParty clears the roster. Called when an expedition reaches a terminal
// status, so every member is free to start their own next run.
func disbandParty(expeditionID string) error {
_, err := db.Get().Exec(
`DELETE FROM expedition_party WHERE expedition_id = ?`, expeditionID)
return err
}
// expeditionForMember resolves the expedition a player is seated on but does
// not own. getActiveExpedition keys on dnd_expedition.user_id and so is blind to
// members; every player-facing lookup wants both, which is what
// activeExpeditionFor provides.
func expeditionForMember(userID id.UserID) (*Expedition, error) {
row := db.Get().QueryRow(`
SELECT`+expeditionSelectCols+`
FROM dnd_expedition e
JOIN expedition_party p ON p.expedition_id = e.expedition_id
WHERE p.user_id = ? AND p.role <> 'leader' AND e.status = 'active'
ORDER BY e.start_date DESC
LIMIT 1`, string(userID))
e, err := scanExpedition(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return e, err
}
// activeExpeditionFor returns the expedition a player is on, whether they lead
// it or ride it, plus whether they lead it. This is the lookup every
// player-facing command wants; getActiveExpedition alone silently tells a party
// member they have no expedition.
func activeExpeditionFor(userID id.UserID) (e *Expedition, isLeader bool, err error) {
if e, err = getActiveExpedition(userID); err != nil || e != nil {
return e, e != nil, err
}
e, err = expeditionForMember(userID)
return e, false, err
}

View File

@@ -0,0 +1,318 @@
package plugin
import (
"errors"
"fmt"
"strings"
"maunium.net/go/mautrix/id"
)
// N3/P6b — the player-facing party surface.
//
// !expedition invite @user (leader, Day 1)
// !expedition accept [packs] (invitee — buys their own loadout)
// !expedition decline
// !expedition party (roster + pool)
// !expedition leave (member; the leader ends it with !extract)
//
// The invitee pays for their own supplies, which is what makes a party fair
// rather than a free ride: the pool is the sum of what everyone carried in.
// zoneOpenToLevel reports whether a player at this level may enter the zone. The
// leader's expedition is no way around the tier gate — a party is not a taxi
// into content two tiers above your head.
func zoneOpenToLevel(zoneID ZoneID, level int) bool {
for _, z := range zonesForLevel(level) {
if z.ID == zoneID {
return true
}
}
return false
}
// expeditionCmdInvite asks a player to join the sender's expedition.
func (p *AdventurePlugin) expeditionCmdInvite(ctx MessageContext, rest string) error {
target := strings.TrimSpace(rest)
if target == "" {
return p.SendDM(ctx.Sender, "`!expedition invite @user` — bring someone along. Day 1 only.")
}
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
}
if exp == nil {
return p.SendDM(ctx.Sender, "No active expedition. `!expedition start <zone>` first.")
}
if !isLeader {
return p.SendDM(ctx.Sender, "Only the party leader can invite. You're along for the ride.")
}
if !inviteWindowOpen(exp) {
return p.SendDM(ctx.Sender,
"This expedition has already set off — companions join on Day 1 only.")
}
invitee, ok := p.ResolveUser(target, ctx.RoomID)
if !ok {
return p.SendDM(ctx.Sender, "Couldn't find that player.")
}
if invitee == ctx.Sender {
return p.SendDM(ctx.Sender, "You're already here.")
}
if c, cerr := LoadDnDCharacter(invitee); cerr != nil || c == nil || c.PendingSetup {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"**%s** hasn't made a character yet — they need `!setup` first.", p.DisplayName(invitee)))
}
zone := zoneOrFallback(exp.ZoneID)
if err := inviteToParty(exp.ID, invitee, ctx.Sender); err != nil {
return p.SendDM(ctx.Sender, inviteRefusalText(p.DisplayName(invitee), err))
}
if derr := p.SendDM(invitee, fmt.Sprintf(
"🤝 **%s** wants you on an expedition into **%s**.\n\n"+
"You'll buy your own supplies and they go into the party's pool. Loot and XP are yours alone — nothing is split.\n\n"+
"`!expedition accept` to pick a supply pack, or `!expedition decline`.\n"+
"_The offer stands for %s._",
p.DisplayName(ctx.Sender), zone.Display, formatRespecDuration(expeditionInviteTTL))); derr != nil {
// The invite row is committed; the DM is not. Tell the leader rather
// than rolling back — the invitee can still `!expedition accept`.
return p.SendDM(ctx.Sender, fmt.Sprintf(
"Invited **%s**, but couldn't DM them (%v). They can still `!expedition accept`.",
p.DisplayName(invitee), derr))
}
return p.SendDM(ctx.Sender, fmt.Sprintf(
"🤝 Asked **%s** to join you in **%s**. I'll hold the expedition here until they answer.",
p.DisplayName(invitee), zone.Display))
}
// inviteRefusalText turns the invite layer's sentinel errors into player-facing
// copy. Anything unrecognised surfaces raw — a silent "couldn't invite" would
// hide a real bug.
func inviteRefusalText(name string, err error) string {
switch {
case errors.Is(err, ErrPartyFull):
return fmt.Sprintf("Your party is full (%d, counting invites you haven't heard back on).", expeditionPartyMax)
case errors.Is(err, ErrAlreadyInParty):
return fmt.Sprintf("**%s** is already with you.", name)
case errors.Is(err, ErrAlreadyInvited):
return fmt.Sprintf("You've already asked **%s** — still waiting on them.", name)
case errors.Is(err, ErrPlayerBusyElsewhere):
return fmt.Sprintf("**%s** is already on an expedition of their own.", name)
default:
return "Couldn't send the invite: " + err.Error()
}
}
// expeditionCmdAccept seats the sender on the expedition they were most recently
// asked to join, once they have bought their own supplies.
func (p *AdventurePlugin) expeditionCmdAccept(ctx MessageContext, c *DnDCharacter, rest string) error {
if remaining := restingLockoutRemaining(c); remaining > 0 {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"🛌 You're still resting — %s remaining. Pack up after.", formatRespecDuration(remaining)))
}
inv, err := latestInviteFor(ctx.Sender)
if errors.Is(err, ErrInviteNotFound) {
return p.SendDM(ctx.Sender, "Nobody has invited you anywhere.")
}
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read your invites: "+err.Error())
}
exp, err := getExpedition(inv.ExpeditionID)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read that expedition: "+err.Error())
}
if exp == nil || !inviteWindowOpen(exp) {
_ = clearInvite(inv.ExpeditionID, ctx.Sender)
return p.SendDM(ctx.Sender, "That expedition has already set off without you.")
}
// A standalone zone run isn't caught by assertNotAdventuring — it guards
// expeditions and rosters, not bare runs. startExpedition rejects one; so
// must this.
if run, _ := getActiveZoneRun(ctx.Sender); run != nil {
return p.SendDM(ctx.Sender,
"You have an active zone run. Finish or `!zone abandon` before joining a party.")
}
zone := zoneOrFallback(exp.ZoneID)
if !zoneOpenToLevel(exp.ZoneID, c.Level) {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"**%s** is beyond you for now — come back at a higher level.", zone.Display))
}
packTok := strings.TrimSpace(rest)
if packTok == "" {
return p.SendDM(ctx.Sender, renderLoadoutPrompt(zone, "expedition accept"))
}
purchase, err := resolveLoadoutOrParse(packTok, zone.Tier)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error())
}
if err := purchase.Validate(zone.Tier); err != nil {
return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
}
if p.euro == nil {
return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
}
cost := float64(purchase.Cost())
if balance := p.euro.GetBalance(ctx.Sender); balance < cost {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.", int(cost), balance))
}
if !p.euro.Debit(ctx.Sender, cost, "expedition outfitting: "+string(exp.ZoneID)) {
return p.SendDM(ctx.Sender, "Couldn't debit outfitting cost (try again).")
}
if err := joinParty(exp.ID, ctx.Sender); err != nil {
p.euro.Credit(ctx.Sender, cost, "expedition outfitting refund")
return p.SendDM(ctx.Sender, inviteRefusalText(p.DisplayName(ctx.Sender), err))
}
_ = clearInvite(exp.ID, ctx.Sender)
// Fold their packs into the pool. Do this after the seat is committed: a
// member whose supplies landed but whose seat did not would have paid to
// feed a party they aren't in.
suppliesPurchase := purchase
if isHol, _ := isHolidayToday(); isHol {
suppliesPurchase.StandardPacks++
}
// updateSupplies overwrites supplies_json wholesale, so the pool has to be
// re-read under the expedition's own lock: `exp` was fetched before the coin
// debit, and a second invitee accepting — or the leader's day-burn tick —
// may have rewritten the row since. Folding onto that stale snapshot would
// silently drop their packs or resurrect spent SU.
expMu := p.advExpeditionLock(exp.ID)
expMu.Lock()
fresh, err := getExpedition(exp.ID)
if err != nil || fresh == nil {
expMu.Unlock()
return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool.")
}
pooled := addSupplyPurchase(fresh.Supplies, suppliesPurchase)
err = updateSupplies(exp.ID, pooled)
expMu.Unlock()
if err != nil {
return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool: "+err.Error())
}
leader := id.UserID(exp.UserID)
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
fmt.Sprintf("%s joined the party", p.DisplayName(ctx.Sender)), "")
for _, uid := range expeditionAudience(exp) {
body := fmt.Sprintf("🤝 **%s** joins the expedition into **%s**. Supplies pooled: **%.1f SU**.",
p.DisplayName(ctx.Sender), zone.Display, pooled.Current)
if uid == ctx.Sender {
body = fmt.Sprintf("🤝 You join **%s** in **%s**. Party supplies: **%.1f SU**.\n\n"+
"`!expedition party` for the roster. When a fight starts, you'll act on your own turn.",
p.DisplayName(leader), zone.Display, pooled.Current)
}
if derr := p.SendDM(uid, body); derr != nil {
return derr
}
}
return nil
}
// expeditionCmdDecline drops the sender's most recent invite.
func (p *AdventurePlugin) expeditionCmdDecline(ctx MessageContext) error {
inv, err := latestInviteFor(ctx.Sender)
if errors.Is(err, ErrInviteNotFound) {
return p.SendDM(ctx.Sender, "Nobody has invited you anywhere.")
}
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read your invites: "+err.Error())
}
if err := clearInvite(inv.ExpeditionID, ctx.Sender); err != nil {
return p.SendDM(ctx.Sender, "Couldn't decline: "+err.Error())
}
leader := id.UserID(inv.InvitedBy)
_ = p.SendDM(leader, fmt.Sprintf("**%s** won't be joining you. The expedition is yours to walk.",
p.DisplayName(ctx.Sender)))
return p.SendDM(ctx.Sender, "Declined. Maybe next time.")
}
// expeditionCmdParty renders the roster, whoever asks for it.
func (p *AdventurePlugin) expeditionCmdParty(ctx MessageContext) error {
exp, _, err := activeExpeditionFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
}
if exp == nil {
return p.SendDM(ctx.Sender, "No active expedition.")
}
members, err := partyMembers(exp.ID)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read the roster: "+err.Error())
}
zone := zoneOrFallback(exp.ZoneID)
var b strings.Builder
b.WriteString(fmt.Sprintf("🎒 **%s — Day %d**\n\n", zone.Display, exp.CurrentDay))
if len(members) == 0 {
b.WriteString(fmt.Sprintf("**%s** — alone.\n", p.DisplayName(id.UserID(exp.UserID))))
}
for _, m := range members {
role := "member"
if m.IsLeader() {
role = "leader"
}
b.WriteString(fmt.Sprintf("**%s** _(%s)_\n", p.DisplayName(id.UserID(m.UserID)), role))
}
if invites, ierr := pendingInvites(exp.ID); ierr == nil {
for _, inv := range invites {
b.WriteString(fmt.Sprintf("**%s** _(asked, no answer yet)_\n", p.DisplayName(id.UserID(inv.UserID))))
}
}
b.WriteString(fmt.Sprintf("\nSupplies: **%.1f / %.1f SU**", exp.Supplies.Current, exp.Supplies.Max))
if inviteWindowOpen(exp) {
b.WriteString("\n\n_`!expedition invite @user` while it's still Day 1._")
}
return p.SendDM(ctx.Sender, b.String())
}
// expeditionCmdLeave walks a member out. The leader cannot leave — their row is
// the expedition — so they are pointed at `!extract`, which ends it for all.
func (p *AdventurePlugin) expeditionCmdLeave(ctx MessageContext) error {
// Resolve the seat the way the guards that trap them do. seatedExpeditionFor
// spans `extracting`, which activeExpeditionFor does not: a leader who
// extracts and never resumes would otherwise leave their members seated —
// refused a new adventure by the guard, and told "no active expedition" by
// the very command the guard points them at. The exit has to see every state
// the gate sees. It already excludes leaders, so they fall through below.
seated, err := seatedExpeditionFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
}
if seated != nil {
return p.leaveSeatedParty(ctx, seated)
}
exp, isLeader, err := activeExpeditionFor(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
}
if exp == nil {
return p.SendDM(ctx.Sender, "No active expedition.")
}
if isLeader {
return p.SendDM(ctx.Sender,
"You're leading this one — `!extract` ends it for everyone, or `!expedition abandon` to walk away from it.")
}
return p.leaveSeatedParty(ctx, exp)
}
// leaveSeatedParty unseats a member and tells both ends. Shared by the two ways
// a member's seat resolves: the `extracting` limbo and the plain active party.
func (p *AdventurePlugin) leaveSeatedParty(ctx MessageContext, exp *Expedition) error {
if err := leaveParty(exp.ID, ctx.Sender); err != nil {
return p.SendDM(ctx.Sender, "Couldn't leave: "+err.Error())
}
// Supplies stay in the pool. They were spent on the expedition, not lent to
// it, and clawing them back would let a member starve the party on their way
// out of the door.
_ = p.SendDM(id.UserID(exp.UserID), fmt.Sprintf(
"**%s** turned back. Their supplies stay with the party.", p.DisplayName(ctx.Sender)))
return p.SendDM(ctx.Sender, "You turn back for town. Your supplies stay with the party.")
}

View File

@@ -0,0 +1,190 @@
package plugin
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// N3/P6b — the invite handshake.
//
// A party forms in the gap between "I started an expedition" and "we set off",
// and that gap is short: the autopilot leaves a fresh expedition alone for
// autoRunMinExpeditionAge (30 minutes) and then starts walking it. Thirty
// minutes is not long enough to ask a friend who is asleep.
//
// So an outstanding invite pins the autopilot in place — loadExpeditionsForAutoRun
// skips any expedition somebody has been asked to join. The pin is bounded by
// expeditionInviteTTL, because a leader who invites someone on holiday must not
// have their expedition frozen forever.
//
// The window the *leader* sees is wider than the plan's "before the first walk":
// an invite is legal for the whole of Day 1. Tying it to the first step would
// have made it a race against a ticker the player cannot see, and lost the
// leader's own `!expedition run` as well. Supplies barely move on day one — the
// pool burns at the night rollover — so a companion who arrives three rooms in
// pays and receives the same as one who arrives at the gate.
const (
// expeditionInviteTTL bounds how long an unanswered invite holds the
// autopilot. Long enough to catch someone between sessions of an async chat
// bot; short enough that a forgotten invite costs the leader one afternoon,
// not their expedition.
expeditionInviteTTL = 2 * time.Hour
)
// Errors returned by the invite layer.
var (
ErrInviteNotFound = errors.New("no pending invite")
ErrInviteWindowShut = errors.New("this expedition has already set off")
ErrAlreadyInvited = errors.New("player already has a pending invite here")
)
// ExpeditionInvite is one unanswered ask.
type ExpeditionInvite struct {
ExpeditionID string
UserID string
InvitedBy string
InvitedAt time.Time
}
// inviteWindowOpen reports whether a party may still take on a member. Day 1
// only, and never after the boss is down — joining a finished expedition would
// seat someone into a payout they did not walk to.
func inviteWindowOpen(e *Expedition) bool {
return e != nil && e.Status == ExpeditionStatusActive &&
e.CurrentDay <= 1 && !e.BossDefeated
}
// inviteToParty records a pending invite. It refuses a full roster — counting
// outstanding invites against the ceiling, so a leader cannot ask four people
// and let them race for two seats — and refuses anyone already committed to an
// expedition of their own.
//
// The count, the guard, and the insert share a transaction for the same reason
// joinParty's do: two invites sent at once must not both find the last seat free.
func inviteToParty(expeditionID string, invitee, leader id.UserID) error {
tx, err := db.Get().Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := seatLeader(tx, expeditionID); err != nil {
return err
}
var seated, pending int
if err := tx.QueryRow(
`SELECT COUNT(*) FROM expedition_party WHERE expedition_id = ?`,
expeditionID).Scan(&seated); err != nil {
return err
}
if err := tx.QueryRow(
`SELECT COUNT(*) FROM expedition_invite WHERE expedition_id = ? AND invited_at > ?`,
expeditionID, inviteCutoff()).Scan(&pending); err != nil {
return err
}
if seated+pending >= expeditionPartyMax {
return ErrPartyFull
}
if err := assertNotAdventuring(tx, expeditionID, invitee); err != nil {
return err
}
res, err := tx.Exec(`
INSERT INTO expedition_invite (expedition_id, user_id, invited_by, invited_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (expedition_id, user_id) DO NOTHING`,
expeditionID, string(invitee), string(leader), time.Now().UTC())
if err != nil {
return fmt.Errorf("invite %s: %w", invitee, err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrAlreadyInvited
}
return tx.Commit()
}
// inviteCutoff is the age past which an invite no longer counts as pending.
func inviteCutoff() time.Time { return time.Now().UTC().Add(-expeditionInviteTTL) }
// latestInviteFor returns the freshest unanswered invite addressed to a player,
// or ErrInviteNotFound.
//
// A player may hold invites from several leaders at once; `!expedition accept`
// takes the most recent, which is the one whose DM is still on their screen. The
// first accept wins the player — joinParty's assertNotAdventuring refuses the
// rest — so there is nothing to reconcile.
func latestInviteFor(userID id.UserID) (*ExpeditionInvite, error) {
var inv ExpeditionInvite
err := db.Get().QueryRow(`
SELECT expedition_id, user_id, invited_by, invited_at
FROM expedition_invite
WHERE user_id = ? AND invited_at > ?
ORDER BY invited_at DESC
LIMIT 1`, string(userID), inviteCutoff()).Scan(
&inv.ExpeditionID, &inv.UserID, &inv.InvitedBy, &inv.InvitedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInviteNotFound
}
if err != nil {
return nil, err
}
return &inv, nil
}
// pendingInvites lists an expedition's unanswered invites, for the roster view.
func pendingInvites(expeditionID string) ([]ExpeditionInvite, error) {
rows, err := db.Get().Query(`
SELECT expedition_id, user_id, invited_by, invited_at
FROM expedition_invite
WHERE expedition_id = ? AND invited_at > ?
ORDER BY invited_at ASC`, expeditionID, inviteCutoff())
if err != nil {
return nil, err
}
defer rows.Close()
var out []ExpeditionInvite
for rows.Next() {
var inv ExpeditionInvite
if err := rows.Scan(&inv.ExpeditionID, &inv.UserID, &inv.InvitedBy, &inv.InvitedAt); err != nil {
return nil, err
}
out = append(out, inv)
}
return out, rows.Err()
}
// clearInvite removes one answered invite.
func clearInvite(expeditionID string, userID id.UserID) error {
_, err := db.Get().Exec(
`DELETE FROM expedition_invite WHERE expedition_id = ? AND user_id = ?`,
expeditionID, string(userID))
return err
}
// clearExpeditionInvites drops every invite an expedition ever sent. Called
// beside disbandParty when the run reaches a terminal status.
func clearExpeditionInvites(expeditionID string) error {
_, err := db.Get().Exec(
`DELETE FROM expedition_invite WHERE expedition_id = ?`, expeditionID)
return err
}
// purgeExpiredInvites deletes invites nobody answered. Every read already
// filters on the TTL, so this reclaims rows rather than enforcing a rule; it
// rides the existing one-minute eventTicker (D4: no net-new ticker).
func purgeExpiredInvites() {
if _, err := db.Get().Exec(
`DELETE FROM expedition_invite WHERE invited_at <= ?`, inviteCutoff()); err != nil {
slog.Warn("expedition: purge expired invites", "err", err)
}
}

View File

@@ -0,0 +1,265 @@
package plugin
import (
"errors"
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func TestInvite_SeatsLeaderAndRecordsTheAsk(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
invitee := id.UserID("@friend:example.org")
seedExpedition(t, "exp-1", leader, "active")
if err := inviteToParty("exp-1", invitee, leader); err != nil {
t.Fatal(err)
}
// The leader is seated even though nobody has accepted — a roster whose
// leader is missing drops them from every fan-out.
members, err := partyMembers("exp-1")
if err != nil {
t.Fatal(err)
}
if len(members) != 1 || !members[0].IsLeader() {
t.Fatalf("roster = %v, want the leader alone", members)
}
inv, err := latestInviteFor(invitee)
if err != nil {
t.Fatal(err)
}
if inv.ExpeditionID != "exp-1" || inv.InvitedBy != string(leader) {
t.Errorf("invite = %+v", inv)
}
}
func TestInvite_RefusesADuplicateAsk(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
invitee := id.UserID("@friend:example.org")
seedExpedition(t, "exp-1", leader, "active")
if err := inviteToParty("exp-1", invitee, leader); err != nil {
t.Fatal(err)
}
if err := inviteToParty("exp-1", invitee, leader); !errors.Is(err, ErrAlreadyInvited) {
t.Errorf("second invite err = %v, want ErrAlreadyInvited", err)
}
}
// Outstanding invites count against the ceiling. Otherwise a leader asks four
// people, three accept, and the roster overflows expeditionPartyMax.
func TestInvite_PendingInvitesCountAgainstTheCeiling(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
seedExpedition(t, "exp-1", leader, "active")
// Leader + 2 invites == expeditionPartyMax (3).
for _, u := range []id.UserID{"@a:example.org", "@b:example.org"} {
if err := inviteToParty("exp-1", u, leader); err != nil {
t.Fatalf("invite %s: %v", u, err)
}
}
if err := inviteToParty("exp-1", "@c:example.org", leader); !errors.Is(err, ErrPartyFull) {
t.Errorf("third invite err = %v, want ErrPartyFull", err)
}
}
func TestInvite_RefusesAPlayerWhoIsAlreadyAdventuring(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
busy := id.UserID("@busy:example.org")
seedExpedition(t, "exp-1", leader, "active")
seedExpedition(t, "exp-2", busy, "active")
if err := inviteToParty("exp-1", busy, leader); !errors.Is(err, ErrPlayerBusyElsewhere) {
t.Errorf("invite err = %v, want ErrPlayerBusyElsewhere", err)
}
}
func TestInvite_ExpiresPastTTL(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
invitee := id.UserID("@friend:example.org")
seedExpedition(t, "exp-1", leader, "active")
if err := inviteToParty("exp-1", invitee, leader); err != nil {
t.Fatal(err)
}
stale := time.Now().UTC().Add(-2 * expeditionInviteTTL)
if _, err := db.Get().Exec(
`UPDATE expedition_invite SET invited_at = ? WHERE user_id = ?`, stale, string(invitee)); err != nil {
t.Fatal(err)
}
if _, err := latestInviteFor(invitee); !errors.Is(err, ErrInviteNotFound) {
t.Errorf("stale invite err = %v, want ErrInviteNotFound", err)
}
// And the seat it was holding is free again.
if err := inviteToParty("exp-1", "@a:example.org", leader); err != nil {
t.Errorf("expired invite still holds a seat: %v", err)
}
}
func TestPurgeExpiredInvites_ReclaimsOnlyStaleRows(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
seedExpedition(t, "exp-1", leader, "active")
for _, u := range []id.UserID{"@fresh:example.org", "@stale:example.org"} {
if err := inviteToParty("exp-1", u, leader); err != nil {
t.Fatal(err)
}
}
if _, err := db.Get().Exec(`UPDATE expedition_invite SET invited_at = ? WHERE user_id = ?`,
time.Now().UTC().Add(-2*expeditionInviteTTL), "@stale:example.org"); err != nil {
t.Fatal(err)
}
purgeExpiredInvites()
var n int
if err := db.Get().QueryRow(`SELECT COUNT(*) FROM expedition_invite`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("invite rows = %d, want 1 (the fresh one)", n)
}
}
// ── the invite window ────────────────────────────────────────────────────────
func TestInviteWindowOpen(t *testing.T) {
for _, tc := range []struct {
name string
e Expedition
want bool
}{
{"day 1, active", Expedition{Status: ExpeditionStatusActive, CurrentDay: 1}, true},
{"day 2", Expedition{Status: ExpeditionStatusActive, CurrentDay: 2}, false},
{"boss down", Expedition{Status: ExpeditionStatusActive, CurrentDay: 1, BossDefeated: true}, false},
{"extracting", Expedition{Status: ExpeditionStatusExtracting, CurrentDay: 1}, false},
} {
t.Run(tc.name, func(t *testing.T) {
if got := inviteWindowOpen(&tc.e); got != tc.want {
t.Errorf("inviteWindowOpen = %v, want %v", got, tc.want)
}
})
}
}
// ── autopilot suppression ────────────────────────────────────────────────────
// The invite window is 30 minutes of autopilot grace; an unanswered invite has
// to hold the expedition still, or the leader walks off without their friend.
func TestAutoRun_PendingInviteHoldsTheExpedition(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
seedExpedition(t, "exp-1", leader, "active")
// Age the expedition past autoRunMinExpeditionAge so only the invite can
// be what holds it.
old := time.Now().UTC().Add(-2 * time.Hour)
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET start_date = ? WHERE expedition_id = 'exp-1'`, old); err != nil {
t.Fatal(err)
}
runCutoff := time.Now().UTC()
ageCutoff := time.Now().UTC().Add(-autoRunMinExpeditionAge)
ids, err := loadExpeditionsForAutoRun(runCutoff, ageCutoff)
if err != nil {
t.Fatal(err)
}
if len(ids) != 1 {
t.Fatalf("without an invite, autorun sees %v; want [exp-1]", ids)
}
if err := inviteToParty("exp-1", "@friend:example.org", leader); err != nil {
t.Fatal(err)
}
ids, err = loadExpeditionsForAutoRun(runCutoff, ageCutoff)
if err != nil {
t.Fatal(err)
}
if len(ids) != 0 {
t.Errorf("autopilot walked an expedition with a pending invite: %v", ids)
}
}
// The hold is bounded: a forgotten invite must not freeze an expedition forever.
func TestAutoRun_ExpiredInviteReleasesTheExpedition(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
seedExpedition(t, "exp-1", leader, "active")
old := time.Now().UTC().Add(-4 * time.Hour)
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET start_date = ? WHERE expedition_id = 'exp-1'`, old); err != nil {
t.Fatal(err)
}
if err := inviteToParty("exp-1", "@friend:example.org", leader); err != nil {
t.Fatal(err)
}
if _, err := db.Get().Exec(`UPDATE expedition_invite SET invited_at = ?`,
time.Now().UTC().Add(-2*expeditionInviteTTL)); err != nil {
t.Fatal(err)
}
ids, err := loadExpeditionsForAutoRun(time.Now().UTC(), time.Now().UTC().Add(-autoRunMinExpeditionAge))
if err != nil {
t.Fatal(err)
}
if len(ids) != 1 {
t.Errorf("expired invite still pins the autopilot: %v", ids)
}
}
// ── terminal cleanup ─────────────────────────────────────────────────────────
// An invite outliving its expedition would let someone accept onto a corpse.
func TestReleaseParty_ClearsPendingInvites(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
invitee := id.UserID("@friend:example.org")
seedExpedition(t, "exp-1", leader, "active")
if err := inviteToParty("exp-1", invitee, leader); err != nil {
t.Fatal(err)
}
if err := completeExpedition("exp-1", ExpeditionStatusComplete); err != nil {
t.Fatal(err)
}
if _, err := latestInviteFor(invitee); !errors.Is(err, ErrInviteNotFound) {
t.Errorf("invite survived expedition completion: %v", err)
}
}
// ── the supply pool ──────────────────────────────────────────────────────────
// Both Current and Max rise: supplyDepletion reads the ratio, and a member
// arriving with a full pack must not read as the party suddenly starving.
func TestAddSupplyPurchase_PoolsBothCurrentAndMax(t *testing.T) {
base := makeSupplies(ZoneTier(1), SupplyPurchase{StandardPacks: 2})
before := supplyDepletion(base)
joined := addSupplyPurchase(base, SupplyPurchase{StandardPacks: 2})
if joined.Current != base.Current*2 || joined.Max != base.Max*2 {
t.Errorf("pooled = %.1f/%.1f, want %.1f/%.1f",
joined.Current, joined.Max, base.Current*2, base.Max*2)
}
if joined.PacksStandard != 4 {
t.Errorf("PacksStandard = %d, want 4", joined.PacksStandard)
}
if got := supplyDepletion(joined); got != before {
t.Errorf("depletion state moved on join: %v -> %v", before, got)
}
// The burn rate is the zone's, not the party's — P6c scales it by size.
if joined.DailyBurn != base.DailyBurn {
t.Errorf("DailyBurn = %v, want %v", joined.DailyBurn, base.DailyBurn)
}
}

View File

@@ -0,0 +1,246 @@
package plugin
import (
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// N3/P6d — the player-facing `getActive*` reads, rewired.
//
// SendDM is a no-op without a Matrix client, so these tests can only observe the
// state a command path left behind. That covers the cases that carry state:
//
// - reads that were blind to a member and let them adventure twice
// (`!zone enter`, `!expedition start`, `!sell`), including across the
// `extracting` limbo where the seat outlives the expedition's 'active' status;
// - reads that write, and so must not write on a member's behalf
// (`!resources` seed-persists the leader's whole region_state blob;
// applyMoodDecayIfStale writes an absolute gm_mood);
// - the one read whose rewire deliberately does move shared state — `!zone
// taunt` swings the party's DM mood, safe only because that write is an
// atomic delta.
//
// The remaining rewires are copy-only — a member used to be told they had no
// expedition, and is now told whose it is — and both spellings leave the database
// exactly as they found it, so there is nothing here to assert. Their seam,
// activeExpeditionFor / activeZoneRunFor / isPartyMember, is pinned in
// expedition_party_resolve_test.go.
// seatedMember stands up a leader with an active expedition and zone run, plus
// a seated member holding a real character sheet. Returns both user ids.
func seatedMember(t *testing.T, tag string) (leader, member id.UserID) {
t.Helper()
leader = id.UserID("@lead-" + tag + ":example.org")
member = id.UserID("@member-" + tag + ":example.org")
seedExpedition(t, "exp-"+tag, leader, "active")
seedZoneRun(t, "run-exp-"+tag, leader)
if err := joinParty("exp-"+tag, member); err != nil {
t.Fatal(err)
}
zoneCmdTestCharacter(t, leader, 1)
zoneCmdTestCharacter(t, member, 1)
return leader, member
}
// startZoneRun only refuses a player who owns an active run, and a member owns
// none — so before P6d a member could `!zone enter` a private dungeon while
// riding the leader's. That run would then win every activeZoneRunFor lookup
// they made, quietly cutting them out of the party's state.
func TestZoneCmdEnter_SeatedMemberCannotOpenTheirOwnRun(t *testing.T) {
setupEmptyTestDB(t)
_, member := seatedMember(t, "enter")
p := &AdventurePlugin{}
if err := p.handleDnDZoneCmd(MessageContext{Sender: member}, "enter goblin_warrens"); err != nil {
t.Fatalf("enter: %v", err)
}
if run, err := getActiveZoneRun(member); err != nil || run != nil {
t.Fatalf("member opened their own run %v (err %v); they are seated in a party", run, err)
}
}
// The same hole at the expedition seam: getActiveExpedition is blind to a
// member, so the "already on expedition" guard waved them through.
func TestExpeditionCmdStart_SeatedMemberIsRefused(t *testing.T) {
setupEmptyTestDB(t)
_, member := seatedMember(t, "start")
// Fund them: a member refused for want of coins would pass this test for
// the wrong reason.
euro := &EuroPlugin{}
euro.ensureBalance(member)
euro.Credit(member, 500, "party reads test")
p := &AdventurePlugin{euro: euro}
c, err := LoadDnDCharacter(member)
if err != nil {
t.Fatal(err)
}
if err := p.expeditionCmdStart(MessageContext{Sender: member}, c, "goblin_warrens 1s"); err != nil {
t.Fatalf("start: %v", err)
}
if own, err := getActiveExpedition(member); err != nil || own != nil {
t.Fatalf("member started their own expedition %v (err %v) while seated in a party", own, err)
}
}
// A member is mid-expedition even though they own no expedition row, so Thom
// Krooke must turn them away like anyone else standing in a dungeon.
func TestSellCmd_SeatedMemberIsRefusedMidExpedition(t *testing.T) {
setupEmptyTestDB(t)
_, member := seatedMember(t, "sell")
if err := addAdvInventoryItem(member, AdvItem{
Name: "goblin ear", Type: "material", Tier: 1, Value: 5,
}); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
if err := p.handleResourceSellCmd(MessageContext{Sender: member}, "all"); err != nil {
t.Fatalf("sell: %v", err)
}
items, err := loadAdvInventory(member)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 {
t.Fatalf("inventory holds %d items, want the 1 seeded; a seated member sold mid-expedition", len(items))
}
}
// `!resources` looks like a pure read, but it seed-persists harvest nodes — and
// saveHarvestNodes rewrites the leader's whole region_state blob (a last-write-
// wins UPDATE). A member holds only their own lock, so their snapshot must never
// be written back over the leader's walk. loadHarvestNodes re-derives the same
// nodes from seedRoomNodes, so the member loses nothing by not persisting.
func TestResourcesCmd_MemberDoesNotRewriteTheLeadersRegionState(t *testing.T) {
setupEmptyTestDB(t)
leader, member := seatedMember(t, "res")
// A sentinel only the leader's walk could have written. If the member's
// !resources persists its snapshot, this is clobbered by the harvest table.
sentinel := `{"party_only_marker":"survive"}`
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET region_state = ? WHERE user_id = ?`,
sentinel, string(leader),
); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
if err := p.handleResourcesCmd(MessageContext{Sender: member}); err != nil {
t.Fatalf("resources: %v", err)
}
var got string
if err := db.Get().QueryRow(
`SELECT region_state FROM dnd_expedition WHERE user_id = ?`, string(leader),
).Scan(&got); err != nil {
t.Fatal(err)
}
if got != sentinel {
t.Errorf("member's !resources rewrote the leader's region_state:\n got %s\nwant %s", got, sentinel)
}
}
// applyMoodDecayIfStale writes gm_mood as an absolute value derived from the
// caller's snapshot, so it must refuse a non-owner: a member running it against
// the leader's run holds the wrong lock and would drop a concurrent update.
func TestApplyMoodDecayIfStale_RefusesANonOwner(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@decay:example.org")
seedZoneRun(t, "run-decay", owner)
run, err := getActiveZoneRun(owner)
if err != nil || run == nil {
t.Fatalf("run: %v, %v", run, err)
}
// Drag the run far enough into the past that passive decay has something
// to correct, then hand it to a non-owner.
run.LastActionAt = time.Now().UTC().Add(-72 * time.Hour)
run.DMMood = 90
if err := applyMoodDecayIfStale(run, false); err != nil {
t.Fatal(err)
}
if run.DMMood != 90 {
t.Errorf("non-owner decay mutated the snapshot: mood = %d, want 90", run.DMMood)
}
fresh, err := getZoneRun("run-decay")
if err != nil || fresh == nil {
t.Fatalf("reload: %v, %v", fresh, err)
}
if fresh.DMMood != 50 {
t.Errorf("non-owner decay wrote gm_mood = %d; the row should be untouched at its 50 default", fresh.DMMood)
}
// The owner still gets the correction — the guard narrows who writes, not what.
if err := applyMoodDecayIfStale(run, true); err != nil {
t.Fatal(err)
}
if run.DMMood == 90 {
t.Error("owner decay did not apply; the guard should only exclude non-owners")
}
}
// `extracting` is a seven-day resumable limbo: releaseParty is deliberately NOT
// called, so the roster survives and `!resume` brings the party back. A member is
// still seated for that whole window, and a guard keyed on status='active' would
// wave them into a private run that then wins every activeZoneRunFor lookup once
// the leader resumes.
func TestSeatedGuards_HoldThroughTheExtractingWindow(t *testing.T) {
setupEmptyTestDB(t)
leader, member := seatedMember(t, "extract")
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET status = 'extracting' WHERE user_id = ?`, string(leader),
); err != nil {
t.Fatal(err)
}
// activeExpeditionFor goes blind here — that is the trap seatedExpeditionFor exists for.
if exp, _, err := activeExpeditionFor(member); err != nil || exp != nil {
t.Fatalf("activeExpeditionFor during extracting = %v, %v; want nil (status filter)", exp, err)
}
seated, err := seatedExpeditionFor(member)
if err != nil || seated == nil {
t.Fatalf("seatedExpeditionFor during extracting = %v, %v; the seat outlives the status", seated, err)
}
p := &AdventurePlugin{}
if err := p.handleDnDZoneCmd(MessageContext{Sender: member}, "enter goblin_warrens"); err != nil {
t.Fatalf("enter: %v", err)
}
if run, err := getActiveZoneRun(member); err != nil || run != nil {
t.Fatalf("member opened a private run %v (err %v) while seated on an extracting party", run, err)
}
}
// Mood belongs to the run, not to whoever typed at TwinBee. A member's taunt
// moves the gauge the whole party is walking under — before P6d it moved
// nothing, because the member resolved to no run at all.
func TestZoneMoodInteraction_MemberMovesThePartyMood(t *testing.T) {
setupEmptyTestDB(t)
leader, member := seatedMember(t, "mood")
before, err := getActiveZoneRun(leader)
if err != nil || before == nil {
t.Fatalf("leader's run: %v, %v", before, err)
}
p := &AdventurePlugin{}
if err := p.handleDnDZoneCmd(MessageContext{Sender: member}, "taunt"); err != nil {
t.Fatalf("taunt: %v", err)
}
after, err := getActiveZoneRun(leader)
if err != nil || after == nil {
t.Fatalf("leader's run after taunt: %v, %v", after, err)
}
if after.DMMood >= before.DMMood {
t.Errorf("DM mood %d → %d; a member's taunt should have annoyed TwinBee",
before.DMMood, after.DMMood)
}
}

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