11 Commits

Author SHA1 Message Date
prosolis
d1c067452e Review fixes: align party enemy-HP scaling in the !fight entry path
The P8 diff scaled the enemy's max HP ×1.15 for parties at persist and
per-turn rebuild, but the !fight command's own template stayed unscaled:
the entry banner reported the pre-scale HP, and the opening-round settle
resolved the enemy against the wrong MaxHP ceiling (regen clamp, bloodied
threshold). Mirror the scalar for the banner and align the in-memory
template before the settle. Solo scales by 1.0, so it is untouched.

Also extract enemyActionPlan() so both combat engines share the one
load-bearing action-count computation instead of duplicating it.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 13:02:28 -07:00
prosolis
0d18cea59a N3/P8: scale the enemy's action economy to the party
A party of N used to face one enemy swing a round against a single seat, so
each member absorbed ~1/N² of the solo incoming and cleared 100% of every T5
cell. The enemy now takes enemyActionsThisRound() attack-actions, each
re-targeted at a fresh standing seat, in both combat engines:

  - auto-resolve (simulatePartyRound): enemyRoundSwings loops the actions,
    re-rolling each target's pet procs.
  - turn engine (stepEnemyTurn): enemyAttackAction resolves one full SRD
    multiattack per action; step() no longer blanket-stamps the enemy turn to
    one seat, since a party's enemy now hits several.

The action count is a fractional expectation realised as a per-round coin-flip
(2.4 for a duo, 2N-1 for N>=3), because the integer lever is too coarse at small
N -- against a duo, 2 actions is a ~91% faceroll and 3 a ~45% grinder, with
nothing between. A light party-only enemy HP scalar (x1.15) trims the martial
ceiling that action count alone leaves at 100%.

Solo is exempt on both levers (1 action, no RNG draw; x1.0 HP), so
TestCombatCharacterization is byte-identical and the d8prereq corpus still
compares. Sim band (party of 3, T5, HP scaled): fighter 70->90%, cleric-led
27->72% -- monotonic by party size, no composition worse than soloing.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 12:44:19 -07:00
prosolis
88c5fcdf2f Review follow-ups: harden the extraction guard, fix Misty/concentration ordering
Applying /code-review high findings on the review-follow-up stack:

- expeditionCmdStart: the resumable-extraction guard swallowed a partySize
  error and fell open, starting a new expedition on top of a still-seated
  party — the exact orphaning it exists to prevent. Now checks
  extractionLapsed first (no DB call on the reap path) and treats a
  roster-read error as "assume occupied → refuse".
- Lapsed reap on !expedition start silently unseated members. Extracted a
  shared reapLapsedExtraction helper (reap + notify the roster) and routed
  both the hourly sweeper and the start-path reap through it.
- stepRoundEnd: moved Misty's crowd/heal seat-loop after the concentration
  tick so a caster whose lingering aura would kill the enemy that round wins
  before the end-of-round crowd swing, honoring the concentration block's
  "a lethal pulse settles the fight" intent.
- Promoted the misty_heal event-log scan to a shared hasAction helper.
- Renamed the new sweep test off the dnd_ prefix.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 11:07:21 -07:00
prosolis
d5fecf45d8 Review follow-up: a test seam for outbound messages
SendDM/SendMessage went straight through Base to the live client with no
fake, so every handler whose only observable output is a DM stopped below
the test boundary — which is how the party close-out bug (fix #1) and the
member soft-lock (fix #2) survived a thorough suite: nothing could see what
was, or wasn't, sent.

Add a MessageSink interface and a Base.Sink field. When non-nil it captures
every outbound message — both the DM route (SendDM/SendDMID) and the room
route (SendMessage/SendMessageID, which is what the arena announcement uses)
— and the client is never touched. Nil in production, so behaviour is
unchanged; one seam, no call-site churn, since all 765 send sites are
downstream of these four methods.

Tests (message_sink_test.go): a captureSink double + installSink helper;
TestMessageSink_CapturesDMAndRoom proving both routes divert with the right
target/text and that the *ID variants report an id back; and
TestExpeditionCmdLeave_DMsBothEnds, which drives the real fix-#2 handler end
to end and asserts both DMs land — a path that was a silent no-op in a unit
test before. beginCombatTurn's terminal path and the arena rollover are now
reachable the same way.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 10:44:56 -07:00
prosolis
6be7744e81 Review follow-up K: a fourth event anchor for the grind loop
The three A6 presence anchors (expedition Night digest, !sell, arena
cashout) miss a whole playstyle: a player who only mines/forages/fishes
(now automatic, done by walking) and clears single-day dungeons, but
never sells, never enters the arena, and never runs a multi-day
expedition to a Night camp, would roll for zero mid-day events.

Anchor a fourth roll on a foreground single-day zone clear — the climax
DM that player is reading, and one they cannot spam (a clear costs a
full walk). advanceResult.zoneCleared is set only in the full-clear
branch (a mid-zone region clear continues the run and would fire per
region), and the roll lives in zoneCmdAdvance, the foreground path only:
autopilot goes through runAutopilotWalk and party members are refused
earlier by isPartyMember. The per-day slot guard still caps everyone at
one event/day, so the three prior anchors are unmoved.

advEventChanceZoneClear = 0.08 puts a zone-clear-only player at ~1
event/week, matching the fully-engaged player; it is the tuning knob.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 09:12:34 -07:00
prosolis
1f1fbf0251 Review follow-ups L + I: drop dead openParty, unify the menu-index parser
L: openParty had no production callers (invite path uses joinParty -> seatLeader,
which seats the leader the same way but reads the owner off the expedition row).
Removed it; the tests now seat the leader through a seatLeaderFixture that wraps
seatLeader in a transaction.

I: promoted parseTemperIndex to parseMenuIndex and routed resolveMagicEquipReply
and handleMasterworkEquipReply through it, replacing two hand-inlined copies of
the same digit parser. Behaviour-preserving (the parsed flag was redundant with
the idx<0 guard); the retry-on-bad-parse disagreement is left as-is since this
is a pure dedup.

Full plugin suite green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:55:07 -07:00
prosolis
d7a5333048 Review follow-up D: unify solo/party close-out effects
Both the solo (finishCombatSession) and party (finishPartyWin/Loss) close-outs
carried hand-copied lists of the same terminal effects. Item A drifted exactly
there. Hoist the effects into three shared helpers so the lists can't diverge:

- applyOwnerWinEffects: kill record + room threat + boss-defeat threat, once
  through the owner; returns bossOnExpedition.
- grantSeatWinXP: near-death calc + XP grant, per seat.
- endRunOnLoss: mood event (death only) + run/expedition teardown, shared by
  the Lost and Fled paths.

Player-facing text stays divergent (it legitimately differs) and the
roster-size death-on-win rule (item E) is untouched. Pure extract-and-call;
full plugin suite green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:50:15 -07:00
prosolis
91eeee0826 Review follow-up N: Misty's round-end procs fire in turn-based combat
DerivePlayerStats builds MistyHealProc / CrowdRevengeProc onto every turn-based
combatant, but stepRoundEnd had no counterpart to endOfRoundForSeat, so neither
was ever read. The buff (Misty's heal) was silently lost. The debuff (her
crowd's revenge) was an exploit: a player who declined Misty escaped it entirely
by fighting with !attack instead of letting the room auto-resolve -- no
discovery required, just press the button.

Hoisted both procs out of endOfRoundForSeat into shared helpers
(mistyCrowdRevenge, mistyHeal) and a seatEndOfRound hook that runs the pair in
the auto-resolve order. endOfRoundForSeat now calls the helpers; stepRoundEnd
calls seatEndOfRound per seat, after the poison tick so a heal can answer the
round's damage. A one-sided debuff-only hook would have been a second parallel
sibling of the kind deferred item D warns about, so the heal ships with it -- a
player-favourable discovery mechanic, consistent with the lift-trailers stance.

Both helpers short-circuit before st.randFloat() when their proc is unarmed, so
a character with no Misty history draws no dice: the sim corpus and
combat_characterization.golden do not move.

seatCombatResult now reads MistyHealed back off the misty_heal event (as
combat_pet_save always has), so combat_misty_clutch is reachable turn-based.
combat_sniper_kill stays unreachable -- Arina's proc is a pre-combat one-shot
with no round-end seam.

renderAllySeatEvent renders crowd_revenge as unattributed damage: an ally sees
the hit but not Misty's name, keeping the grudge the owner's own discovery.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:44:25 -07:00
prosolis
c34e740008 Review follow-up M: Grim Harvest fires in turn-based combat
The doc's item M claimed all three mage subclass spell hooks were dead on the
turn path because applyMageSubclassSpellHooks had one caller. It has three.
resolveTurnSpell has called it since 5cd343a, so Empowered Evocation and
Overchannel always worked -- they only move mods.SpellPreDamage, which
resolveTurnSpell returns as EnemyDamage.

Grim Harvest was the real defect, with a narrower cause: the hook wrote
mods.GrimHarvestSlot into a local CombatModifiers that resolveTurnSpell
discarded, because turnSpellOutcome had no field to carry it out. A Necromancy
Mage who killed with a spell in a manual fight never healed.

The stash can't ride on fight-start mods the way auto-resolve's does -- the
spell is cast mid-fight and the turn engine rebuilds combatants every round --
so it rides on the casting seat's ActorStatuses, like ArmedAbility. Each
damaging cast overwrites it; snapshotActor carries it across commit().

grimHarvestHeal also scanned for the *first* spell_cast event to ask whether
the spell landed the killing blow. Auto-resolve casts once, pre-combat, so
first == last there. A turn-based mage casts every round, so a non-lethal
opening cantrip vetoed the heal the killing spell had earned. Now scans for the
last spell_cast -- provably identical on the auto-resolve path, so the golden
corpus does not move.

Balance: a caster buff on the manual surface only, and the one the subclass was
written to have. Auto-resolve already paid it out.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:25:56 -07:00
prosolis
a59a544fff Review follow-up C: enforce the extraction resume window
The seven-day window was a promise the code never kept. releaseParty fires
only on a terminal status, and dnd_expedition.go justified skipping it for
'extracting' by asserting the roster gets cleared when the window lapses and
the row flips to 'failed'. Nothing did that: the only extracting -> failed
transition lived inside handleResumeCmd, a lazy expiry that runs only when the
leader personally types !resume.

A leader who quit, forgot, or simply started a different expedition therefore
left the row 'extracting' forever. releaseParty never ran, every member stayed
seated, and assertNotAdventuring kept refusing them a run of their own.

Three holes:

  - No sweeper. sweepLapsedExtractions reaps every row past completed_at + 7d
    through completeExpedition, which releases the roster, and DMs the
    audience. Hourly ticker plus a one-shot at Start() -- a lapse that happened
    while the bot was down is blocking those members now. The audience is read
    before the close-out, since completeExpedition disbands the roster
    expeditionAudience reads. handleResumeCmd's lazy expiry stays, now sharing
    the extractionLapsed predicate.

  - The leader could orphan their own party. !expedition start checked only
    getActiveExpedition, and !resume resolves the newest 'extracting' row, so
    starting fresh on top of one left it unreachable with its roster still
    held. It now refuses when that row has a roster; a solo extraction strands
    nobody, so walking away from one stays normal.

  - The leader had no way out but to pay. !expedition abandon could not see the
    extracted row it owns, so closing it meant buying a !resume first. Both it
    and abandonExpedition now span 'extracting' via ownedLiveExpedition, which
    sorts active rows first so expeditionCmdStart's run-spawn rollback still
    tears down the row it just created. This fixes dnd_setup.go for free: a
    leader who rerolled their character used to strand their whole party.

Note the review item this came from (C) was mis-stated: it claimed members and
leaders disagree during 'extracting' because expeditionForMember filters
status = 'active'. getActiveExpedition filters 'active' too, so they already
agree -- and honestly, since nobody is standing in the dungeon. Widening
expeditionForMember would have made the member the only player who can see a
paused expedition. Not done.

Abandoning now DMs the members, and says the true thing when the party is in
town rather than the dungeon (supplies already spent, loot already banked).

New: dnd_expedition_extract_sweep_test.go, 6 cases.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:14:59 -07:00
prosolis
d76c63be0c Review follow-ups A + B: armed abilities survive the fight, supply pool serialized
A. An armed ability lasted one round of a turn-based fight.

buildZoneCombatants called applyArmedAbility, which applies an ability's mods
*and* clears ArmedAbility and saves the sheet. The turn engine calls that
builder again on every !attack / !cast / !consume, so round 1 fired the ability
and disarmed the character, and every later round rebuilt them with none of its
mods. A Berserker paid stamina for a single round of BerserkerRage /
RageMeleeDmg / PhysicalResistRage / FrenzyDmgBonus. Every entry in
dndActiveAbilities had the same shape. mods.BerserkerRage was not merely unread
at close-out — by then it no longer existed.

Split arming into its two halves:

  consumeArmedAbility(c)          mutates: disarms, saves, returns the id. Once,
                                  at fight start.
  applyAbilityByID(c, id, mods)   pure: no DB write, no disarm. Safe on every
                                  rebuild. (No ability's Apply writes to the
                                  character, so this really is pure.)
  armAbilityForFight(c, mods)     consume + apply, for the auto-resolve callers
                                  that build and fight in one breath.

buildZoneCombatants now takes the already-consumed id and re-applies it. The id
rides on ActorStatuses.ArmedAbility, seeded per seat at fight start, so
partyCombatantsForSession reproduces the ability every rebuild and the close-out
can still see that a rage fired.

The close-out itself: postCombatBookkeeping now carries grantCombatAchievements
+ persistDnDPostCombatSubclass, and all four close-outs route through it —
runDungeonCombat, runZoneCombatRoster, finishCombatSession,
finishPartyCombatSession. It fires on every terminal status, not just a win: a
Berserker who rages and loses is still exhausted, which is what auto-resolve
always did.

Also: buildFightSeats and runZoneCombatRoster consumed the ability before the
checks that could sit a seat out, so a downed member was disarmed for a fight
they never joined. The refusals now run first.

B. Six unlocked read-modify-writes against the shared supply pool.

updateSupplies rewrites supplies_json wholesale, so a caller folding its delta
onto an *Expedition it read earlier discards whatever landed in between.
Handlers run one goroutine per event, so those writers genuinely interleave.

All six now go through withExpeditionSupplies, which takes advExpeditionLock,
re-reads the row, hands the closure the fresh copy and persists what it returns:
nightRolloverBurn (forage + burn in one write), grantTwoWeeksCache,
advanceToNextRegion's transit burn, campPitch, pitchAutopilotCamp, and the
ambient pack-rat drain. expeditionCmdAccept's hand-rolled lock folds onto the
same helper. expedition_sim.go is left alone: single-threaded, takes no locks.

Known consequence, for the balance track: trySimAutoArm used to live inside the
rebuild, so a simulated Fighter (second_wind) or Cleric (healing_word) re-armed
and re-spent a resource every round of every elite/boss fight. expedition-sim
drives those through the turn engine, so every prior expedition-sim corpus
overstates those two classes. Re-baseline after this, not before.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 08:01:51 -07:00
44 changed files with 2280 additions and 381 deletions

View File

@@ -270,9 +270,13 @@ func (p *AdventurePlugin) Init() error {
go p.expeditionRecapTicker()
go p.expeditionAmbientTicker()
go p.expeditionAutoRunTicker()
go p.expeditionExtractionSweepTicker()
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
p.arenaCleanupStaleRuns()
// Extractions that lapsed while the bot was down: the roster they hold is
// blocking those members right now, not an hour from now.
p.sweepLapsedExtractions(time.Now().UTC())
return nil
}

View File

@@ -32,10 +32,23 @@ type advActiveEvent struct {
//
// The per-anchor chances below put a player who hits all three at ~1 event
// per week; see TestAnchoredEventWeeklyRate.
//
// K — the three social/economic anchors miss the pure grind loop: a player who
// only mines/forages/fishes (now automatic, done by walking) and clears
// single-day dungeons, but never sells, never enters the arena, and never runs
// a multi-day expedition to a Night camp, would roll for zero events. The
// fourth anchor, ZoneClear, fires on a foreground single-day zone clear — the
// climax DM that player *is* reading — so they get the same rough cadence.
// Autopilot walks and mid-zone region clears deliberately do not fire it (see
// zoneCmdAdvance / the full-clear branch of advanceOnceWithOpts). The per-day
// slot still caps everyone at one event, so a player who happens to hit two
// anchors in a day can't double up. This rate is the tuning knob for item K —
// bump it if telemetry shows the grind-loop player still sees too few.
const (
advEventChanceDigest = 0.08
advEventChanceSell = 0.05
advEventChanceArena = 0.05
advEventChanceDigest = 0.08
advEventChanceSell = 0.05
advEventChanceArena = 0.05
advEventChanceZoneClear = 0.08
)
var (

View File

@@ -44,6 +44,43 @@ func TestAnchoredEventWeeklyRate(t *testing.T) {
}
}
// TestZoneClearAnchorRate pins item K's fourth anchor for the population the
// other three miss: a grind-loop player who never sells, never enters the
// arena, and never runs a multi-day expedition, but clears single-day zones.
// Modelled at two foreground clears per active day (a plausible dungeon
// session) they should land in the same ~1 event/week band as the fully-
// engaged player — that is the whole point of adding the anchor. The clears/day
// assumption and advEventChanceZoneClear are the two tuning knobs; see the K
// note in adventure_events.go.
func TestZoneClearAnchorRate(t *testing.T) {
const clearsPerDay = 2
const weeks = 20000
rng := rand.New(rand.NewPCG(0x5EED, 0x2C)) // "ZC"
events := 0
for range weeks {
for range 7 {
firedToday := false
for range clearsPerDay {
if firedToday {
break // one event per player per UTC day
}
if rng.Float64() < advEventChanceZoneClear {
firedToday = true
}
}
if firedToday {
events++
}
}
}
perWeek := float64(events) / weeks
if perWeek < 0.8 || perWeek > 1.5 {
t.Errorf("zone-clear anchor = %.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")

View File

@@ -435,18 +435,8 @@ func (p *AdventurePlugin) handleMasterworkEquipReply(ctx MessageContext, interac
return p.SendDM(ctx.Sender, "Equip cancelled.")
}
// Parse number
idx := 0
for _, c := range reply {
if c >= '0' && c <= '9' {
idx = idx*10 + int(c-'0')
} else {
break
}
}
idx-- // 1-indexed to 0-indexed
if idx < 0 || idx >= len(data.Items) {
idx, ok := parseMenuIndex(reply, len(data.Items))
if !ok {
return p.SendDM(ctx.Sender, "Invalid selection. Reply with a number from the list, or \"cancel\".")
}

View File

@@ -140,8 +140,12 @@ func findTemperMaterial(items []AdvItem) (AdvItem, bool) {
return AdvItem{}, false
}
// parseTemperIndex reads a leading 1-indexed number and bounds-checks it.
func parseTemperIndex(reply string, n int) (int, bool) {
// parseMenuIndex reads a leading 1-indexed number off a menu reply and returns
// the bounds-checked 0-indexed position. The single parser behind every "reply
// with a number from the list" prompt — temper, magic-item equip, masterwork
// equip. Leading whitespace and any trailing text after the digits are ignored;
// a reply with no leading digit, or one outside [1, n], returns ok=false.
func parseMenuIndex(reply string, n int) (int, bool) {
idx, parsed := 0, false
for _, c := range strings.TrimSpace(reply) {
if c < '0' || c > '9' {
@@ -214,7 +218,7 @@ func (p *AdventurePlugin) resolveTemperPick(ctx MessageContext, interaction *adv
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))
idx, ok := parseMenuIndex(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\".")

View File

@@ -346,9 +346,9 @@ func TestParseTemperIndex(t *testing.T) {
{"cancel", 3, 0, false},
}
for _, tc := range tests {
got, ok := parseTemperIndex(tc.in, tc.n)
got, ok := parseMenuIndex(tc.in, tc.n)
if ok != tc.wantOK || (ok && got != tc.want) {
t.Errorf("parseTemperIndex(%q, %d) = (%d, %v), want (%d, %v)",
t.Errorf("parseMenuIndex(%q, %d) = (%d, %v), want (%d, %v)",
tc.in, tc.n, got, ok, tc.want, tc.wantOK)
}
}

View File

@@ -0,0 +1,294 @@
package plugin
import (
"testing"
"maunium.net/go/mautrix/id"
)
// An armed ability is consumed once, at fight start, and its id is parked on the
// seat. Everything here defends that split.
//
// Before it existed, buildZoneCombatants consumed the ability itself — and the
// turn-based engine calls that builder again on every !attack. So a Berserker
// raged on round 1, the builder cleared their armed flag and saved, and round 2
// rebuilt them with no rage at all. They paid stamina for one round of a buff
// that is supposed to span the fight, and the close-out could not see the rage
// it was supposed to charge exhaustion for.
// ragingBerserker is a Fighter who has already `!arm`ed rage.
func ragingBerserker(t *testing.T, uid id.UserID) *DnDCharacter {
t.Helper()
if err := createAdvCharacter(uid, string(uid)); err != nil {
t.Fatal(err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Subclass: SubclassBerserker, Level: 5,
STR: 16, DEX: 12, CON: 14, INT: 10, WIS: 10, CHA: 10,
HPMax: 40, HPCurrent: 40, ArmorClass: 15,
ArmedAbility: "rage",
}
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
return c
}
// ── the split ────────────────────────────────────────────────────────────────
// consumeArmedAbility disarms and reports; applyAbilityByID applies and does not
// disarm. Calling the second one twice must be indistinguishable from once.
func TestApplyAbilityByID_IsPureAndRepeatable(t *testing.T) {
setupEmptyTestDB(t)
c := ragingBerserker(t, "@pure:example.org")
armed := consumeArmedAbility(c)
if armed != "rage" {
t.Fatalf("consumeArmedAbility = %q, want rage", armed)
}
if c.ArmedAbility != "" {
t.Errorf("character still armed after consume: %q", c.ArmedAbility)
}
if again := consumeArmedAbility(c); again != "" {
t.Errorf("second consume returned %q, want \"\" — the ability is spent", again)
}
// Same id, applied to two independent mod sets, yields the same rage.
for i, want := range []bool{true, true} {
var mods CombatModifiers
name, fired := applyAbilityByID(c, armed, &mods)
if !fired || name != "Rage" {
t.Fatalf("apply #%d: fired=%v name=%q", i+1, fired, name)
}
if mods.BerserkerRage != want {
t.Errorf("apply #%d: BerserkerRage = %v, want %v", i+1, mods.BerserkerRage, want)
}
if mods.RageMeleeDmg != 2 || !mods.PhysicalResistRage {
t.Errorf("apply #%d: rage did not carry its full mod set: %+v", i+1, mods)
}
}
// And it never writes back to the sheet.
got, _ := LoadDnDCharacter(c.UserID)
if got.ArmedAbility != "" {
t.Errorf("applyAbilityByID re-armed the sheet: %q", got.ArmedAbility)
}
}
func TestApplyAbilityByID_UnknownAndEmptyAreNoOps(t *testing.T) {
c := &DnDCharacter{Class: ClassFighter}
for _, id := range []string{"", "no_such_ability"} {
var mods CombatModifiers
if _, fired := applyAbilityByID(c, id, &mods); fired {
t.Errorf("applyAbilityByID(%q) fired", id)
}
if mods.BerserkerRage {
t.Errorf("applyAbilityByID(%q) set rage", id)
}
}
}
// ── the fight-start seat ─────────────────────────────────────────────────────
// The seat carries the id forward, and the sheet is disarmed exactly once.
func TestBuildFightSeats_ConsumesTheAbilityOnceAndCarriesItOnTheSeat(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@berserk:example.org")
ragingBerserker(t, uid)
seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats(
uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0)
if refusal != "" {
t.Fatalf("fight refused: %s", refusal)
}
if len(seats) != 1 {
t.Fatalf("seats = %d, want 1", len(seats))
}
if seats[0].ArmedAbility != "rage" {
t.Errorf("seat.ArmedAbility = %q, want rage", seats[0].ArmedAbility)
}
if !seats[0].Mods.BerserkerRage {
t.Error("seat built without rage in its mods")
}
got, _ := LoadDnDCharacter(uid)
if got.ArmedAbility != "" {
t.Errorf("sheet still armed after fight start: %q", got.ArmedAbility)
}
}
// The regression itself: rebuilding the combatant — what every !attack does —
// must reproduce the rage from the persisted id, not from the (now cleared)
// sheet. Two rebuilds in a row, both raging.
func TestBuildZoneCombatants_RebuildKeepsTheRageForTheWholeFight(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@rebuild:example.org")
ragingBerserker(t, uid)
p := &AdventurePlugin{}
seats, _, _, refusal := p.buildFightSeats(uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0)
if refusal != "" {
t.Fatalf("fight refused: %s", refusal)
}
armed := seats[0].ArmedAbility
for round := 2; round <= 3; round++ {
player, _, _, err := p.buildZoneCombatants(uid, dndBestiary["goblin"], 1, 0, armed)
if err != nil {
t.Fatalf("round %d rebuild: %v", round, err)
}
if !player.Mods.BerserkerRage {
t.Errorf("round %d: rage evaporated on rebuild", round)
}
if player.Mods.RageMeleeDmg != 2 {
t.Errorf("round %d: RageMeleeDmg = %d, want 2", round, player.Mods.RageMeleeDmg)
}
}
// A seat that armed nothing rebuilds without rage — the id is the only source.
player, _, _, err := p.buildZoneCombatants(uid, dndBestiary["goblin"], 1, 0, "")
if err != nil {
t.Fatal(err)
}
if player.Mods.BerserkerRage {
t.Error("unarmed rebuild raged anyway")
}
}
// A member who is down sits the fight out. They must not be charged the ability
// they had readied for it — the refusal is checked before the arm.
func TestBuildFightSeats_SatOutMemberKeepsTheirArmedAbility(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
downed := id.UserID("@downed:example.org")
fightTestChar(t, leader, 30)
ragingBerserker(t, downed)
// Drop the member to 0 HP with rage still armed.
c, _ := LoadDnDCharacter(downed)
c.HPCurrent = 0
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats(
leader, []id.UserID{leader, downed}, dndBestiary["goblin"], 1, 0)
if refusal != "" {
t.Fatalf("fight refused: %s", refusal)
}
if len(seats) != 1 || seats[0].UserID != leader {
t.Fatalf("seats = %+v, want the leader alone", seats)
}
got, _ := LoadDnDCharacter(downed)
if got.ArmedAbility != "rage" {
t.Errorf("downed member was disarmed for a fight they never joined: %q", got.ArmedAbility)
}
}
// ── the close-out ────────────────────────────────────────────────────────────
func TestSeatFightStartMods_ReadsTheRageOffTheSeatsStatuses(t *testing.T) {
c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassBerserker, Level: 5}
raging := &CombatSession{}
raging.Statuses.ArmedAbility = "rage"
if !seatFightStartMods(raging, 0, c).BerserkerRage {
t.Error("seat 0 armed rage, mods say otherwise")
}
// Seat 1 reads its own statuses, not the leader's.
party := &CombatSession{Participants: []CombatParticipant{{Seat: 1}}}
party.Statuses.ArmedAbility = "rage"
if seatFightStartMods(party, 1, c).BerserkerRage {
t.Error("a member inherited the leader's rage")
}
}
func TestSeatCombatResult_ReadsWinAndNearDeathOffTheSession(t *testing.T) {
sess := &CombatSession{
Status: CombatStatusWon, Round: 4, PlayerHP: 5, PlayerHPMax: 40, EnemyHP: 0,
TurnLog: []CombatEvent{
{Action: "attack", Seat: 0},
{Action: "use_consumable", Seat: 1},
},
}
got := seatCombatResult(sess, 0)
if !got.PlayerWon {
t.Error("PlayerWon = false on a won session")
}
if !got.NearDeath {
t.Error("5/40 HP is under the 15% near-death line")
}
if len(got.Events) != 1 || got.Events[0].Action != "attack" {
t.Errorf("seat 0 got seat 1's events: %+v", got.Events)
}
sess.PlayerHP = 30
if seatCombatResult(sess, 0).NearDeath {
t.Error("30/40 HP is not near death")
}
sess.Status = CombatStatusLost
sess.PlayerHP = 0
if seatCombatResult(sess, 0).PlayerWon {
t.Error("PlayerWon = true on a lost session")
}
}
// Item A: the manual kill now costs the Berserker their exhaustion, exactly as
// the auto-resolved one always has.
func TestPostCombatBookkeepingForSeat_RageCostsExhaustionOnTheTurnBasedPath(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@exhausted:example.org")
ragingBerserker(t, uid)
sess := &CombatSession{
UserID: string(uid), Status: CombatStatusWon, Round: 3,
PlayerHP: 12, PlayerHPMax: 40,
}
sess.Statuses.ArmedAbility = "rage"
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
got, _ := LoadDnDCharacter(uid)
if got.Exhaustion != 1 {
t.Errorf("Exhaustion = %d after a raging win, want 1", got.Exhaustion)
}
}
// A fight nobody raged in leaves the sheet alone.
func TestPostCombatBookkeepingForSeat_NoRageNoExhaustion(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@calm:example.org")
fightTestChar(t, uid, 30)
sess := &CombatSession{
UserID: string(uid), Status: CombatStatusWon, Round: 2,
PlayerHP: 20, PlayerHPMax: 30,
}
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
got, _ := LoadDnDCharacter(uid)
if got.Exhaustion != 0 {
t.Errorf("Exhaustion = %d with no rage, want 0", got.Exhaustion)
}
}
// Losing while raging still exhausts you — the close-out runs on every terminal
// status, not just the win. This is the half the turn-based path used to skip.
func TestPostCombatBookkeepingForSeat_RageCostsExhaustionOnALoss(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@dead:example.org")
ragingBerserker(t, uid)
sess := &CombatSession{
UserID: string(uid), Status: CombatStatusLost, Round: 6,
PlayerHP: 0, PlayerHPMax: 40,
}
sess.Statuses.ArmedAbility = "rage"
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
got, _ := LoadDnDCharacter(uid)
if got.Exhaustion != 1 {
t.Errorf("Exhaustion = %d after a raging loss, want 1", got.Exhaustion)
}
}

View File

@@ -10,6 +10,30 @@ import (
"maunium.net/go/mautrix/id"
)
// postCombatBookkeeping is the close-out every fight owes its character,
// whatever surface it was fought on: achievements, and the subclass state that
// outlives the fight (Berserker exhaustion, Grim Harvest's kill-heal).
//
// It exists because there are four close-outs — two auto-resolve
// (runDungeonCombat, runZoneCombatRoster) and two turn-based
// (finishCombatSession, finishPartyCombatSession) — and for a long time only
// the auto-resolve pair ran any of this. The same Elite kill therefore paid out
// differently depending on whether the player let it auto-resolve or fought it
// a round at a time. Route all four through here and the divergence cannot
// silently reopen.
//
// raged is whether the character's Berserker rage was active for this fight;
// mods carries the fight-start modifiers Grim Harvest reads. HP persistence is
// NOT done here — the turn-based paths already own their own HP writes.
func (p *AdventurePlugin) postCombatBookkeeping(
userID id.UserID, dndChar *DnDCharacter, raged bool, result CombatResult, mods CombatModifiers,
) {
p.grantCombatAchievements(userID, result)
if err := persistDnDPostCombatSubclass(dndChar, raged, result, mods); err != nil {
slog.Error("dnd: post-combat subclass persist", "user", userID, "err", err)
}
}
// grantCombatAchievements checks combat results for achievement-worthy moments.
func (p *AdventurePlugin) grantCombatAchievements(userID id.UserID, result CombatResult) {
if p.achievements == nil {
@@ -87,7 +111,7 @@ func (p *AdventurePlugin) runDungeonCombat(
applySubclassPassives(&playerStats, &playerMods, dndChar)
applyMagicItemEffects(&playerStats, &playerMods, userID)
trySimAutoArm(dndChar)
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
if firedName, fired := armAbilityForFight(dndChar, &playerMods); fired {
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
}
applyDnDDungeonMonsterLayer(&enemyStats, loc.Tier)
@@ -129,12 +153,8 @@ func (p *AdventurePlugin) runDungeonCombat(
// until a player-driven use command lands.
consumeFiredHealingItems(userID, countHealEventsFired(result))
p.grantCombatAchievements(userID, result)
persistDnDHPAfterCombat(userID, result.PlayerEndHP)
if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage, result, playerMods); err != nil {
slog.Error("dnd: post-combat subclass persist (dungeon)", "user", userID, "err", err)
}
p.postCombatBookkeeping(userID, dndChar, playerMods.BerserkerRage, result, playerMods)
if xp := dungeonCombatXP(result, loc.Tier); xp > 0 {
if _, err := p.grantDnDXP(userID, xp); err != nil {

View File

@@ -101,7 +101,11 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
if refusal != "" {
return p.replyDM(ctx, refusal)
}
enemyHP := enemy.Stats.MaxHP
// The persisted session scales enemy HP for a party (solo scales by 1.0, so
// this is a no-op there); mirror it here so the entry banner and the opening
// round resolve against the same ceiling startPartyCombatSession persisted and
// the rebuilt rounds use.
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats))
// Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded
// per seat onto the session and its participant rows, so they survive the
@@ -130,6 +134,11 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
if sess.IsParty() {
players := seatCombatants(seats)
// Align the in-memory template with the scaled HP persisted above, so the
// opening-round settle resolves the enemy against the same MaxHP the
// rebuilt rounds do (regen clamp, bloodied-ability threshold). The persist
// already happened off the unscaled value, so this does not double-scale.
enemy.Stats.MaxHP = enemyHP
// 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.
@@ -332,6 +341,10 @@ func continueHint(userID id.UserID) string {
// the room's manual combat is done, and a fresh !zone advance clears the room.
func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSession, enemy Combatant) string {
persistDnDHPAfterCombat(userID, sess.PlayerHP)
// Achievements and post-combat subclass state are owed on every terminal
// status, not just a win — a Berserker who rages and loses still comes out
// of it exhausted. The auto-resolve close-outs have always done this.
p.postCombatBookkeepingForSeat(sess, 0)
run, _ := getZoneRun(sess.RunID)
var zone ZoneDefinition
@@ -350,28 +363,12 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
var b strings.Builder
switch sess.Status {
case CombatStatusWon:
recordZoneKillForUser(userID, sess.EnemyID)
applyRoomCombatThreatForUser(userID, elite)
// zoneCombatXP only reads PlayerWon + NearDeath off the result.
nearDeath := sess.PlayerHPMax > 0 && sess.PlayerHP*5 < sess.PlayerHPMax
tier := 1
if run != nil {
tier = int(zone.Tier)
}
if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 {
if _, err := p.grantDnDXP(userID, xp); err != nil {
slog.Error("combat: grantDnDXP turn-based", "user", userID, "err", err)
}
}
bossOnExpedition := false
if !elite {
// §8.1 — zone boss defeat drops expedition threat. Silent no-op
// for standalone zone runs (no active expedition).
if exp, eerr := getActiveExpedition(userID); eerr == nil && exp != nil {
_ = applyBossDefeatThreat(exp.ID)
bossOnExpedition = true
}
}
bossOnExpedition := p.applyOwnerWinEffects(userID, sess.EnemyID, elite)
p.grantSeatWinXP(userID, sess.PlayerHP, sess.PlayerHPMax, monster, tier)
if line := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence); line != "" {
b.WriteString(line + "\n")
}
@@ -395,11 +392,7 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
}
case CombatStatusLost:
if run != nil {
_, _ = applyMoodEvent(sess.RunID, MoodEventPlayerDeath)
}
_ = abandonZoneRun(userID)
forceExtractExpeditionForRunLoss(userID, "combat death")
endRunOnLoss(userID, sess.RunID, true)
markAdventureDead(userID, "zone", zone.Display)
if line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence); line != "" {
b.WriteString(line + "\n")
@@ -410,8 +403,7 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
// Flee = run ends, light penalty: wounds persist (HP already saved),
// but no death timer. Chosen candidate from the migration plan's
// open question on flee outcome.
_ = abandonZoneRun(userID)
forceExtractExpeditionForRunLoss(userID, "combat flee")
endRunOnLoss(userID, sess.RunID, false)
b.WriteString(fmt.Sprintf("🏃 You broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name))
default:
@@ -598,6 +590,18 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
return PlayerAction{}, noop, msg
}
// Park the Necromancy kill-heal stash on the casting seat. The
// auto-resolve path keeps it on the fight-start CombatModifiers, which
// a turn-based fight has nowhere to hold — it rebuilds its combatants
// every round. Only a damaging cast stashes (a miss leaves the slot at
// 0), and each one overwrites the last, so the stash always describes
// the seat's most recent landed spell — which is the only one that can
// have been lethal by the time the close-out reads it.
if out.GrimHarvestSlot > 0 {
as := ct.sess.actorStatusesPtr(seat)
as.GrimHarvestSlot = out.GrimHarvestSlot
as.GrimHarvestNecrotic = out.GrimHarvestNecrotic
}
eff = &turnActionEffect{
Label: out.Desc,
Action: "spell_cast",

View File

@@ -162,9 +162,11 @@ type CombatModifiers struct {
// ArcaneWardHP: flat HP buffer absorbed before player HP. Refilled at the
// start of each combat by Abjuration L5+ (2× Mage level, +prof at L7).
// Persists across rounds within a single combat; not refunded between fights.
// GrimHarvestSlot/Necrotic: snapshot of the queued spell stashed by
// applyPendingCast for the post-combat Grim Harvest hook (Necromancy L5+).
// Heal fires only if the spell event is what dropped the enemy to 0.
// GrimHarvestSlot/Necrotic: snapshot of the damaging spell stashed for the
// post-combat Grim Harvest hook (Necromancy L5+) — by applyPendingCast on
// the auto-resolve path, and by seatFightStartMods reading the seat's
// statuses on the turn-based one. Heal fires only if that spell's event is
// what dropped the enemy to 0.
ArcaneWardHP int
GrimHarvestSlot int
GrimHarvestNecrotic bool

View File

@@ -64,6 +64,12 @@ func simulateParty(players []Combatant, enemy Combatant, phases []CombatPhase) P
}
func simulatePartyWithRNG(players []Combatant, enemy Combatant, phases []CombatPhase, rng *rand.Rand) PartyCombatResult {
// Party-only: bump the enemy's max HP so the fight lasts long enough for the
// scaled action economy to actually threaten each member. Solo is unchanged
// (scale 1.0), so SimulateCombat and the golden do not move.
if len(players) > 1 {
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, len(players))
}
enemyStart := enemy.Stats.MaxHP
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP {
enemyStart = enemy.Stats.StartHP
@@ -289,6 +295,141 @@ func roundInitiative(st *combatState, enemy *Combatant, phase *CombatPhase) []in
return order
}
// enemyActionsThisRound is how many attack-actions the enemy takes this round
// against the seated roster. Solo returns 1 without drawing from the RNG, so both
// combat engines collapse to their pre-party behaviour and the characterization
// golden and d8prereq corpus do not move.
//
// A party's action budget is a *fractional expectation* (partyActionExpectation),
// realised as floor(exp) actions plus one more with probability frac(exp). The
// fraction matters because the action lever is coarse: against a party of two, an
// integer 2 actions is a 100% faceroll and 3 is brutal (~45%), with nothing
// between — a per-round coin-flip for the extra action is the only way to land
// the band in the gap. The single draw is taken only for a party, so the solo RNG
// stream is untouched.
func enemyActionsThisRound(st *combatState) int {
n := len(st.actors)
if n < 2 {
return 1
}
exp := partyActionExpectation(n)
base := int(exp)
if frac := exp - float64(base); frac > 0 && st.randFloat() < frac {
base++
}
return base
}
// partyActionExpectation is the expected number of enemy attack-actions per round
// against a party of n. A single enemy swing (the pre-P8 behaviour) let each
// member absorb ~1/N² of the solo incoming and cleared 100% of T5; the sim band
// landed the martial-leader target only near ~2N actions, and HP alone can't touch
// a martial (fighter stays at 100% even at enemy HP ×1.6). So the enemy's action
// economy is the load-bearing lever, and it is fractional so a two-person party
// lands between soloing and a trio instead of snapping to one of two integers
// (against a duo, an integer 2 actions is a ~91% faceroll and 3 is a ~45% meat
// grinder — 2.4 sits fighter+cleric at ~75%, between solo 70% and trio 90%).
//
// N≥3 follows 2N1, the point that gave fighter ~90% / cleric-led ~72% at
// HP ×1.15. The whole curve is monotonic by party size and never drops a
// composition below its solo clear rate — bringing a friend is never a penalty.
func partyActionExpectation(n int) float64 {
switch {
case n < 2:
return 1
case n == 2:
return 2.4
default:
return float64(2*n - 1)
}
}
// partyEnemyHPScale is the party-only multiplier on the enemy's max HP. Solo
// (roster < 2) returns 1.0, so the solo path — and the characterization golden
// and the d8prereq corpus — is byte-for-byte untouched. With the action economy
// fixed, each member now takes ~1 swing a round, so a longer fight is more
// cumulative exposure per member: HP became a live difficulty lever exactly the
// way P6e predicted it would once the enemy stopped swinging only once.
//
// 1.15 is where the P8 sim sweep landed the band: with 2N1 actions, a party of
// three clears the T5 martial-leader band at ~89% (fighter) / ~67% (cleric-led),
// clearly safer than soloing (70% / 27%) but with real TPKs and member deaths —
// not the 100% faceroll a single enemy swing produced.
func partyEnemyHPScale(rosterSize int) float64 {
if rosterSize < 2 {
return 1.0
}
return 1.15
}
// scaledEnemyMaxHP applies the party HP scalar to a base max-HP with one rounding
// rule, so every call site (the auto-resolve engine, the party session's initial
// persist, and the per-turn rebuild) agrees on the same number.
func scaledEnemyMaxHP(baseMaxHP, rosterSize int) int {
return int(float64(baseMaxHP) * partyEnemyHPScale(rosterSize))
}
// enemyActionPlan is the shared action budget both combat engines resolve an
// enemy turn against: how many attack-actions the enemy takes, and whether the
// first one reuses the round's already-picked target (and its already-rolled
// procs). A cleave/lifesteal ability already spent the first action, so one fewer
// follows and none of them reuse the initial target. Both engines call this so
// the load-bearing count stays in lockstep even though their per-action bodies
// differ.
func enemyActionPlan(st *combatState, abilityDealtDamage bool) (count int, reuseFirst bool) {
count = enemyActionsThisRound(st)
reuseFirst = !abilityDealtDamage
if abilityDealtDamage {
count--
}
return count, reuseFirst
}
// enemyRoundSwings resolves the enemy's attacks for one round of the auto-resolve
// engine. A solo roster takes exactly one swing at its single seat, reusing the
// round's already-rolled target and pet procs, so its RNG stream and event log are
// byte-for-byte the pre-P8 engine's. A party faces enemyActionsThisRound() swings,
// each re-targeted at a random standing seat with that seat's own pet procs, so the
// damage is spread across the roster instead of pinning the round's first target.
//
// abilityDealtDamage means a cleave/lifesteal already spent the enemy's first
// action this round, so one fewer swing follows — for solo that collapses to the
// old "the ability stands in for the attack" skip. Returns true if the fight is
// decided.
func enemyRoundSwings(st *combatState, enemy *Combatant, phase *CombatPhase, seats []CombatResult,
target int, abilityDealtDamage, petWhiff, petDeflect, sporeMiss bool) bool {
swings, reuseFirst := enemyActionPlan(st, abilityDealtDamage)
for k := 0; k < swings; k++ {
tgt := target
sw, sd, sp := petWhiff, petDeflect, sporeMiss
if !(reuseFirst && k == 0) {
// A fresh target for every swing past the first: re-pick among the
// standing seats and roll that seat's own pet procs. This branch never
// runs for a solo roster, so it adds no draws to the solo stream.
var alive bool
if tgt, alive = enemyTargetSeat(st); !alive {
return combatOver(st)
}
st.seat(tgt)
sw = st.c.Mods.PetWhiffProc > 0 && st.randFloat() < st.c.Mods.PetWhiffProc
sd = st.c.Mods.PetDeflectProc > 0 && st.randFloat() < st.c.Mods.PetDeflectProc
if sd {
seats[tgt].PetDeflected = true
}
sp = st.sporeRounds > 0 && st.randFloat() < 0.15
}
st.seat(tgt)
mark := len(st.events)
over := resolveEnemyAttack(st, st.c, enemy, phase, &seats[tgt], sw, sd, sp)
stampEventSeats(st, mark, tgt)
if over && combatOver(st) {
return true
}
}
return false
}
// 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 {
@@ -383,14 +524,7 @@ func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, s
// 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) {
if enemyRoundSwings(st, enemy, phase, seats, target, abilityDealtDamage, petWhiff, petDeflect, sporeMiss) {
return true
}
continue
@@ -448,6 +582,78 @@ func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, s
return false
}
// ── Misty's pair, shared by both engines ─────────────────────────────────────
//
// These two are the only round-end effects the turn engine did not have its own
// copy of. Everything else in endOfRoundForSeat either exists there already (the
// pet, the spiritual weapon — fired after a player action rather than at round
// end), is deliberately absent (the environmental hazard: turnCombatPhase is a
// single flat "Duel" phase with EnvironmentProc at 0), or is replaced by an
// explicit player command (the consumable auto-heal, which is `!consume`).
//
// So they are hoisted rather than re-implemented. A parallel sibling is what let
// the two win close-outs drift apart (deferred item D); this pair is now one
// list of effects with two callers, and cannot.
//
// Neither draws from the RNG unless its proc is armed, so a character with no
// Misty history rolls exactly the dice it rolled before — the sim corpus and
// combat_characterization.golden do not move.
// mistyCrowdRevenge is the debuff for declining Misty: her crowd takes a swing
// at the end of the round. Returns true when it decided the fight — that is,
// when it dropped this character, the death save failed, and nobody else is
// standing. A downed character in a still-live party returns false.
func mistyCrowdRevenge(st *combatState, player *Combatant, phaseName string) bool {
if player.Mods.CrowdRevengeProc <= 0 || st.randFloat() >= player.Mods.CrowdRevengeProc {
return false
}
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",
})
return st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive()
}
// mistyHeal is the buff's payout. It cannot decide the fight, so it returns
// nothing. result may be a scratch value the caller discards (the turn engine's
// is) — the durable record is the misty_heal event on the log, which is what
// seatCombatResult reads to award combat_misty_clutch.
func mistyHeal(st *combatState, player *Combatant, phaseName string, result *CombatResult) {
if player.Mods.MistyHealProc <= 0 || st.randFloat() >= player.Mods.MistyHealProc {
return
}
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",
})
}
// seatEndOfRound runs the round-end effects the turn engine owes one seat, in
// the order endOfRoundForSeat runs them: the crowd's swing, then — if the seat
// survived it — Misty's heal. Returns true when the fight is over.
//
// It exists because the turn engine's stepRoundEnd never ran either one. A
// player carrying Misty's buff lost it by fighting manually; worse, a player
// carrying her debuff *escaped* it by doing the same, which needed no discovery
// to exploit — just press !attack.
func seatEndOfRound(st *combatState, player *Combatant, phaseName string, result *CombatResult) bool {
if over := mistyCrowdRevenge(st, player, phaseName); over {
return true
}
if st.playerHP <= 0 {
return false
}
mistyHeal(st, player, phaseName, result)
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
@@ -470,17 +676,8 @@ func endOfRoundForSeat(st *combatState, phase *CombatPhase, result *CombatResult
}
// 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
}
if over := mistyCrowdRevenge(st, player, phaseName); over {
return true
}
// A character the round just killed stops acting, but the fight goes on if
@@ -517,16 +714,7 @@ func endOfRoundForSeat(st *combatState, phase *CombatPhase, result *CombatResult
}
// 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",
})
}
mistyHeal(st, player, phaseName, result)
// Consumable heal: triggers when the character drops below 60% HP. Fires up
// to HealItemCharges times per fight (1 = legacy one-shot; bosses can

View File

@@ -124,31 +124,89 @@ func TestSimulateParty_DownedMemberDoesNotEndTheFight(t *testing.T) {
}
}
// The enemy swings at one seat per round, not at everyone.
func TestSimulateParty_EnemyStrikesOneSeatPerRound(t *testing.T) {
// P8 scaling constants: solo is exempt on both levers (1 action, ×1.0 HP) so the
// characterization golden and the d8prereq corpus are untouched. A party's action
// budget is a fractional expectation — 2.4 for a duo (so it lands between soloing
// and a trio, where an integer 2 vs 3 has no room), 2N1 for N≥3 — plus ×1.15
// enemy HP.
func TestP8PartyScaling_SoloExemptPartyScaled(t *testing.T) {
if got := partyActionExpectation(1); got != 1 {
t.Fatalf("solo action expectation = %v, want 1", got)
}
if got := partyEnemyHPScale(1); got != 1.0 {
t.Fatalf("solo HP scale = %v, want 1.0", got)
}
if got := scaledEnemyMaxHP(200, 1); got != 200 {
t.Fatalf("solo enemy HP = %d, want 200 (unscaled)", got)
}
if got := partyActionExpectation(2); got != 2.4 {
t.Fatalf("duo action expectation = %v, want 2.4", got)
}
for n := 3; n <= 5; n++ {
if got, want := partyActionExpectation(n), float64(2*n-1); got != want {
t.Fatalf("party of %d: action expectation = %v, want %v", n, got, want)
}
}
if got := partyEnemyHPScale(3); got != 1.15 {
t.Fatalf("party HP scale = %v, want 1.15", got)
}
// int(200*1.15) truncates 229.99… to 229; the 1-HP floor is immaterial.
if got := scaledEnemyMaxHP(200, 3); got != 229 {
t.Fatalf("party enemy HP = %d, want 229 (trunc 200*1.15)", got)
}
}
// P8: the enemy's action economy scales with the roster. A party of N faces up to
// N attack-actions a round, each re-targeted, so the enemy's damage spreads across
// the roster instead of pinning one seat — the 1/N² exposure that made P6e's party
// a 100%-clear faceroll. Solo stays at exactly one swing a round, the pre-party
// behaviour the characterization golden pins.
func TestSimulateParty_EnemyActionEconomyScalesWithRoster(t *testing.T) {
enemyRollsPerRound := func(res PartyCombatResult) map[int]int {
perRound := map[int]int{}
for _, e := range res.Events {
if e.Actor == "enemy" && e.Roll > 0 {
perRound[e.Round]++
}
}
return perRound
}
// Solo: never more than one enemy swing in a round.
soloTank := baseEnemy()
soloTank.Stats.MaxHP = 5000
solo := simulatePartyWithRNG(
[]Combatant{basePlayer()}, soloTank, dungeonCombatPhases, seededRNG(23))
if len(enemyRollsPerRound(solo)) == 0 {
t.Fatal("solo: the enemy never attacked")
}
for round, n := range enemyRollsPerRound(solo) {
if n > 1 {
t.Fatalf("solo round %d: enemy swung %d times, want at most 1", round, n)
}
}
// Party of 3: no round exceeds the roster's action budget, and at least one
// round sees more than one swing — the enemy is really taking extra actions.
tank := baseEnemy()
tank.Stats.MaxHP = 5000
res := simulatePartyWithRNG(
party := 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
budget := int(partyActionExpectation(3))
if partyActionExpectation(3) > float64(budget) {
budget++ // ceil: the coin-flip round can add one action
}
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))
sawMulti := false
for round, n := range enemyRollsPerRound(party) {
if n > budget {
t.Fatalf("party round %d: enemy swung %d times, over the %d-action budget", round, n, budget)
}
if n > 1 {
sawMulti = true
}
}
if !sawMulti {
t.Fatal("a party of 3 never faced more than one enemy swing in a round — action economy did not scale")
}
}

View File

@@ -0,0 +1,232 @@
package plugin
import (
"testing"
"maunium.net/go/mautrix/id"
)
// Grim Harvest on the turn-based surface.
//
// The auto-resolve path stashes the killing spell's slot level on the
// fight-start CombatModifiers, where the close-out reads it. A turn-based fight
// has nowhere to keep that: it rebuilds its combatants from the session row on
// every !attack / !cast. resolveTurnSpell computed the stash into a local mod
// set and dropped it on the floor, so a Necromancy Mage who killed with a spell
// never healed — on a class that already trails.
//
// The stash now rides on the casting seat's ActorStatuses, and the close-out
// asks whether the *last* spell_cast is the one that dropped the enemy to 0.
//
// (Empowered Evocation and Overchannel were never broken here: they only move
// mods.SpellPreDamage, which resolveTurnSpell already returns as EnemyDamage.)
// necromancer is a Mage who has reached L5 Grim Harvest.
func necromancer(t *testing.T, uid id.UserID, level int) *DnDCharacter {
t.Helper()
if err := createAdvCharacter(uid, string(uid)); err != nil {
t.Fatal(err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassMage, Subclass: SubclassNecromancy, Level: level,
STR: 8, DEX: 12, CON: 12, INT: 16, WIS: 10, CHA: 10,
HPMax: 30, HPCurrent: 10, ArmorClass: 12,
}
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
return c
}
// tough is a stat block a spell can be cast at without any of it mattering.
func tough() CombatStats { return CombatStats{MaxHP: 40, AC: 10} }
// ── the leak itself ──────────────────────────────────────────────────────────
// resolveTurnSpell must hand the stash back to its caller. Before this, the
// slot level lived and died inside the function.
func TestResolveTurnSpell_CarriesTheGrimHarvestStashOut(t *testing.T) {
c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, INT: 16}
// magic_missile: auto-hit, force damage, upcast to a 3rd-level slot.
spell, _ := lookupSpell("magic_missile")
enemy := tough()
out, ok := resolveTurnSpell(c, spell, 3, &enemy)
if !ok {
t.Fatal("magic_missile should be a supported turn spell")
}
if out.GrimHarvestSlot != 3 {
t.Errorf("GrimHarvestSlot = %d, want 3 (the slot it was cast at)", out.GrimHarvestSlot)
}
if out.GrimHarvestNecrotic {
t.Error("magic_missile deals force damage — Necrotic should be false")
}
// blight: auto-hit, necrotic — the flag that triples the heal.
spell, _ = lookupSpell("blight")
enemy = tough()
out, _ = resolveTurnSpell(c, spell, 4, &enemy)
if out.GrimHarvestSlot != 4 || !out.GrimHarvestNecrotic {
t.Errorf("blight L4: slot=%d necrotic=%v, want 4/true", out.GrimHarvestSlot, out.GrimHarvestNecrotic)
}
}
// Nobody else stashes. A Mage below L5 has not learned the harvest, and an
// Evocation Mage never will.
func TestResolveTurnSpell_NoStashForAnyoneElse(t *testing.T) {
spell, _ := lookupSpell("magic_missile")
for _, c := range []*DnDCharacter{
{Class: ClassMage, Subclass: SubclassNecromancy, Level: 4, INT: 16},
{Class: ClassMage, Subclass: SubclassEvocation, Level: 12, INT: 16},
{Class: ClassSorcerer, Level: 12, CHA: 16},
} {
enemy := tough()
out, _ := resolveTurnSpell(c, spell, 3, &enemy)
if out.GrimHarvestSlot != 0 {
t.Errorf("%s/%s L%d stashed slot %d, want 0",
c.Class, c.Subclass, c.Level, out.GrimHarvestSlot)
}
}
}
// ── which cast counts ────────────────────────────────────────────────────────
// The killing-blow check used to break on the first spell_cast it saw. In a
// turn-based fight the mage casts every round, so the first one is an opening
// cantrip that left the enemy standing — and it vetoed the heal the killing
// spell had earned. It is the last cast that has to be lethal.
func TestGrimHarvestHeal_ReadsTheLastCastNotTheFirst(t *testing.T) {
c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, INT: 16}
mods := CombatModifiers{GrimHarvestSlot: 2, GrimHarvestNecrotic: true}
result := CombatResult{PlayerWon: true, Events: []CombatEvent{
{Round: 1, Action: "spell_cast", EnemyHP: 22}, // opening cantrip, enemy stands
{Round: 2, Action: "enemy_attack", EnemyHP: 22},
{Round: 2, Action: "spell_cast", EnemyHP: 0}, // this one killed it
}}
if got := grimHarvestHeal(c, result, mods); got != 6 {
t.Errorf("heal = %d, want 6 (3× a 2nd-level necrotic slot)", got)
}
}
// The mirror: the mage softened it up with a spell, then finished it with a
// weapon. No spell landed the blow, so no harvest.
func TestGrimHarvestHeal_WeaponKillAfterASpellDoesNotHarvest(t *testing.T) {
c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, INT: 16}
mods := CombatModifiers{GrimHarvestSlot: 2, GrimHarvestNecrotic: true}
result := CombatResult{PlayerWon: true, Events: []CombatEvent{
{Round: 1, Action: "spell_cast", EnemyHP: 9},
{Round: 2, Action: "player_attack", EnemyHP: 0},
}}
if got := grimHarvestHeal(c, result, mods); got != 0 {
t.Errorf("heal = %d, want 0 — the sword landed the blow, not the spell", got)
}
}
// ── the seam through the session ─────────────────────────────────────────────
// The stash has to survive the round-trip the fight puts it through: parked on
// the seat by the cast, carried across commit()'s snapshot, read back by the
// close-out.
func TestSeatFightStartMods_ReadsTheStashOffTheSeat(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@seatstash:example.org")
c := necromancer(t, uid, 5)
sess := &CombatSession{UserID: string(uid), Status: CombatStatusWon}
sess.Statuses.GrimHarvestSlot = 3
sess.Statuses.GrimHarvestNecrotic = true
mods := seatFightStartMods(sess, 0, c)
if mods.GrimHarvestSlot != 3 || !mods.GrimHarvestNecrotic {
t.Fatalf("seatFightStartMods lost the stash: %+v", mods)
}
// snapshotActor rebuilds ActorStatuses from combatState each commit; fields
// with no combatState counterpart must be carried over from the prior
// snapshot, the way ArmedAbility is.
kept := snapshotActor(&actor{}, sess.Statuses.ActorStatuses)
if kept.GrimHarvestSlot != 3 || !kept.GrimHarvestNecrotic {
t.Errorf("commit() dropped the stash: %+v", kept)
}
}
// End to end at the close-out: a necromancer who ended the fight at 10/30 HP
// with a lethal 2nd-level necrotic spell walks away with 6 HP back.
func TestPostCombatBookkeepingForSeat_GrimHarvestHealsOnASpellKill(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@harvest:example.org")
necromancer(t, uid, 5)
sess := &CombatSession{
UserID: string(uid), Status: CombatStatusWon, Round: 3,
PlayerHP: 10, PlayerHPMax: 30, EnemyHP: 0,
TurnLog: []CombatEvent{
{Round: 1, Action: "spell_cast", EnemyHP: 14},
{Round: 3, Action: "spell_cast", EnemyHP: 0},
},
}
sess.Statuses.GrimHarvestSlot = 2
sess.Statuses.GrimHarvestNecrotic = true
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
got, _ := LoadDnDCharacter(uid)
if got.HPCurrent != 16 {
t.Errorf("HPCurrent = %d after a turn-based spell kill, want 16 (10 + 3×2)", got.HPCurrent)
}
}
// A seat with no stash — the mage never cast, or missed every time — is left
// exactly as the fight left them.
func TestPostCombatBookkeepingForSeat_NoStashNoHeal(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@nostash:example.org")
necromancer(t, uid, 5)
sess := &CombatSession{
UserID: string(uid), Status: CombatStatusWon, Round: 2,
PlayerHP: 10, PlayerHPMax: 30, EnemyHP: 0,
TurnLog: []CombatEvent{{Round: 2, Action: "player_attack", EnemyHP: 0}},
}
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
got, _ := LoadDnDCharacter(uid)
if got.HPCurrent != 10 {
t.Errorf("HPCurrent = %d, want 10 — nothing was stashed", got.HPCurrent)
}
}
// Party fights: the stash is per-seat, so seat 1's harvest cannot heal seat 0.
// This is the same class of bug P5 fixed for mid-fight buffs.
func TestGrimHarvestStash_IsPerSeat(t *testing.T) {
setupEmptyTestDB(t)
leader, member := id.UserID("@leader:example.org"), id.UserID("@member:example.org")
fightTestChar(t, leader, 30)
necromancer(t, member, 5)
sess := &CombatSession{
UserID: string(leader), Status: CombatStatusWon, Round: 2,
PlayerHP: 20, PlayerHPMax: 30, EnemyHP: 0,
Participants: []CombatParticipant{{Seat: 1, UserID: string(member), HP: 10, HPMax: 30}},
TurnLog: []CombatEvent{
{Round: 2, Seat: 1, Action: "spell_cast", EnemyHP: 0},
},
}
sess.Participants[0].Statuses.GrimHarvestSlot = 2
sess.Participants[0].Statuses.GrimHarvestNecrotic = true
p := &AdventurePlugin{}
p.postCombatBookkeepingForSeat(sess, 0)
p.postCombatBookkeepingForSeat(sess, 1)
gotLeader, _ := LoadDnDCharacter(leader)
if gotLeader.HPCurrent != 30 {
t.Errorf("leader HPCurrent = %d, want 30 — the member's harvest is not theirs", gotLeader.HPCurrent)
}
gotMember, _ := LoadDnDCharacter(member)
if gotMember.HPCurrent != 16 {
t.Errorf("member HPCurrent = %d, want 16 (10 + 3×2)", gotMember.HPCurrent)
}
}

View File

@@ -0,0 +1,169 @@
package plugin
import (
"math/rand/v2"
"testing"
)
// Misty's two round-end procs on the turn-based surface.
//
// Both were built onto every turn-based combatant by DerivePlayerStats and then
// never read: stepRoundEnd had no counterpart to endOfRoundForSeat. The buff was
// simply lost. The debuff was an exploit — a player who declined Misty escaped
// her crowd entirely by fighting with !attack instead of letting the room
// auto-resolve, which needed no discovery at all.
//
// seatEndOfRound is the shared hook. These pin that it fires, that it cannot be
// dodged, and that a character with no Misty history is not touched at all —
// the property that keeps the sim corpus and the golden file still.
// mistyState seats one combatant at hp, cursor armed, with a seeded RNG.
func mistyState(t *testing.T, player *Combatant, hp int) *combatState {
t.Helper()
st := testCombatState(hp, 100, 1, rand.New(rand.NewPCG(7, 7)))
st.actor.c = player
st.actor.hpMax = player.Stats.MaxHP
return st
}
// The two procs at certainty, so no test depends on a roll.
func mistyCursed(maxHP, dmg int) *Combatant {
return &Combatant{
Name: "Cursed",
Stats: CombatStats{MaxHP: maxHP, AC: 10},
Mods: CombatModifiers{CrowdRevengeProc: 1.0, CrowdRevengeDmg: dmg},
}
}
func mistyBuffed(maxHP, heal int) *Combatant {
return &Combatant{
Name: "Buffed",
Stats: CombatStats{MaxHP: maxHP, AC: 10},
Mods: CombatModifiers{MistyHealProc: 1.0, MistyHealAmt: heal},
}
}
// ── the exploit ──────────────────────────────────────────────────────────────
// The debuff must land at round end in a manual fight, exactly as it does when
// the room auto-resolves. Before seatEndOfRound, !attack was a clean escape.
func TestSeatEndOfRound_CrowdRevengeCannotBeDodgedByFightingManually(t *testing.T) {
st := mistyState(t, mistyCursed(40, 6), 40)
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})
if over {
t.Fatal("a 6-damage swing against 40 HP should not end the fight")
}
if st.playerHP != 34 {
t.Errorf("playerHP = %d, want 34 — Misty's crowd took its swing", st.playerHP)
}
if !hasAction(st.events, "crowd_revenge") {
t.Error("no crowd_revenge event on the log")
}
}
// The debuff can be lethal, and a lethal one with nobody else standing ends the
// fight rather than leaving a corpse to take the next round's turn.
func TestSeatEndOfRound_CrowdRevengeCanEndASoloFight(t *testing.T) {
st := mistyState(t, mistyCursed(40, 30), 3)
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})
if st.playerHP != 0 {
t.Fatalf("playerHP = %d, want 0", st.playerHP)
}
if !over {
t.Error("a solo character dropped by the crowd should end the fight")
}
}
// ── the buff ─────────────────────────────────────────────────────────────────
// The heal fires, caps at max HP, and marks the result so the achievement can
// be granted.
func TestSeatEndOfRound_MistyHealFiresAndCaps(t *testing.T) {
st := mistyState(t, mistyBuffed(40, 12), 34)
var res CombatResult
seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &res)
if st.playerHP != 40 {
t.Errorf("playerHP = %d, want 40 — the heal caps at max HP", st.playerHP)
}
if !res.MistyHealed {
t.Error("MistyHealed flag not set")
}
if !hasAction(st.events, "misty_heal") {
t.Error("no misty_heal event on the log")
}
}
// A character the crowd just dropped is not then healed back up by the same
// hook. The heal is for the living.
func TestSeatEndOfRound_NoHealForACharacterTheCrowdJustDropped(t *testing.T) {
c := mistyCursed(40, 40)
c.Mods.MistyHealProc = 1.0
c.Mods.MistyHealAmt = 20
st := mistyState(t, c, 10)
st.actors = append(st.actors, &actor{playerHP: 5}) // an ally keeps the fight alive
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})
if over {
t.Fatal("an ally is still standing — the fight is not over")
}
if st.playerHP != 0 {
t.Errorf("playerHP = %d, want 0 — the heal must not resurrect them", st.playerHP)
}
if hasAction(st.events, "misty_heal") {
t.Error("misty_heal fired on a downed character")
}
}
// ── the property that protects the corpus ────────────────────────────────────
// A character with no Misty history is untouched: no HP change, no events, and
// — because both procs short-circuit before st.randFloat() — no dice drawn. If
// the hook drew even one float, every simulated fight would diverge and
// combat_characterization.golden would move.
func TestSeatEndOfRound_UnbuffedCharacterIsUntouchedAndDrawsNoDice(t *testing.T) {
plain := &Combatant{Name: "Plain", Stats: CombatStats{MaxHP: 40, AC: 10}}
st := mistyState(t, plain, 40)
control := mistyState(t, plain, 40) // same seed, never passed to the hook
if over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{}); over {
t.Fatal("an unbuffed character cannot be dropped by a hook that does nothing")
}
if st.playerHP != 40 || len(st.events) != 0 {
t.Errorf("unbuffed character was touched: hp=%d events=%d", st.playerHP, len(st.events))
}
// The RNG streams must still be in lockstep: the hook consumed nothing.
if got, want := st.randFloat(), control.randFloat(); got != want {
t.Errorf("seatEndOfRound consumed RNG: next float %v, want %v", got, want)
}
}
// ── the wiring ───────────────────────────────────────────────────────────────
// The unit tests above call seatEndOfRound directly, which proves the hook is
// correct but not that stepRoundEnd calls it. This drives the real session API
// through a round_end step. Comment the call out of stepRoundEnd and this test
// reports PlayerHP=40 — the exploit, exactly as it shipped.
func TestStepRoundEnd_WiresSeatEndOfRound(t *testing.T) {
setupEmptyTestDB(t)
player := mistyCursed(40, 6)
enemy := &Combatant{Name: "Target", Stats: CombatStats{MaxHP: 100, AC: 10}}
sess := &CombatSession{
SessionID: "wiring", UserID: "@u:example.org", Status: CombatStatusActive,
PlayerHP: 40, PlayerHPMax: 40, EnemyHP: 100,
Phase: CombatPhaseRoundEnd, Round: 1,
}
if _, err := advanceCombatSession(sess, player, enemy, PlayerAction{}); err != nil {
t.Fatal(err)
}
if sess.PlayerHP != 34 {
t.Errorf("PlayerHP = %d, want 34 — stepRoundEnd never ran the hook", sess.PlayerHP)
}
if !hasAction(sess.TurnLog, "crowd_revenge") {
t.Error("no crowd_revenge event persisted to the session log")
}
}

View File

@@ -979,6 +979,12 @@ func renderAllySeatEvent(e CombatEvent, name, enemyName string) string {
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 "crowd_revenge":
// Deliberately unattributed. An ally sees the damage land — silence
// would read as HP vanishing — but Misty's grudge is the owner's own
// discovery, and naming her here would spoil it for the whole party.
// Same reason misty_heal below reads as a plain recovery.
return down(fmt.Sprintf("%s takes %d.", who, e.Damage))
case "flat_damage":
return fmt.Sprintf("%s deals %d.", who, e.Damage)
case "heal_item", "misty_heal":

View File

@@ -51,9 +51,12 @@ func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
// 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.
// Every seat's HP lands on their sheet regardless of how the fight ended, and
// so does the bookkeeping that outlives the fight — a Berserker who raged and
// lost is still exhausted. Both fan out; neither is the owner's alone.
for seat := range sess.RosterSize() {
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
p.postCombatBookkeepingForSeat(sess, seat)
}
switch sess.Status {
@@ -62,8 +65,7 @@ func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
case CombatStatusLost:
return p.finishPartyLoss(ct, zone, cadence)
case CombatStatusFled:
_ = abandonZoneRun(owner)
forceExtractExpeditionForRunLoss(owner, "combat flee")
endRunOnLoss(owner, sess.RunID, false)
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:
@@ -81,16 +83,7 @@ func (p *AdventurePlugin) finishPartyWin(
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
}
}
bossOnExpedition := p.applyOwnerWinEffects(owner, sess.EnemyID, elite)
tier := 1
if run != nil {
@@ -107,14 +100,8 @@ func (p *AdventurePlugin) finishPartyWin(
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)
}
}
// A member who went down before the killing blow still earns the kill.
p.grantSeatWinXP(uid, hp, hpMax, monster, tier)
var b strings.Builder
if shared != "" && seat == 0 {
@@ -153,11 +140,7 @@ func (p *AdventurePlugin) finishPartyLoss(ct *combatTurn, zone ZoneDefinition, c
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")
endRunOnLoss(owner, sess.RunID, true)
line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence)
out := make([]string, sess.RosterSize())
@@ -181,3 +164,62 @@ func (p *AdventurePlugin) eachSeat(ct *combatTurn, block string) []string {
}
return out
}
// ── Shared terminal effects (item D) ─────────────────────────────────────────
//
// finishCombatSession (solo, combat_cmd.go) and finishPartyWin/finishPartyLoss
// (party, above) still write their own player-facing text — the solo line says
// "You finished", the party line says "The party stands", and the death-on-win
// rule is deliberately roster-size-dependent (see item E). But the *effects*
// they fire were hand-copied lists, and item A drifted exactly there. These
// helpers are the single copy of each effect; both close-outs route through
// them so the effect lists cannot diverge again.
// applyOwnerWinEffects fires the terminal effects a win owes the run and
// expedition — the zone-kill record, room threat, and the boss-defeat threat
// drop — once, through the row's owner (a party member owns neither row).
// Returns whether the kill was a zone boss on an active expedition; both
// close-outs use it to reframe the final line as the expedition's climax.
func (p *AdventurePlugin) applyOwnerWinEffects(owner id.UserID, enemyID string, elite bool) (bossOnExpedition bool) {
recordZoneKillForUser(owner, enemyID)
applyRoomCombatThreatForUser(owner, elite)
if !elite {
// §8.1 — a zone boss defeat drops expedition threat. Silent no-op for a
// standalone zone run with no active expedition.
if exp, eerr := getActiveExpedition(owner); eerr == nil && exp != nil {
_ = applyBossDefeatThreat(exp.ID)
bossOnExpedition = true
}
}
return bossOnExpedition
}
// grantSeatWinXP pays one combatant their kill XP. A seat that finished at a
// fifth of its pool or less takes the near-death path. Full XP per person,
// solo or party, per the accessibility-over-crunch stance.
func (p *AdventurePlugin) grantSeatWinXP(uid id.UserID, hp, hpMax int, monster DnDMonsterTemplate, tier int) {
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: grantDnDXP", "user", uid, "err", err)
}
}
}
// endRunOnLoss tears down the owner's run and wrapping expedition when a fight
// ends in death or flight. On a death (not a flee) it also stamps the run's
// mood event. Owner-scoped, fires once; the per-seat death mark stays with the
// caller, since it is decided per HP and gated on roster size.
func endRunOnLoss(owner id.UserID, runID string, death bool) {
if death {
if run, _ := getZoneRun(runID); run != nil {
_, _ = applyMoodEvent(runID, MoodEventPlayerDeath)
}
}
_ = abandonZoneRun(owner)
reason := "combat flee"
if death {
reason = "combat death"
}
forceExtractExpeditionForRunLoss(owner, reason)
}

View File

@@ -58,19 +58,10 @@ func (p *AdventurePlugin) buildFightSeats(
}
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
}
// Both refusals below are cheap and neither needs the build, so they run
// before it: consuming a seat's armed ability and *then* sitting them out
// would spend their rage on a fight they never joined.
hp, hpMax := dndHPSnapshot(uid)
if hp <= 0 {
if leader {
@@ -96,7 +87,32 @@ func (p *AdventurePlugin) buildFightSeats(
continue
}
seats = append(seats, CombatSeatSetup{UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player})
// Consumed exactly once for the fight, here. Every later rebuild
// re-applies this id off the seat's statuses rather than re-arming.
armed := ""
if c, cerr := LoadDnDCharacter(uid); cerr == nil && c != nil {
trySimAutoArm(c)
armed = consumeArmedAbility(c)
}
player, e, _, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed)
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
}
if armed != "" {
slog.Info("combat: armed ability fired (turn-based start)", "user", uid, "ability", armed)
}
seats = append(seats, CombatSeatSetup{
UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player, ArmedAbility: armed,
})
}
return seats, enemy, senderSkip, ""
}

View File

@@ -88,6 +88,24 @@ type ActorStatuses struct {
// and druids with no sustained DPS once their burst landed.
ConcentrationDmg int `json:"concentration_dmg,omitempty"`
// ArmedAbility is the id of the active ability this character armed and
// spent entering the fight (rage, second_wind, …). The resource is already
// debited and the character disarmed; this is the record that lets every
// rebuild of an in-flight fight re-apply the ability's mods, and lets the
// close-out know a rage fired. Empty when nothing was armed.
ArmedAbility string `json:"armed_ability,omitempty"`
// GrimHarvestSlot / GrimHarvestNecrotic snapshot the most recent damaging
// spell this seat cast, for the Necromancy Mage's post-combat kill-heal.
// The auto-resolve path carries the same pair on CombatModifiers, stashed
// once by applyPendingCast; the turn-based path can cast every round, so
// the stash lives here and each damaging cast overwrites it. Whether the
// heal actually fires is decided at close-out by grimHarvestHeal, which
// asks whether the *last* spell_cast event is the one that dropped the
// enemy to 0 — so a stale stash from an earlier, non-lethal cast is inert.
GrimHarvestSlot int `json:"grim_harvest_slot,omitempty"`
GrimHarvestNecrotic bool `json:"grim_harvest_necrotic,omitempty"`
// Once-per-fight class/race/subclass one-shots: the "already used" flags.
// Without persistence these reset every round on resume, letting a Halfling
// reroll a nat 1 or an Orc rage every single round of a turn-based fight.
@@ -221,23 +239,26 @@ func (s *ActorStatuses) applyBuffDelta(d turnBuffDelta) {
// the Abjuration Arcane Ward is normally non-zero at fight start — the
// turn-based build deliberately omits pre-combat consumables and queued casts —
// but the full set is seeded for robustness. Returns true if anything was set.
func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) bool {
return seedActorOneShots(&s.Statuses.ActorStatuses, playerMods)
func seedCombatSessionOneShots(s *CombatSession, seat CombatSeatSetup) bool {
return seedActorOneShots(&s.Statuses.ActorStatuses, seat)
}
// 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 {
func seedActorOneShots(st *ActorStatuses, seat CombatSeatSetup) bool {
playerMods := seat.Mods
st.WardCharges = playerMods.WardCharges
st.SporeRounds = playerMods.SporeCloud
st.ReflectFrac = playerMods.ReflectNext
st.AutoCritFirst = playerMods.AutoCritFirst
st.ArcaneWardHP = playerMods.ArcaneWardHP
st.HealChargesLeft = playerMods.HealItemCharges
st.ArmedAbility = seat.ArmedAbility
return st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 ||
st.ArmedAbility != ""
}
// CombatParticipant is one party member's seat in a fight, from seat 1 up.
@@ -467,7 +488,10 @@ func (p *AdventurePlugin) startPartyCombatSession(
if len(seats) == 0 {
return nil, fmt.Errorf("start combat session: empty roster")
}
enemyHP := enemy.Stats.MaxHP
// Party-only enemy HP bump (solo roster scales by 1.0). The per-turn rebuild
// in partyCombatantsForSession applies the identical scalar to the enemy's
// Stats.MaxHP, so the persisted current HP and the rebuilt max never drift.
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats))
owner := seats[0]
sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID,
owner.HP, owner.HPMax, enemyHP, enemyHP)
@@ -477,13 +501,13 @@ func (p *AdventurePlugin) startPartyCombatSession(
// 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)
dirty := seedCombatSessionOneShots(sess, owner)
if len(seats) > 1 {
ps := make([]CombatParticipant, 0, len(seats)-1)
for i, s := range seats[1:] {
var st ActorStatuses
seedActorOneShots(&st, s.Mods)
seedActorOneShots(&st, s)
ps = append(ps, CombatParticipant{
Seat: i + 1, UserID: string(s.UserID), HP: s.HP, HPMax: s.HPMax, Statuses: st,
})
@@ -525,6 +549,10 @@ type CombatSeatSetup struct {
HPMax int
Mods CombatModifiers
C *Combatant
// ArmedAbility is the ability id this seat consumed entering the fight, ""
// if they armed nothing. It is persisted onto the seat's statuses so every
// later rebuild can re-apply the ability without re-spending it.
ArmedAbility string
}
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil).

View File

@@ -33,11 +33,18 @@ import (
// + armed ability) and the monster's bestiary stat block with tier scaling and
// the DM-mood combat tilt folded in. The returned dndChar is handed back so
// callers can run post-combat subclass persistence without reloading it.
//
// armed is the ability id already consumed for this fight (consumeArmedAbility,
// or armAbilityForFight's return on the auto-resolve paths); "" means none. The
// build re-applies it rather than consuming, because the turn-based path calls
// this once per player command and a consuming build would spend the ability on
// round 1 and drop it for the rest of the fight.
func (p *AdventurePlugin) buildZoneCombatants(
userID id.UserID,
monster DnDMonsterTemplate,
tier int,
dmMood int,
armed string,
) (player Combatant, enemy Combatant, dndChar *DnDCharacter, err error) {
tilt := dmMoodCombatTilt(dmMood)
char, err := loadAdvCharacter(userID)
@@ -64,10 +71,7 @@ func (p *AdventurePlugin) buildZoneCombatants(
applyRacePassives(&playerStats, &playerMods, dndChar)
applySubclassPassives(&playerStats, &playerMods, dndChar)
applyMagicItemEffects(&playerStats, &playerMods, userID)
trySimAutoArm(dndChar)
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
slog.Info("dnd: armed ability fired (turn-based build)", "user", userID, "ability", firedName)
}
applyAbilityByID(dndChar, armed, &playerMods)
enemyStats, enemyMods := monster.toCombatStats()
// Tier scaling mirrors runZoneCombat: only raise AC/AttackBonus to a
@@ -152,7 +156,11 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
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)
// The ability this seat armed was consumed once, at fight start, and its
// id parked on their statuses. Re-applying it here — not re-consuming —
// is what makes a rage last the whole fight instead of one round.
st := sess.actorStatusesForSeat(seat)
player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility)
if err != nil {
return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
}
@@ -161,18 +169,107 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
// one-shots (ward/spore/…) live on their persisted statuses and flow
// through the turn engine's resume/commit cycle, so only the persistent
// stat deltas are applied here — and only that seat's own.
applySessionBuffs(&player, sess.actorStatusesForSeat(seat))
applySessionBuffs(&player, st)
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
// Party-only enemy HP bump, re-derived each turn from the template so
// it never compounds. Matches the scalar startPartyCombatSession used
// for the initial persist; solo (roster 1) scales by 1.0.
if sess.IsParty() {
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, sess.RosterSize())
}
}
}
return players, &enemy, nil
}
// seatFightStartMods re-derives the modifiers a finished fight's close-out still
// needs: the Berserker's rage flag, which decides whether the character owes a
// point of exhaustion, and the Necromancy Mage's Grim Harvest stash.
//
// It re-applies the seat's armed ability to an empty mod set rather than
// rebuilding the whole combatant: no Apply writes to the character (they read
// level and HP and write only mods), so this is pure, and the passive/equipment
// layers a full build would add are not read by any post-combat hook.
//
// The Grim Harvest pair is not re-derived but read back off the seat's
// statuses, where castActionForSeat parked it: the spell that stashed it was
// cast mid-fight, not at fight start, and nothing in the character sheet still
// remembers which one it was.
func seatFightStartMods(sess *CombatSession, seat int, c *DnDCharacter) CombatModifiers {
var mods CombatModifiers
st := sess.actorStatusesForSeat(seat)
applyAbilityByID(c, st.ArmedAbility, &mods)
mods.GrimHarvestSlot = st.GrimHarvestSlot
mods.GrimHarvestNecrotic = st.GrimHarvestNecrotic
return mods
}
// seatCombatResult reconstructs, for one seat of a finished turn-based fight,
// the slice of CombatResult that the post-combat hooks actually read. The turn
// engine never builds a CombatResult — it persists a session and a shared event
// log — so the close-out has to assemble one.
//
// MistyHealed is read back off the seat's event log rather than off a flag: the
// turn engine's CombatResult is a scratch value it discards between steps, and
// the log is the only thing that survives to the close-out. This is how
// combat_pet_save has always been detected.
//
// SniperKilled stays false. Arina's proc is a pre-combat one-shot, not a
// round-end effect, so seatEndOfRound does not carry it and the turn engine
// still has no place to fire it — combat_sniper_kill remains unreachable from a
// manual kill.
//
// NearDeath mirrors the auto-resolve engine's win threshold (below 15% of max);
// its loss-side meaning is unused here, since the only hook that reads it gates
// on PlayerWon.
func seatCombatResult(sess *CombatSession, seat int) CombatResult {
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
won := sess.Status == CombatStatusWon
events := eventsForSeat(sess.TurnLog, seat)
return CombatResult{
PlayerWon: won,
Events: events,
PlayerEndHP: hp,
EnemyEndHP: sess.EnemyHP,
TotalRounds: sess.Round,
NearDeath: won && hp > 0 && float64(hp) < float64(max(1, hpMax))*0.15,
MistyHealed: hasAction(events, "misty_heal"),
}
}
// hasAction reports whether the event log holds at least one event with the
// given Action. The one-line "did this happen in the fight?" scan the close-out
// uses to read a proc's outcome off the log rather than a flag.
func hasAction(events []CombatEvent, action string) bool {
for _, e := range events {
if e.Action == action {
return true
}
}
return false
}
// postCombatBookkeepingForSeat is postCombatBookkeeping for one seat of a
// terminal turn-based session — the bridge between what the turn engine
// persisted and what the shared close-out expects.
func (p *AdventurePlugin) postCombatBookkeepingForSeat(sess *CombatSession, seat int) {
uid := id.UserID(sess.seatUserID(seat))
// Loaded after the caller's persistDnDHPAfterCombat, so a Grim-Harvest-style
// hook that heals off HPCurrent reads the fight's real ending HP.
c, err := LoadDnDCharacter(uid)
if err != nil || c == nil {
slog.Error("combat: post-combat bookkeeping skipped, no sheet", "user", uid, "err", err)
return
}
mods := seatFightStartMods(sess, seat, c)
p.postCombatBookkeeping(uid, c, mods.BerserkerRage, seatCombatResult(sess, seat), mods)
}
// applySessionBuffs re-derives the persistent stat effect of every mid-fight
// buff onto one rebuilt character. The buffs are stored as accumulated deltas
// (diffTurnBuff produced them against that character's state at cast time), so

View File

@@ -454,15 +454,23 @@ func TestApplySessionBuffs(t *testing.T) {
func TestSeedCombatSessionOneShots(t *testing.T) {
// Arcane Ward is the one resource normally live at fight start.
s := &CombatSession{}
if !seedCombatSessionOneShots(s, CombatModifiers{ArcaneWardHP: 15}) {
if !seedCombatSessionOneShots(s, CombatSeatSetup{Mods: CombatModifiers{ArcaneWardHP: 15}}) {
t.Error("seed should report true when Arcane Ward is present")
}
if s.Statuses.ArcaneWardHP != 15 {
t.Errorf("ArcaneWardHP = %d, want 15", s.Statuses.ArcaneWardHP)
}
// The armed ability rides along, so a rebuild mid-fight can re-apply it.
armed := &CombatSession{}
if !seedCombatSessionOneShots(armed, CombatSeatSetup{ArmedAbility: "rage"}) {
t.Error("seed should report true when an ability was armed")
}
if armed.Statuses.ArmedAbility != "rage" {
t.Errorf("ArmedAbility = %q, want rage", armed.Statuses.ArmedAbility)
}
// Nothing to seed → false, statuses untouched.
empty := &CombatSession{}
if seedCombatSessionOneShots(empty, CombatModifiers{}) {
if seedCombatSessionOneShots(empty, CombatSeatSetup{}) {
t.Error("seed should report false with no fight-start resources")
}
}

View File

@@ -414,10 +414,11 @@ func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) {
te.stepPlayerTurn(action)
te.stampSeat(0, acting)
case CombatPhaseEnemyTurn:
// stepEnemyTurn self-stamps each attack-action to the seat it targeted: a
// party's enemy re-targets across the roster within one turn, so there is
// no single seat the whole phase lands on. Solo stamps seat 0 throughout,
// exactly as the old blanket stamp did.
te.stepEnemyTurn()
// stepEnemyTurn seats its target before anything resolves, and the whole
// phase lands on that one character.
te.stampSeat(0, te.st.seatIdx)
case CombatPhaseRoundEnd:
te.stepRoundEnd()
case CombatPhaseOver:
@@ -615,34 +616,39 @@ func (te *turnEngine) stepEnemyTurn() {
return
}
te.st.seat(target)
// A control spell cast last phase forfeits the enemy's attack this round —
// unless the enemy is fear_immune, in which case the control fizzled and it
// acts as normal.
// A control spell cast last phase forfeits the enemy's whole turn (every
// attack-action) this round — unless the enemy is fear_immune, in which case
// the control fizzled and it acts as normal.
if te.st.enemySkipFirst {
te.st.enemySkipFirst = false
mark := len(te.st.events)
if enemyImmuneToControl(te.enemy, te.st) {
te.st.events = append(te.st.events, CombatEvent{
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "fear_resist",
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
})
te.stampSeat(mark, target)
} else {
te.st.events = append(te.st.events, CombatEvent{
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held",
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
})
te.stampSeat(mark, target)
te.advance()
return
}
}
// Monster ability fires at the top of the enemy turn. cleave / lifesteal
// resolve their own damage and stand in for the attack action this round;
// every other effect (poison / stun / enrage / armor_break) is a rider that
// leaves the multiattack below intact. applyAbility returns true when the
// player went down without a save.
// Monster ability fires once, at the top of the enemy turn, against the
// initial target. cleave / lifesteal resolve their own damage and stand in
// for the enemy's first attack-action this round; every other effect (poison
// / stun / enrage / armor_break) is a rider that leaves the multiattack below
// intact. applyAbility returns true when the player went down without a save.
abilityDealtDamage := false
if te.enemy.Ability != nil && turnAbilityFires(te.enemy.Ability, te.st, te.enemy) {
mark := len(te.st.events)
if applyAbility(te.st, te.st.c, te.enemy, &turnCombatPhase, te.result) {
te.stampSeat(mark, target)
// 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.
@@ -653,57 +659,90 @@ func (te *turnEngine) stepEnemyTurn() {
}
return
}
te.stampSeat(mark, target)
switch te.enemy.Ability.Effect {
case "cleave", "lifesteal":
abilityDealtDamage = true
}
}
if !abilityDealtDamage {
// Pet defensive procs are a single proc per enemy turn: roll once, then
// spend it on the first swing only. Whiff makes that one swing a
// guaranteed miss; deflect halves its damage. Against a multiattack the
// remaining swings resolve normally — a single proc shouldn't nullify a
// boss's whole multiattack round. (This deliberately diverges from the
// auto-resolve engine's apply-to-all model.)
petWhiff := te.st.c.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.st.c.Mods.PetWhiffProc
petDeflect := te.st.c.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.st.c.Mods.PetDeflectProc
if petDeflect {
te.result.PetDeflected = true
}
// SRD multiattack: each profile entry is one attack roll resolved
// through the shared primitive. A registered elite/boss swings its full
// profile; everyone else gets a single attack from the template stats.
// resolveEnemyAttack returns true when the fight is decided — either the
// player went down without a death save, or a reflect consumable killed
// the enemy. Disambiguate by inspecting HP.
for i, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) {
swing := *te.enemy
swing.Stats.Attack = atk.Damage
swing.Stats.AttackBonus = atk.AttackBonus
// Spend the proc on the first swing only; later swings see false.
swingWhiff := petWhiff && i == 0
swingDeflect := petDeflect && i == 0
decided := resolveEnemyAttack(te.st, te.st.c, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false)
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)
return
}
break
}
if decided || te.st.enemyHP <= 0 {
te.finish(CombatStatusWon)
// The enemy takes enemyActionsThisRound() attack-actions, each its full
// SRD multiattack, re-targeting a fresh standing seat per action so a party's
// damage is spread across the roster rather than pinning the first target.
// A solo roster is exactly one action against its one seat — no re-target is
// drawn — so its event stream and RNG draws are unchanged. When the ability
// already dealt damage it was the first action, so one fewer follows; for solo
// that collapses to the old "the ability stood in for the attack" skip.
actions, reuseFirstTarget := enemyActionPlan(te.st, abilityDealtDamage)
for a := 0; a < actions; a++ {
if !(reuseFirstTarget && a == 0) {
tgt, alive := te.enemyTarget()
if !alive {
te.finish(CombatStatusLost)
return
}
te.st.seat(tgt)
target = tgt
}
if te.enemyAttackAction(target) {
return
}
}
te.advance()
}
// enemyAttackAction resolves one enemy attack-action — the full SRD multiattack
// profile — against the seat the cursor already points at, and stamps every event
// it emits to that seat (the party's enemy re-targets across the roster within one
// turn, so the whole-turn blanket stamp in step() no longer holds). Registered
// elites/bosses swing their full profile; everyone else gets a single attack from
// the template stats.
//
// Returns true only when it decided the fight (te.finish has already been called),
// so the caller ends the enemy turn. It returns false when the target dropped but
// the roster is still alive — this action is over, and the caller re-targets the
// next one.
func (te *turnEngine) enemyAttackAction(seat int) (finished bool) {
mark := len(te.st.events)
// Pet defensive procs are a single proc per attack-action: roll once, then
// spend on the first swing only. Whiff makes that one swing a guaranteed miss;
// deflect halves its damage. Against a multiattack the remaining swings resolve
// normally — a single proc shouldn't nullify a boss's whole multiattack.
// (This deliberately diverges from the auto-resolve engine's apply-to-all.)
petWhiff := te.st.c.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.st.c.Mods.PetWhiffProc
petDeflect := te.st.c.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.st.c.Mods.PetDeflectProc
if petDeflect {
te.result.PetDeflected = true
}
for i, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) {
swing := *te.enemy
swing.Stats.Attack = atk.Damage
swing.Stats.AttackBonus = atk.AttackBonus
swingWhiff := petWhiff && i == 0
swingDeflect := petDeflect && i == 0
decided := resolveEnemyAttack(te.st, te.st.c, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false)
if te.st.playerHP <= 0 {
te.stampSeat(mark, seat)
// The target is down. The enemy stops swinging at a corpse; the fight
// ends only if the roster is empty, otherwise this action is over and
// the caller re-targets.
if !te.st.anyAlive() {
te.finish(CombatStatusLost)
return true
}
return false
}
if decided || te.st.enemyHP <= 0 {
te.stampSeat(mark, seat)
te.finish(CombatStatusWon)
return true
}
}
te.stampSeat(mark, seat)
return false
}
// turnAbilityFires decides whether a monster ability triggers this enemy turn.
// The auto-resolve engine gates abilities on its named phase clock (Opening /
// Clash / Decisive); the turn-based duel has no phase clock, so the phase tag
@@ -756,7 +795,9 @@ func (te *turnEngine) stepRoundEnd() {
// 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.
// settles the fight before the enemy knits its wounds back — and before
// Misty's crowd swings, so a caster whose aura would end the round is not
// robbed of the win by an end-of-round debuff.
for i := range st.actors {
st.seat(i)
if st.concentrationDmg <= 0 || st.enemyHP <= 0 || st.playerHP <= 0 {
@@ -772,6 +813,24 @@ func (te *turnEngine) stepRoundEnd() {
return
}
}
// Misty's crowd, then Misty's heal — per seat, after the round's other
// damage (including the concentration pulse above) has landed so the heal
// can answer it, which is the order endOfRoundForSeat uses. Both no-op (and
// draw no RNG) for a character with no Misty history, which is every
// simulated one.
for i := range st.actors {
st.seat(i)
if st.playerHP <= 0 {
continue
}
mark := len(st.events)
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, te.result)
te.stampSeat(mark, i)
if over {
te.finish(CombatStatusLost)
return
}
}
st.seat(0)
// Regenerate (monster ability): the enemy knits its wounds at round end.
if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP {

View File

@@ -410,22 +410,52 @@ func trySimAutoArm(c *DnDCharacter) string {
return ab.Name
}
// applyArmedAbility checks for a pre-armed ability on the character and
// applies its effect to the player's CombatModifiers, then clears the armed
// flag. Called from combat_bridge.go before SimulateCombat.
func applyArmedAbility(c *DnDCharacter, mods *CombatModifiers) (string, bool) {
// consumeArmedAbility disarms the character, returning the ability id that was
// armed. It is the *mutating* half of arming: the flag is cleared and saved, so
// it must run exactly once per fight, at fight start.
//
// It is split from applyAbilityByID because a turn-based fight rebuilds its
// combatants from the DB on every player command. A rebuild that consumed would
// fire the ability on round 1, clear the flag, and then hand every later round a
// character with no ability at all — the player pays the resource for one round
// of a buff that is supposed to span the fight. So the fight consumes once and
// persists the id on the session; each rebuild re-applies it from there.
//
// An id that is no longer in the ability table is disarmed and reported as "".
func consumeArmedAbility(c *DnDCharacter) string {
if c == nil || c.ArmedAbility == "" {
return ""
}
armed := c.ArmedAbility
c.ArmedAbility = ""
_ = SaveDnDCharacter(c)
if _, ok := dndActiveAbilities[armed]; !ok {
return ""
}
return armed
}
// applyAbilityByID folds an ability's effect into a freshly-derived set of
// CombatModifiers. It is pure with respect to persistence — no DB write, no
// disarm — so it is safe to call on every rebuild of an in-flight fight.
//
// abilityID is what consumeArmedAbility returned at fight start. Empty (nobody
// armed anything) and unknown ids are both no-ops.
func applyAbilityByID(c *DnDCharacter, abilityID string, mods *CombatModifiers) (string, bool) {
if c == nil || abilityID == "" {
return "", false
}
ab, ok := dndActiveAbilities[c.ArmedAbility]
ab, ok := dndActiveAbilities[abilityID]
if !ok {
c.ArmedAbility = ""
_ = SaveDnDCharacter(c)
return "", false
}
ab.Apply(c, mods)
firedName := ab.Name
c.ArmedAbility = ""
_ = SaveDnDCharacter(c)
return firedName, true
return ab.Name, true
}
// armAbilityForFight consumes whatever the character armed and applies it in one
// step, for the auto-resolve callers that build a combatant and immediately
// fight with it. A turn-based fight must not use this — see consumeArmedAbility.
func armAbilityForFight(c *DnDCharacter, mods *CombatModifiers) (string, bool) {
return applyAbilityByID(c, consumeArmedAbility(c), mods)
}

View File

@@ -146,7 +146,7 @@ func TestSpendAndRefreshResource(t *testing.T) {
}
}
func TestApplyArmedAbility_FighterSecondWind(t *testing.T) {
func TestArmAbilityForFight_FighterSecondWind(t *testing.T) {
setupAbilitiesTestDB(t)
uid := id.UserID("@arm_fighter:example")
c := &DnDCharacter{
@@ -162,7 +162,7 @@ func TestApplyArmedAbility_FighterSecondWind(t *testing.T) {
}
mods := CombatModifiers{}
name, fired := applyArmedAbility(c, &mods)
name, fired := armAbilityForFight(c, &mods)
if !fired {
t.Fatal("ability did not fire")
}
@@ -181,7 +181,7 @@ func TestApplyArmedAbility_FighterSecondWind(t *testing.T) {
}
}
func TestApplyArmedAbility_MageMagicMissile(t *testing.T) {
func TestArmAbilityForFight_MageMagicMissile(t *testing.T) {
setupAbilitiesTestDB(t)
uid := id.UserID("@arm_mage:example")
c := &DnDCharacter{
@@ -192,7 +192,7 @@ func TestApplyArmedAbility_MageMagicMissile(t *testing.T) {
t.Fatal(err)
}
mods := CombatModifiers{}
_, fired := applyArmedAbility(c, &mods)
_, fired := armAbilityForFight(c, &mods)
if !fired {
t.Fatal("magic missile did not fire")
}
@@ -202,10 +202,10 @@ func TestApplyArmedAbility_MageMagicMissile(t *testing.T) {
}
}
func TestApplyArmedAbility_NoArm(t *testing.T) {
func TestArmAbilityForFight_NoArm(t *testing.T) {
c := &DnDCharacter{Class: ClassFighter, ArmedAbility: ""}
mods := CombatModifiers{}
_, fired := applyArmedAbility(c, &mods)
_, fired := armAbilityForFight(c, &mods)
if fired {
t.Error("fired with no armed ability")
}

View File

@@ -25,7 +25,7 @@ import (
//
// Bypassed deliberately (Phase 0 simplifying constraints, doc §2):
//
// - DB-touching layers: applyMagicItemEffects, applyArmedAbility, and
// - DB-touching layers: applyMagicItemEffects, armAbilityForFight, and
// the SaveDnDCharacter inside applyPendingCast. The harness is pure
// Go; tests run without a sqlite instance.
// - Race passives beyond Human (+1 all): neutral baseline, again per §2.

View File

@@ -224,6 +224,28 @@ func getActiveExpedition(userID id.UserID) (*Expedition, error) {
return e, err
}
// ownedLiveExpedition returns the expedition this player owns and has not yet
// finished with — 'active' or 'extracting'. An extracted expedition is not
// over: it holds its roster for the whole resume window (releaseParty is not
// called on it), so the owner is still the only person who can close it.
//
// Active rows sort first, so a leader who owns both — extracted from one, then
// started another — resolves to the one they are standing in.
func ownedLiveExpedition(userID id.UserID) (*Expedition, error) {
row := db.Get().QueryRow(`
SELECT`+expeditionSelectCols+`
FROM dnd_expedition e
WHERE e.user_id = ?
AND e.status IN ('active', 'extracting')
ORDER BY (e.status = 'active') DESC, e.start_date DESC
LIMIT 1`, string(userID))
e, err := scanExpedition(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return e, err
}
// getExpedition fetches by ID regardless of status. Test/admin use.
func getExpedition(id string) (*Expedition, error) {
row := db.Get().QueryRow(`
@@ -321,6 +343,42 @@ func updateSupplies(expID string, s ExpeditionSupplies) error {
return err
}
// withExpeditionSupplies serializes one read-modify-write of the shared supply
// pool. updateSupplies rewrites supplies_json wholesale, so a caller that folds
// its delta onto an *Expedition it read earlier silently discards anything that
// landed in between — a member's pooled packs, another writer's spend. Handlers
// run one goroutine per event, so those writers genuinely interleave.
//
// fn is handed a freshly-read expedition under the pool's own lock and returns
// the supplies to persist. Callers that keep using their own *Expedition
// afterwards must copy the returned pool back onto it.
//
// advUserLock cannot stand in here: it is keyed by sender, so two members
// racing the same expedition row take two different mutexes and exclude nobody.
func (p *AdventurePlugin) withExpeditionSupplies(
expID string, fn func(fresh *Expedition) (ExpeditionSupplies, error),
) (ExpeditionSupplies, error) {
mu := p.advExpeditionLock(expID)
mu.Lock()
defer mu.Unlock()
fresh, err := getExpedition(expID)
if err != nil {
return ExpeditionSupplies{}, err
}
if fresh == nil {
return ExpeditionSupplies{}, fmt.Errorf("expedition %s not found", expID)
}
next, err := fn(fresh)
if err != nil {
return ExpeditionSupplies{}, err
}
if err := updateSupplies(expID, next); err != nil {
return ExpeditionSupplies{}, err
}
return next, nil
}
// updateCamp persists camp state. Pass nil to break camp.
func updateCamp(expID string, c *CampState) error {
var arg any
@@ -349,9 +407,18 @@ func setExpeditionRunID(expID, runID string) error {
return err
}
// abandonExpedition flags the active expedition as abandoned. Idempotent.
// abandonExpedition flags the player's live expedition as abandoned. Idempotent.
//
// It spans 'extracting' as well as 'active': an extracted expedition still owns
// its roster, so abandoning is the leader's only way to free their party
// without paying to `!resume` first. Character reset (dnd_setup) leans on this
// too — a rerolled leader who left an extracted party behind would otherwise
// strand every member until the sweeper reaped the row.
//
// One row per call, active first, so the run-spawn rollback in expeditionCmdStart
// tears down the expedition it just created rather than an older extracted one.
func abandonExpedition(userID id.UserID) error {
e, err := getActiveExpedition(userID)
e, err := ownedLiveExpedition(userID)
if err != nil {
return err
}

View File

@@ -190,10 +190,18 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
}
}
exp.Supplies.Current -= cost
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
// The affordability check above ran against `exp` as it was read, and
// nightRolloverBurn may have moved the pool since. Deduct against the pool
// as it stands — the same arithmetic, just not onto a stale snapshot.
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
next := fresh.Supplies
next.Current -= cost
return next, nil
})
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't deduct supplies: "+err.Error())
}
exp.Supplies = pooled
camp := &CampState{
Active: true,
Type: kind,

View File

@@ -322,6 +322,37 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
"You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
zone.Display, existing.CurrentDay))
}
// A leader who extracted still holds their roster for the resume window, and
// `!resume` only ever reaches the *newest* extracted row. Starting fresh on
// top of one would orphan it: unreachable, un-reapable until the sweeper
// catches it, with every member still seated and refused a run of their own.
//
// Only a row with a roster blocks. A solo extraction strands nobody, so
// walking away from it stays a normal thing to do.
if pending, _ := getResumableExpedition(ctx.Sender); pending != nil {
switch {
case extractionLapsed(pending, time.Now().UTC()):
// Past the window — reap it here rather than make them wait an hour
// for the sweeper, and let the new expedition proceed. Route through
// the shared reap so the freed members hear about it, same as the
// sweeper and `!expedition abandon` do.
if err := p.reapLapsedExtraction(pending); err != nil {
slog.Warn("expedition: reap lapsed on start", "expedition", pending.ID, "err", err)
}
default:
// A roster still holds; block. On a roster-read error, assume it is
// occupied and refuse — proceeding would orphan a party we could not
// confirm was empty, the one outcome this guard exists to prevent. A
// solo extraction (n == 1) strands nobody, so walking away is fine.
n, err := partySize(pending.ID)
if err != nil || n > 1 {
zone, _ := getZone(pending.ZoneID)
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You extracted from **%s** on Day %d and your party is still waiting on you. `!resume` to lead them back in, or `!expedition abandon` to let it go — until you do one or the other, none of them can start a run of their own.",
zone.Display, pending.CurrentDay))
}
}
}
cost := float64(purchase.Cost())
if p.euro == nil {
@@ -610,6 +641,15 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
}
if exp == nil {
// An extracted expedition is still the owner's to close — it holds the
// roster until the resume window lapses. Without this, a leader who
// wanted out had to pay to `!resume` first just to abandon.
if exp, err = getResumableExpedition(ctx.Sender); err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
}
isLeader = exp != nil
}
if exp == nil {
return p.SendDM(ctx.Sender, "No active expedition to abandon.")
}
@@ -619,15 +659,37 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
"Only your party leader can abandon the expedition. `!expedition leave` to walk out alone.")
}
zone, _ := getZone(exp.ZoneID)
extracted := exp.Status == ExpeditionStatusExtracting
audience := expeditionAudience(exp) // read before abandonExpedition disbands the roster
if err := abandonExpedition(ctx.Sender); err != nil {
return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error())
}
markActedToday(ctx.Sender)
_ = retireAllRegionRuns(exp)
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "")
if err := p.SendDM(ctx.Sender, fmt.Sprintf(
// An extracted party is standing in town, not in the dungeon: their supplies
// are already spent and their loot is already banked. Say the true thing.
body := fmt.Sprintf(
"Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.",
zone.Display, exp.CurrentDay)); err != nil {
zone.Display, exp.CurrentDay)
if extracted {
body = fmt.Sprintf(
"You let the expedition in **%s** go. Day %d is where it ends — loot, XP, and coins are kept. The dungeon remembers.",
zone.Display, exp.CurrentDay)
}
// The roster is being disbanded out from under the members; they hear it from
// their leader rather than discovering it the next time a command works again.
for _, uid := range audience {
if uid == ctx.Sender {
continue
}
if err := p.SendDM(uid, fmt.Sprintf(
"Your leader called off the expedition in **%s** on Day %d. You're free to start a run of your own.",
zone.Display, exp.CurrentDay)); err != nil {
slog.Warn("expedition: abandon DM failed", "user", uid, "expedition", exp.ID, "err", err)
}
}
if err := p.SendDM(ctx.Sender, body); err != nil {
return err
}
// Emergence seam: see maybeRollPetArrivalOnEmerge.

View File

@@ -82,34 +82,39 @@ type nightRolloverResult struct {
// to finish the rollover; legacy deliverBriefing interleaves processOvernightCamp
// between the two so a fortified camp's 5 lands before drift's +3.
func (p *AdventurePlugin) nightRolloverBurn(e *Expedition) (float32, error) {
// D5-c: Ranger forage runs before the daily burn so the +SU lands on
// today's supplies, not tomorrow's. Logged so the end-of-day digest
// can surface the gain; pure no-op for non-Ranger characters.
if c, err := LoadDnDCharacter(id.UserID(e.UserID)); err == nil && c != nil {
if gain := applyRangerForage(e, c, nil); gain > 0 {
_ = appendExpeditionLog(e.ID, e.CurrentDay, "forage",
fmt.Sprintf("ranger forage +%g SU", gain),
flavor.Pick(flavor.HarvestForageSuccess))
}
}
burnOverride := applyZoneTemporalPreBurn(e, e.CurrentDay+1)
var newSupplies ExpeditionSupplies
var burn float32
if burnOverride.Multiplier > 0 {
// 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.Current -= burn
if newSupplies.Current < 0 {
newSupplies.Current = 0
// Forage and burn land in one write, so they resolve together against the
// pool as it stands now — not as `e` last saw it.
newSupplies, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
// D5-c: Ranger forage runs before the daily burn so the +SU lands on
// today's supplies, not tomorrow's. Logged so the end-of-day digest
// can surface the gain; pure no-op for non-Ranger characters.
if c, cerr := LoadDnDCharacter(id.UserID(fresh.UserID)); cerr == nil && c != nil {
if gain := applyRangerForage(fresh, c, nil); gain > 0 {
_ = appendExpeditionLog(fresh.ID, fresh.CurrentDay, "forage",
fmt.Sprintf("ranger forage +%g SU", gain),
flavor.Pick(flavor.HarvestForageSuccess))
}
}
newSupplies.ForagedToday = false
} else {
harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e)
newSupplies, burn = applyExpeditionDailyBurn(e, harsh, e.SiegeMode)
}
if err := updateSupplies(e.ID, newSupplies); err != nil {
burnOverride := applyZoneTemporalPreBurn(fresh, fresh.CurrentDay+1)
if burnOverride.Multiplier > 0 {
// 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 = fresh.Supplies.DailyBurn * burnOverride.Multiplier * float32(expeditionBurnRatePct(fresh.ID)) / 100
next := fresh.Supplies
next.Current -= burn
if next.Current < 0 {
next.Current = 0
}
next.ForagedToday = false
return next, nil
}
harsh := fresh.ThreatLevel > 60 || zoneTemporalHarsh(fresh)
next, b := applyExpeditionDailyBurn(fresh, harsh, fresh.SiegeMode)
burn = b
return next, nil
})
if err != nil {
return 0, err
}
if err := advanceExpeditionDay(e.ID); err != nil {

View File

@@ -30,6 +30,7 @@ import (
const (
extractResumeWindow = 7 * 24 * time.Hour
forcedExtractCoinTaxFrac = 0.20
extractionSweepInterval = time.Hour
)
var (
@@ -218,6 +219,94 @@ func getResumableExpedition(userID id.UserID) (*Expedition, error) {
return e, err
}
// extractionLapsed reports whether an 'extracting' row is past its resume
// window and should be reaped. A NULL completed_at counts as lapsed: the column
// is the only clock the window has, and a row without one can never expire.
func extractionLapsed(e *Expedition, now time.Time) bool {
return e.CompletedAt == nil || now.Sub(*e.CompletedAt) > extractResumeWindow
}
// loadLapsedExtractions returns every extracted expedition whose resume window
// has closed, regardless of owner.
func loadLapsedExtractions(now time.Time) ([]*Expedition, error) {
rows, err := db.Get().Query(`
SELECT`+expeditionSelectCols+`
FROM dnd_expedition e
WHERE e.status = 'extracting'
AND (e.completed_at IS NULL OR e.completed_at < ?)`,
now.Add(-extractResumeWindow))
if err != nil {
return nil, err
}
defer rows.Close()
return scanExpeditionRows(rows)
}
// sweepLapsedExtractions closes out every extraction whose seven-day window has
// run out, which releases its roster.
//
// Without this the window is a promise the code never keeps. `handleResumeCmd`
// expires a lapsed row lazily, but only the *leader* can call `!resume` — so a
// leader who quits, forgets, or simply starts a different expedition leaves the
// row 'extracting' forever, and releaseParty (which fires only on a terminal
// status) never runs. Every member stays seated, and assertNotAdventuring keeps
// refusing them a run of their own. `!expedition leave` is their escape, but a
// player should not have to find it.
func (p *AdventurePlugin) sweepLapsedExtractions(now time.Time) {
exps, err := loadLapsedExtractions(now)
if err != nil {
slog.Error("expedition: load lapsed extractions", "err", err)
return
}
for _, e := range exps {
if err := p.reapLapsedExtraction(e); err != nil {
slog.Warn("expedition: expire lapsed extraction", "expedition", e.ID, "err", err)
continue
}
}
}
// reapLapsedExtraction closes one lapsed extraction and tells its roster the
// way back has shut. It is the single reap-with-notify path: the hourly
// sweeper loops it, and expeditionCmdStart calls it when a player starts a new
// expedition on top of one whose window has already closed — so a member is
// never silently unseated by whichever path happens to reach the row first.
//
// The audience is read before the close-out: completeExpedition disbands the
// roster, and expeditionAudience reads that roster.
func (p *AdventurePlugin) reapLapsedExtraction(e *Expedition) error {
audience := expeditionAudience(e)
if err := completeExpedition(e.ID, ExpeditionStatusFailed); err != nil {
return err
}
zone, _ := getZone(e.ZoneID)
body := fmt.Sprintf(
"🕯 **The way back has closed — %s**\n\nYour extracted expedition sat past its seven-day resume window, and the dungeon reshaped without you. Day %d is where it ends.\n\nThe loot, XP, and coins you carried out are still yours. `!expedition list` when you're ready for the next one.",
zone.Display, e.CurrentDay)
for _, uid := range audience {
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: lapsed-extraction DM failed", "user", uid, "expedition", e.ID, "err", err)
}
}
return nil
}
// expeditionExtractionSweepTicker reaps lapsed extractions hourly. The window is
// seven days, so the cadence only bounds how long a freed member waits to hear
// about it.
func (p *AdventurePlugin) expeditionExtractionSweepTicker() {
ticker := time.NewTicker(extractionSweepInterval)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
p.sweepLapsedExtractions(time.Now().UTC())
}
}
}
// resumeExpedition flips an extracting row back to 'active' with fresh
// supplies. Threat/temporal/region state are preserved as-is.
func resumeExpedition(expID string, supplies ExpeditionSupplies) error {
@@ -323,8 +412,9 @@ func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error
if exp == nil {
return p.SendDM(ctx.Sender, "No extracted expedition to resume. Use `!expedition start <zone>` to begin a new one.")
}
if exp.CompletedAt == nil || time.Since(*exp.CompletedAt) > extractResumeWindow {
// Expire it so it doesn't keep resurfacing.
if extractionLapsed(exp, time.Now().UTC()) {
// Expire it so it doesn't keep resurfacing. The hourly sweeper would get
// here on its own; this keeps the refusal and the reap in one breath.
_ = completeExpedition(exp.ID, ExpeditionStatusFailed)
return p.SendDM(ctx.Sender,
"That extraction is past its 7-day resume window — the dungeon has reshaped without you. Start a new expedition.")

View File

@@ -176,20 +176,30 @@ const twoWeeksCacheSize = 3
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
if e.Supplies.DailyBurn > 0 {
var restocked bool
pooled, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
s := fresh.Supplies
if s.DailyBurn <= 0 {
return s, nil
}
s.Current += twoWeeksRestockDays * s.DailyBurn
if s.Max > 0 && s.Current > s.Max {
s.Current = s.Max
}
restocked = s.Current > fresh.Supplies.Current
return s, nil
})
switch {
case err != nil:
slog.Error("milestone: two weeks restock failed",
"expedition", e.ID, "err", err)
case restocked:
lines = append(lines, fmt.Sprintf(
"📦 Supplies restocked — %.1f of %.1f.", pooled.Current, pooled.Max))
e.Supplies = pooled
default:
e.Supplies = pooled
}
}

View File

@@ -143,13 +143,17 @@ func (p *AdventurePlugin) regionCmdTravel(ctx MessageContext, exp *Expedition) e
// on both paths.
func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition, cur, next ExpeditionRegion) (string, error) {
// Burn one day of supplies (transit day).
siege := exp.SiegeMode
harsh := exp.ThreatLevel > 60 || zoneTemporalHarsh(exp)
newSupplies, burned := applyExpeditionDailyBurn(exp, harsh, siege)
exp.Supplies = newSupplies
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
var burned float32
newSupplies, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
harsh := fresh.ThreatLevel > 60 || zoneTemporalHarsh(fresh)
next, b := applyExpeditionDailyBurn(fresh, harsh, fresh.SiegeMode)
burned = b
return next, nil
})
if err != nil {
return "", fmt.Errorf("apply transit supply burn: %w", err)
}
exp.Supplies = newSupplies
if err := advanceExpeditionDay(exp.ID); err != nil {
return "", fmt.Errorf("advance expedition day: %w", err)
}

View File

@@ -310,6 +310,13 @@ type turnSpellOutcome struct {
PlayerHeal int
EnemySkip bool
Desc string
// GrimHarvestSlot / GrimHarvestNecrotic are the Necromancy Mage's kill-heal
// stash for this cast, zero for everyone else. Unlike the fields above they
// outlive the casting round: the caller parks them on the seat's
// ActorStatuses and the close-out decides whether the cast was lethal.
GrimHarvestSlot int
GrimHarvestNecrotic bool
}
// resolveTurnSpell resolves a spell as a turn-based player action, reusing the
@@ -344,6 +351,8 @@ func resolveTurnSpell(c *DnDCharacter, spell SpellDefinition, slotLevel int, ene
out.EnemyDamage = mods.SpellPreDamage
out.EnemySkip = mods.SpellEnemySkipFirst
out.Desc = mods.SpellPreDamageDesc
out.GrimHarvestSlot = mods.GrimHarvestSlot
out.GrimHarvestNecrotic = mods.GrimHarvestNecrotic
if out.Desc == "" {
out.Desc = spell.Name
}

View File

@@ -934,11 +934,22 @@ func grimHarvestHeal(c *DnDCharacter, result CombatResult, mods CombatModifiers)
if !result.PlayerWon || mods.GrimHarvestSlot <= 0 {
return 0
}
// The pre-combat spell killed the enemy iff the spell_cast event itself
// dropped EnemyHP to 0. If a later round-event finished the kill, no heal.
for _, ev := range result.Events {
if ev.Action == "spell_cast" {
if ev.EnemyHP > 0 {
// The spell killed the enemy iff the spell_cast event itself dropped EnemyHP
// to 0. If a later round-event (a weapon swing, the pet, a concentration
// aura re-tick) finished the kill, no heal.
//
// It is the *last* spell_cast that has to be lethal, not the first. The
// auto-resolve path casts once, pre-combat, so the two coincide there; the
// turn-based path can cast every round, and there the stash on the seat's
// statuses describes that last cast. Reading the first event would let a
// non-lethal opening cantrip veto a heal the killing spell had earned.
//
// No spell_cast event at all cannot happen while the stash is set — both
// surfaces emit one for every damaging cast — so the loop falling through
// is not a case, just an absence.
for i := len(result.Events) - 1; i >= 0; i-- {
if result.Events[i].Action == "spell_cast" {
if result.Events[i].EnemyHP > 0 {
return 0
}
break

View File

@@ -471,6 +471,12 @@ type advanceResult struct {
// harvest ran (Entry/Trap/Elite/Boss rooms, forks, stops).
harvest autoHarvestResult
harvestFooter string
// zoneCleared is true only when this step was a *full* zone clear
// (reason == stopComplete and not a mid-zone region clear). The K
// event anchor reads it so a foreground `!zone advance` clear can roll
// the grind-loop player's mid-day event — a mid-zone region clear does
// not, since the run continues and it would fire per region.
zoneCleared bool
}
// zoneCmdAdvance resolves the room the player is currently standing in,
@@ -495,7 +501,16 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
if err != nil {
return p.SendDM(ctx.Sender, err.Error())
}
return p.streamOrSend(ctx.Sender, res.preStream, res.intro, res.phases, res.final)
sendErr := p.streamOrSend(ctx.Sender, res.preStream, res.intro, res.phases, res.final)
// K fourth event anchor: a foreground single-day zone clear is a presence
// moment for the grind-loop player the other three anchors miss. Rolled
// after the clear DM so any triggered event lands behind it, and only here
// — zoneCmdAdvance is the foreground path; autopilot walks go through
// runAutopilotWalk and never reach this.
if res.zoneCleared {
p.maybeFireAnchoredEvent(ctx.Sender, advEventChanceZoneClear)
}
return sendErr
}
// advanceOnce runs the single-room advance pipeline and returns a
@@ -737,7 +752,8 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin
// and point at the next one — "Cleared {zone}. Run complete." reads
// wrong right before the auto-advance transit block (and is shared with
// manual `!region travel`, which advances next).
if region, next, midZone := midZoneRegionClear(ctx.Sender, run.RunID); midZone {
region, next, midZone := midZoneRegionClear(ctx.Sender, run.RunID)
if midZone {
b.WriteString(fmt.Sprintf("🏁 **Cleared %s.** The way to %s opens ahead.\n\n", region.Name, next.Name))
} else {
b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Run complete.\n\n", zone.Display))
@@ -763,11 +779,12 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin
}
}
return advanceResult{
preStream: preStream,
intro: intro,
phases: phases,
final: b.String(),
reason: stopComplete,
preStream: preStream,
intro: intro,
phases: phases,
final: b.String(),
reason: stopComplete,
zoneCleared: !midZone,
}, nil
}
if forkMsg != "" {

View File

@@ -349,12 +349,14 @@ func (p *AdventurePlugin) applyAmbientEffect(e *Expedition, ev ambientEvent) str
return "Something out there is paying attention now."
case "pack_rat":
drain := float32(0.2) + float32(rng.IntN(2))*float32(0.1) // 0.2 or 0.3
ns := e.Supplies
ns.Current -= drain
if ns.Current < 0 {
ns.Current = 0
}
if err := updateSupplies(e.ID, ns); err != nil {
if _, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
ns := fresh.Supplies
ns.Current -= drain
if ns.Current < 0 {
ns.Current = 0
}
return ns, nil
}); err != nil {
slog.Warn("expedition: ambient supply drain", "expedition", e.ID, "err", err)
return ""
}

View File

@@ -250,10 +250,15 @@ func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision
if exp.Supplies.Current < cost {
return "", fmt.Errorf("insufficient supplies (have %.1f, need %.1f)", exp.Supplies.Current, cost)
}
exp.Supplies.Current -= cost
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
next := fresh.Supplies
next.Current -= cost
return next, nil
})
if err != nil {
return "", err
}
exp.Supplies = pooled
camp := &CampState{
Active: true,
Type: d.Kind,

View File

@@ -0,0 +1,158 @@
package plugin
import (
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// seedExtraction inserts an 'extracting' row whose window closed `age` ago.
// A negative age is a live extraction.
func seedExtraction(t *testing.T, expeditionID string, owner id.UserID, age time.Duration) {
t.Helper()
seedExpedition(t, expeditionID, owner, ExpeditionStatusExtracting)
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET completed_at = ? WHERE expedition_id = ?`,
time.Now().UTC().Add(-extractResumeWindow-age), expeditionID); err != nil {
t.Fatal(err)
}
}
func seatedOn(t *testing.T, member id.UserID) string {
t.Helper()
e, err := seatedExpeditionFor(member)
if err != nil {
t.Fatal(err)
}
if e == nil {
return ""
}
return e.ID
}
// The window is the only thing bounding how long a leader's roster holds their
// party. Nothing else flips an 'extracting' row terminal unless the leader
// personally types !resume, so if the sweep misses, members are stuck forever.
func TestSweepLapsedExtractions_ReleasesTheRosterItStranded(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@sweep-leader:example.org")
member := id.UserID("@sweep-member:example.org")
seedExtraction(t, "exp-lapsed", leader, time.Hour)
if err := joinParty("exp-lapsed", member); err != nil {
t.Fatal(err)
}
if got := seatedOn(t, member); got != "exp-lapsed" {
t.Fatalf("member seated on %q before sweep, want exp-lapsed", got)
}
(&AdventurePlugin{}).sweepLapsedExtractions(time.Now().UTC())
e, err := getExpedition("exp-lapsed")
if err != nil {
t.Fatal(err)
}
if e.Status != ExpeditionStatusFailed {
t.Errorf("status = %q, want failed", e.Status)
}
if got := seatedOn(t, member); got != "" {
t.Errorf("member still seated on %q after sweep", got)
}
if n, _ := partySize("exp-lapsed"); n != 1 {
t.Errorf("roster survived the sweep: partySize = %d", n)
}
}
// The mirror: a leader who extracted an hour ago still has six days to !resume,
// and their party must still be there when they do.
func TestSweepLapsedExtractions_LeavesALiveExtractionAlone(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@sweep-live-leader:example.org")
member := id.UserID("@sweep-live-member:example.org")
seedExtraction(t, "exp-live", leader, -6*24*time.Hour) // one day in
if err := joinParty("exp-live", member); err != nil {
t.Fatal(err)
}
(&AdventurePlugin{}).sweepLapsedExtractions(time.Now().UTC())
e, _ := getExpedition("exp-live")
if e.Status != ExpeditionStatusExtracting {
t.Errorf("status = %q, want extracting — the resume window is still open", e.Status)
}
if got := seatedOn(t, member); got != "exp-live" {
t.Errorf("member unseated from a resumable expedition (seated on %q)", got)
}
}
// completed_at is the only clock the window has. A row without one can never
// lapse, so it must read as already lapsed rather than as immortal.
func TestExtractionLapsed_NullCompletedAtIsLapsed(t *testing.T) {
now := time.Now().UTC()
if !extractionLapsed(&Expedition{}, now) {
t.Error("nil completed_at should read as lapsed")
}
fresh := now.Add(-time.Hour)
if extractionLapsed(&Expedition{CompletedAt: &fresh}, now) {
t.Error("an hour-old extraction is not lapsed")
}
old := now.Add(-extractResumeWindow - time.Second)
if !extractionLapsed(&Expedition{CompletedAt: &old}, now) {
t.Error("a row past the window is lapsed")
}
}
// A leader must be able to let an extracted expedition go without paying to
// !resume it first — that is the escape hatch !expedition start points them at.
func TestAbandonExpedition_ReachesAnExtractedRowAndFreesTheParty(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@abandon-extracted-leader:example.org")
member := id.UserID("@abandon-extracted-member:example.org")
seedExtraction(t, "exp-extracted", leader, -24*time.Hour) // live, resumable
if err := joinParty("exp-extracted", member); err != nil {
t.Fatal(err)
}
if err := abandonExpedition(leader); err != nil {
t.Fatalf("abandonExpedition on an extracting row: %v", err)
}
e, _ := getExpedition("exp-extracted")
if e.Status != ExpeditionStatusAbandoned {
t.Errorf("status = %q, want abandoned", e.Status)
}
if got := seatedOn(t, member); got != "" {
t.Errorf("member still seated on %q", got)
}
}
// expeditionCmdStart's run-spawn rollback calls abandonExpedition to tear down
// the row it just created. A leader who also owns an older extracted row must
// not have that one reaped instead.
func TestAbandonExpedition_PrefersTheActiveRowOverAnExtractedOne(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@abandon-prefers-active:example.org")
seedExtraction(t, "exp-old-extracted", leader, -24*time.Hour)
seedExpedition(t, "exp-new-active", leader, ExpeditionStatusActive)
if err := abandonExpedition(leader); err != nil {
t.Fatal(err)
}
if e, _ := getExpedition("exp-new-active"); e.Status != ExpeditionStatusAbandoned {
t.Errorf("active row status = %q, want abandoned", e.Status)
}
if e, _ := getExpedition("exp-old-extracted"); e.Status != ExpeditionStatusExtracting {
t.Errorf("extracted row was collateral damage: status = %q", e.Status)
}
}
func TestAbandonExpedition_NoLiveRow(t *testing.T) {
setupEmptyTestDB(t)
if err := abandonExpedition(id.UserID("@abandon-nothing:example.org")); err != ErrNoActiveExpedition {
t.Errorf("err = %v, want ErrNoActiveExpedition", err)
}
}

View File

@@ -115,18 +115,6 @@ func partySize(expeditionID string) (int, error) {
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
@@ -171,9 +159,11 @@ func joinParty(expeditionID string, userID id.UserID) error {
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.
// seatLeader converts a solo expedition row into a party of one, 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. Idempotent (ON CONFLICT DO NOTHING), so the invite
// path can call it unconditionally before adding a member.
func seatLeader(tx *sql.Tx, expeditionID string) error {
var owner string
err := tx.QueryRow(

View File

@@ -178,21 +178,13 @@ func (p *AdventurePlugin) expeditionCmdAccept(ctx MessageContext, c *DnDCharacte
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()
// `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, so the fold happens against a fresh read under the pool's lock.
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
return addSupplyPurchase(fresh.Supplies, suppliesPurchase), nil
})
if err != nil {
return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool: "+err.Error())
}

View File

@@ -22,6 +22,24 @@ func seedExpedition(t *testing.T, expeditionID string, owner id.UserID, status s
}
}
// seatLeaderFixture seats the expedition's leader the way the invite path does —
// through seatLeader in its own transaction — reading the owner off the row that
// seedExpedition already wrote. Replaces the retired openParty helper.
func seatLeaderFixture(t *testing.T, expeditionID string) {
t.Helper()
tx, err := db.Get().Begin()
if err != nil {
t.Fatal(err)
}
if err := seatLeader(tx, expeditionID); err != nil {
_ = tx.Rollback()
t.Fatalf("seatLeader %s: %v", expeditionID, err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
}
func TestParty_SoloExpeditionHasNoRoster(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@solo:example.org")
@@ -53,10 +71,9 @@ func TestParty_OpenIsIdempotent(t *testing.T) {
owner := id.UserID("@lead:example.org")
seedExpedition(t, "exp-1", owner, "active")
// seatLeader is idempotent — a second call on the same expedition is a no-op.
for i := 0; i < 2; i++ {
if err := openParty("exp-1", owner); err != nil {
t.Fatalf("openParty #%d: %v", i+1, err)
}
seatLeaderFixture(t, "exp-1")
}
members, err := partyMembers("exp-1")
if err != nil {
@@ -71,9 +88,7 @@ func TestParty_JoinSeatsMembersLeaderFirst(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead2:example.org")
seedExpedition(t, "exp-2", owner, "active")
if err := openParty("exp-2", owner); err != nil {
t.Fatal(err)
}
seatLeaderFixture(t, "exp-2")
for _, u := range []id.UserID{"@b:x", "@c:x"} {
if err := joinParty("exp-2", u); err != nil {
t.Fatalf("joinParty %s: %v", u, err)
@@ -127,9 +142,7 @@ func TestParty_JoinRefusesFullRoster(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead3:example.org")
seedExpedition(t, "exp-3", owner, "active")
if err := openParty("exp-3", owner); err != nil {
t.Fatal(err)
}
seatLeaderFixture(t, "exp-3")
for _, u := range []id.UserID{"@b:x", "@c:x"} {
if err := joinParty("exp-3", u); err != nil {
t.Fatal(err)
@@ -145,9 +158,7 @@ func TestParty_JoinRefusesDuplicate(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead4:example.org")
seedExpedition(t, "exp-4", owner, "active")
if err := openParty("exp-4", owner); err != nil {
t.Fatal(err)
}
seatLeaderFixture(t, "exp-4")
if err := joinParty("exp-4", "@b:x"); err != nil {
t.Fatal(err)
}
@@ -167,12 +178,8 @@ func TestParty_JoinRefusesPlayerBusyElsewhere(t *testing.T) {
setupEmptyTestDB(t)
seedExpedition(t, "exp-5", "@lead5:example.org", "active")
seedExpedition(t, "exp-6", "@lead6:example.org", "active")
if err := openParty("exp-5", "@lead5:example.org"); err != nil {
t.Fatal(err)
}
if err := openParty("exp-6", "@lead6:example.org"); err != nil {
t.Fatal(err)
}
seatLeaderFixture(t, "exp-5")
seatLeaderFixture(t, "exp-6")
// Owns their own active expedition.
seedExpedition(t, "exp-own", "@busy:example.org", "active")
@@ -198,9 +205,7 @@ func TestParty_LeaveRemovesMemberButNotLeader(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead7:example.org")
seedExpedition(t, "exp-7", owner, "active")
if err := openParty("exp-7", owner); err != nil {
t.Fatal(err)
}
seatLeaderFixture(t, "exp-7")
if err := joinParty("exp-7", "@b:x"); err != nil {
t.Fatal(err)
}
@@ -217,9 +222,7 @@ func TestParty_LeaveRemovesMemberButNotLeader(t *testing.T) {
}
// Leaving frees the member for their own next run.
seedExpedition(t, "exp-7b", "@lead7b:example.org", "active")
if err := openParty("exp-7b", "@lead7b:example.org"); err != nil {
t.Fatal(err)
}
seatLeaderFixture(t, "exp-7b")
if err := joinParty("exp-7b", "@b:x"); err != nil {
t.Errorf("departed member could not rejoin elsewhere: %v", err)
}
@@ -229,9 +232,7 @@ func TestParty_DisbandFreesEveryone(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead8:example.org")
seedExpedition(t, "exp-8", owner, "complete")
if err := openParty("exp-8", owner); err != nil {
t.Fatal(err)
}
seatLeaderFixture(t, "exp-8")
if err := joinParty("exp-8", "@b:x"); err != nil {
t.Fatal(err)
}
@@ -242,9 +243,7 @@ func TestParty_DisbandFreesEveryone(t *testing.T) {
t.Errorf("after disband, partySize = %d, want 1 (no rows)", n)
}
seedExpedition(t, "exp-8b", "@lead8b:example.org", "active")
if err := openParty("exp-8b", "@lead8b:example.org"); err != nil {
t.Fatal(err)
}
seatLeaderFixture(t, "exp-8b")
if err := joinParty("exp-8b", "@b:x"); err != nil {
t.Errorf("disbanded member still looks busy: %v", err)
}
@@ -257,9 +256,7 @@ func TestParty_ActiveExpeditionForResolvesBothRoles(t *testing.T) {
owner := id.UserID("@lead9:example.org")
member := id.UserID("@member9:example.org")
seedExpedition(t, "exp-9", owner, "active")
if err := openParty("exp-9", owner); err != nil {
t.Fatal(err)
}
seatLeaderFixture(t, "exp-9")
if err := joinParty("exp-9", member); err != nil {
t.Fatal(err)
}

View File

@@ -579,18 +579,8 @@ func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction
return p.SendDM(ctx.Sender, "Equip cancelled.")
}
idx := 0
parsed := false
for _, c := range reply {
if c >= '0' && c <= '9' {
idx = idx*10 + int(c-'0')
parsed = true
} else {
break
}
}
idx-- // 1-indexed → 0-indexed
if !parsed || idx < 0 || idx >= len(data.Items) {
idx, ok := parseMenuIndex(reply, len(data.Items))
if !ok {
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, "Invalid selection. Reply with a number from the list, or \"cancel\".")
}

View File

@@ -0,0 +1,132 @@
package plugin
import (
"strings"
"testing"
"maunium.net/go/mautrix/id"
)
// captureSink is the test implementation of MessageSink. It records every
// outbound message a handler emits so a test can assert on a handler whose
// only observable output is a DM or a room announcement — the paths that had
// no seam below the live client before this. Install it with installSink.
type captureSink struct {
msgs []outboundMessage
}
func (s *captureSink) Capture(m outboundMessage) (id.EventID, error) {
s.msgs = append(s.msgs, m)
// A stable, non-empty id keeps the *ID send variants honest for callers
// that expect an event id back (e.g. one they would later edit).
return id.EventID("$sink"), nil
}
// dmsTo returns the text of every DM captured for a given user, in order.
func (s *captureSink) dmsTo(user id.UserID) []string {
var out []string
for _, m := range s.msgs {
if m.ToUser == user {
out = append(out, m.Text)
}
}
return out
}
// roomMsgs returns the text of every room message captured for a room.
func (s *captureSink) roomMsgs(room id.RoomID) []string {
var out []string
for _, m := range s.msgs {
if m.ToRoom == room {
out = append(out, m.Text)
}
}
return out
}
// installSink points a plugin's outbound sends at a fresh captureSink and
// returns it. The Base carries no client in these tests, so without the sink
// SendDM would silently no-op — which is exactly why these paths were untestable.
func installSink(p *AdventurePlugin) *captureSink {
s := &captureSink{}
p.Sink = s
return s
}
// TestMessageSink_CapturesDMAndRoom is the seam's own unit test: it proves both
// the DM and room-message routes divert into the sink with the right target and
// text, and that the *ID variants report back the id the sink chose.
func TestMessageSink_CapturesDMAndRoom(t *testing.T) {
p := &AdventurePlugin{}
sink := installSink(p)
user := id.UserID("@player:example.org")
room := id.RoomID("!hall:example.org")
if err := p.SendDM(user, "a direct message"); err != nil {
t.Fatalf("SendDM: %v", err)
}
if err := p.SendMessage(room, "a room announcement"); err != nil {
t.Fatalf("SendMessage: %v", err)
}
evID, err := p.SendDMID(user, "an editable dm")
if err != nil {
t.Fatalf("SendDMID: %v", err)
}
if evID != "$sink" {
t.Errorf("SendDMID returned %q, want the sink's id", evID)
}
if got := sink.dmsTo(user); len(got) != 2 || got[0] != "a direct message" || got[1] != "an editable dm" {
t.Errorf("DMs to %s = %v", user, got)
}
if got := sink.roomMsgs(room); len(got) != 1 || got[0] != "a room announcement" {
t.Errorf("room messages = %v", got)
}
// A DM must not leak into the room bucket, or vice versa.
if len(sink.roomMsgs(id.RoomID(user))) != 0 {
t.Error("a DM was miscaptured as a room message")
}
}
// TestExpeditionCmdLeave_DMsBothEnds drives the real handler behind review
// fix #2 end to end — the member soft-lock path — and asserts on the two DMs it
// sends. Before the sink this was unreachable in a unit test: SendDM no-ops
// without a client, so nothing could observe that the leader was notified and
// the departing member was told they turned back.
func TestExpeditionCmdLeave_DMsBothEnds(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead-leave:example.org")
member := id.UserID("@member-leave:example.org")
seedExpedition(t, "exp-leave", owner, "active")
seatLeaderFixture(t, "exp-leave")
if err := joinParty("exp-leave", member); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
sink := installSink(p)
if err := p.expeditionCmdLeave(MessageContext{Sender: member}); err != nil {
t.Fatalf("expeditionCmdLeave: %v", err)
}
// The departing member is freed and told so.
memberDMs := sink.dmsTo(member)
if len(memberDMs) != 1 || memberDMs[0] != "You turn back for town. Your supplies stay with the party." {
t.Errorf("member DMs = %v", memberDMs)
}
// The leader is notified their party shrank.
leaderDMs := sink.dmsTo(owner)
if len(leaderDMs) != 1 || leaderDMs[0] == "" {
t.Fatalf("leader DMs = %v, want one departure notice", leaderDMs)
}
if !strings.Contains(leaderDMs[0], "turned back") || !strings.Contains(leaderDMs[0], "supplies stay") {
t.Errorf("leader notice = %q", leaderDMs[0])
}
// And the DB actually reflects the departure the DMs described.
if n, _ := partySize("exp-leave"); n != 1 {
t.Errorf("after leave, partySize = %d, want 1", n)
}
}

View File

@@ -92,6 +92,30 @@ var (
type Base struct {
Client *mautrix.Client
Prefix string
// Sink, when non-nil, receives every outbound message (DM or room) the
// plugin would otherwise hand to the Matrix client, and the client is
// never touched. It is the test seam for handlers whose only observable
// output is a DM — beginCombatTurn's terminal close-out,
// expeditionCmdLeave, the arena season announcement. Production leaves it
// nil, so behaviour is unchanged. See captureSink in the test files.
Sink MessageSink
}
// outboundMessage is one message a handler tried to send. Exactly one of
// ToUser (a DM) or ToRoom (a room message) is set.
type outboundMessage struct {
ToUser id.UserID
ToRoom id.RoomID
Text string
}
// MessageSink captures a plugin's outbound messages in place of the live
// client. Capture returns the event ID to report back to the caller, so the
// *ID send variants keep working. Unexported param by design: only in-package
// tests implement it.
type MessageSink interface {
Capture(outboundMessage) (id.EventID, error)
}
// NewBase creates a Base with default prefix "!".
@@ -560,6 +584,10 @@ func textContent(text string) *event.MessageEventContent {
// SendMessage sends a message to a room, auto-formatting Markdown as HTML.
func (b *Base) SendMessage(roomID id.RoomID, text string) error {
if b != nil && b.Sink != nil {
_, err := b.Sink.Capture(outboundMessage{ToRoom: roomID, Text: text})
return err
}
content := textContent(text)
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
if err != nil {
@@ -570,6 +598,9 @@ func (b *Base) SendMessage(roomID id.RoomID, text string) error {
// SendMessageID sends a message and returns the event ID.
func (b *Base) SendMessageID(roomID id.RoomID, text string) (id.EventID, error) {
if b != nil && b.Sink != nil {
return b.Sink.Capture(outboundMessage{ToRoom: roomID, Text: text})
}
content := textContent(text)
resp, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
if err != nil {
@@ -707,6 +738,10 @@ func IsDMRoom(roomID id.RoomID, userID id.UserID) bool {
// No-op when the Matrix client is nil (which happens in unit tests that
// construct plugins without a real client).
func (b *Base) SendDM(userID id.UserID, text string) error {
if b != nil && b.Sink != nil {
_, err := b.Sink.Capture(outboundMessage{ToUser: userID, Text: text})
return err
}
if b == nil || b.Client == nil {
return nil
}
@@ -719,6 +754,9 @@ func (b *Base) SendDM(userID id.UserID, text string) error {
// SendDMID sends a direct message and returns the event ID (for later editing).
func (b *Base) SendDMID(userID id.UserID, text string) (id.EventID, error) {
if b != nil && b.Sink != nil {
return b.Sink.Capture(outboundMessage{ToUser: userID, Text: text})
}
roomID, err := b.GetDMRoom(userID)
if err != nil {
return "", err

View File

@@ -86,7 +86,24 @@ func (p *AdventurePlugin) runZoneCombatRoster(
continue
}
player, e, dndChar, err := p.buildZoneCombatants(uid, monster, tier, dmMood)
// A downed member sits it out. They own no close-out claim — and the
// check runs before the arm, so sitting out doesn't cost them the
// ability they had readied.
if !leader {
if hp, _ := dndHPSnapshot(uid); hp <= 0 {
continue
}
}
// Auto-resolve builds and fights in one breath, so this seat can arm and
// apply together. The turn-based path has to split the two — see
// consumeArmedAbility.
armed := ""
if c, cerr := LoadDnDCharacter(uid); cerr == nil && c != nil {
trySimAutoArm(c)
armed = consumeArmedAbility(c)
}
player, e, dndChar, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed)
if err != nil {
if leader {
return bail(err)
@@ -96,9 +113,6 @@ func (p *AdventurePlugin) runZoneCombatRoster(
}
if leader {
enemy = e
} else if hp, _ := dndHPSnapshot(uid); hp <= 0 {
// A downed member sits it out. They own no close-out claim.
continue
}
// Pre-combat one-shots that the turn-based path does NOT share: a queued
@@ -136,11 +150,8 @@ func (p *AdventurePlugin) runZoneCombatRoster(
}
}
p.grantCombatAchievements(b.uid, seatRes)
persistDnDHPAfterCombat(b.uid, seatRes.PlayerEndHP)
if err := persistDnDPostCombatSubclass(b.dndChar, b.mods.BerserkerRage, seatRes, b.mods); err != nil {
slog.Error("dnd: post-combat subclass persist (zone)", "user", b.uid, "err", err)
}
p.postCombatBookkeeping(b.uid, b.dndChar, b.mods.BerserkerRage, seatRes, b.mods)
if xp := zoneCombatXP(seatRes, monster.CR, tier); xp > 0 {
if _, err := p.grantDnDXP(b.uid, xp); err != nil {