1 Commits

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-10 14:24:35 +00:00
22 changed files with 141 additions and 669 deletions

4
go.mod
View File

@@ -15,7 +15,7 @@ require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/image v0.40.0
maunium.net/go/mautrix v0.28.1
modernc.org/sqlite v1.50.1
modernc.org/sqlite v1.53.0
)
require (
@@ -43,7 +43,7 @@ require (
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

20
go.sum
View File

@@ -166,20 +166,20 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.28.1 h1:Hic3oDMPbLbQu1fhboTRAKZcORMjzzkjxsa+SGk60b0=
maunium.net/go/mautrix v0.28.1/go.mod h1:mWXQNmOlrq4VTDU9f1HO03BSIswdUIyyY4wUKHqwzzY=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -188,8 +188,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -1,294 +0,0 @@
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,30 +10,6 @@ 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 {
@@ -111,7 +87,7 @@ func (p *AdventurePlugin) runDungeonCombat(
applySubclassPassives(&playerStats, &playerMods, dndChar)
applyMagicItemEffects(&playerStats, &playerMods, userID)
trySimAutoArm(dndChar)
if firedName, fired := armAbilityForFight(dndChar, &playerMods); fired {
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
}
applyDnDDungeonMonsterLayer(&enemyStats, loc.Tier)
@@ -153,8 +129,12 @@ func (p *AdventurePlugin) runDungeonCombat(
// until a player-driven use command lands.
consumeFiredHealingItems(userID, countHealEventsFired(result))
p.grantCombatAchievements(userID, result)
persistDnDHPAfterCombat(userID, result.PlayerEndHP)
p.postCombatBookkeeping(userID, dndChar, playerMods.BerserkerRage, result, playerMods)
if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage, result, playerMods); err != nil {
slog.Error("dnd: post-combat subclass persist (dungeon)", "user", userID, "err", err)
}
if xp := dungeonCombatXP(result, loc.Tier); xp > 0 {
if _, err := p.grantDnDXP(userID, xp); err != nil {

View File

@@ -332,10 +332,6 @@ 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

View File

@@ -51,12 +51,9 @@ 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, 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.
// Every seat's HP lands on their sheet regardless of how the fight ended.
for seat := range sess.RosterSize() {
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
p.postCombatBookkeepingForSeat(sess, seat)
}
switch sess.Status {

View File

@@ -58,10 +58,19 @@ 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 {
@@ -87,32 +96,7 @@ func (p *AdventurePlugin) buildFightSeats(
continue
}
// 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,
})
seats = append(seats, CombatSeatSetup{UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player})
}
return seats, enemy, senderSkip, ""
}

View File

@@ -88,13 +88,6 @@ 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"`
// 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.
@@ -228,26 +221,23 @@ 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, seat CombatSeatSetup) bool {
return seedActorOneShots(&s.Statuses.ActorStatuses, seat)
func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) bool {
return seedActorOneShots(&s.Statuses.ActorStatuses, playerMods)
}
// seedActorOneShots copies one character's fight-start one-shot resources onto
// their persisted statuses. Seat 0's live on the session row; a party member's
// live on their participant row, and each seat reads its own combatant's mods —
// a party Abjurer brings their own Arcane Ward, not the leader's.
func seedActorOneShots(st *ActorStatuses, seat CombatSeatSetup) bool {
playerMods := seat.Mods
func seedActorOneShots(st *ActorStatuses, playerMods CombatModifiers) bool {
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.ArmedAbility != ""
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0
}
// CombatParticipant is one party member's seat in a fight, from seat 1 up.
@@ -487,13 +477,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)
dirty := seedCombatSessionOneShots(sess, owner.Mods)
if len(seats) > 1 {
ps := make([]CombatParticipant, 0, len(seats)-1)
for i, s := range seats[1:] {
var st ActorStatuses
seedActorOneShots(&st, s)
seedActorOneShots(&st, s.Mods)
ps = append(ps, CombatParticipant{
Seat: i + 1, UserID: string(s.UserID), HP: s.HP, HPMax: s.HPMax, Statuses: st,
})
@@ -535,10 +525,6 @@ 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,18 +33,11 @@ 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)
@@ -71,7 +64,10 @@ func (p *AdventurePlugin) buildZoneCombatants(
applyRacePassives(&playerStats, &playerMods, dndChar)
applySubclassPassives(&playerStats, &playerMods, dndChar)
applyMagicItemEffects(&playerStats, &playerMods, userID)
applyAbilityByID(dndChar, armed, &playerMods)
trySimAutoArm(dndChar)
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
slog.Info("dnd: armed ability fired (turn-based build)", "user", userID, "ability", firedName)
}
enemyStats, enemyMods := monster.toCombatStats()
// Tier scaling mirrors runZoneCombat: only raise AC/AttackBonus to a
@@ -156,11 +152,7 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
players := make([]*Combatant, len(seats))
var enemy Combatant
for seat, uid := range seats {
// 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)
player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood)
if err != nil {
return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
}
@@ -169,7 +161,7 @@ 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, st)
applySessionBuffs(&player, sess.actorStatusesForSeat(seat))
players[seat] = &player
if seat == 0 {
// The enemy build reads only (monster, tier, dmMood): every seat
@@ -181,64 +173,6 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
return players, &enemy, nil
}
// seatFightStartMods re-derives the fight-start modifiers a finished fight's
// close-out still needs — today only the Berserker's rage flag, which decides
// whether the character owes a point of exhaustion.
//
// 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.
//
// GrimHarvestSlot stays zero here, and that is not an oversight: the turn-based
// path never runs applyPendingCast, so a Necromancy Mage's spell is never
// stashed and Grim Harvest cannot fire on this surface at all. Wiring the mage
// spell hooks into the turn-based `!cast` is a separate change.
func seatFightStartMods(sess *CombatSession, seat int, c *DnDCharacter) CombatModifiers {
var mods CombatModifiers
applyAbilityByID(c, sess.actorStatusesForSeat(seat).ArmedAbility, &mods)
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.
//
// SniperKilled and MistyHealed stay false because the turn engine has no Arina
// or Misty proc to set them: those two live only in the auto-resolve engine.
// 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
return CombatResult{
PlayerWon: won,
Events: eventsForSeat(sess.TurnLog, seat),
PlayerEndHP: hp,
EnemyEndHP: sess.EnemyHP,
TotalRounds: sess.Round,
NearDeath: won && hp > 0 && float64(hp) < float64(max(1, hpMax))*0.15,
}
}
// 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,23 +454,15 @@ 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, CombatSeatSetup{Mods: CombatModifiers{ArcaneWardHP: 15}}) {
if !seedCombatSessionOneShots(s, 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, CombatSeatSetup{}) {
if seedCombatSessionOneShots(empty, CombatModifiers{}) {
t.Error("seed should report false with no fight-start resources")
}
}

View File

@@ -410,52 +410,22 @@ func trySimAutoArm(c *DnDCharacter) string {
return ab.Name
}
// 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 {
// 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) {
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[abilityID]
ab, ok := dndActiveAbilities[c.ArmedAbility]
if !ok {
c.ArmedAbility = ""
_ = SaveDnDCharacter(c)
return "", false
}
ab.Apply(c, mods)
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)
firedName := ab.Name
c.ArmedAbility = ""
_ = SaveDnDCharacter(c)
return firedName, true
}

View File

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

@@ -321,42 +321,6 @@ 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

View File

@@ -190,18 +190,10 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
}
}
// 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 {
exp.Supplies.Current -= cost
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
return p.SendDM(ctx.Sender, "Couldn't deduct supplies: "+err.Error())
}
exp.Supplies = pooled
camp := &CampState{
Active: true,
Type: kind,

View File

@@ -82,39 +82,34 @@ 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
// 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))
}
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
}
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 {
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 {
return 0, err
}
if err := advanceExpeditionDay(e.ID); err != nil {

View File

@@ -176,30 +176,20 @@ const twoWeeksCacheSize = 3
func (p *AdventurePlugin) grantTwoWeeksCache(e *Expedition) string {
var lines []string
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
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
}
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,17 +143,13 @@ 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).
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 {
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 {
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

@@ -349,14 +349,12 @@ 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
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 {
ns := e.Supplies
ns.Current -= drain
if ns.Current < 0 {
ns.Current = 0
}
if err := updateSupplies(e.ID, ns); err != nil {
slog.Warn("expedition: ambient supply drain", "expedition", e.ID, "err", err)
return ""
}

View File

@@ -250,15 +250,10 @@ 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)
}
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
next := fresh.Supplies
next.Current -= cost
return next, nil
})
if err != nil {
exp.Supplies.Current -= cost
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
return "", err
}
exp.Supplies = pooled
camp := &CampState{
Active: true,
Type: d.Kind,

View File

@@ -178,13 +178,21 @@ func (p *AdventurePlugin) expeditionCmdAccept(ctx MessageContext, c *DnDCharacte
if isHol, _ := isHolidayToday(); isHol {
suppliesPurchase.StandardPacks++
}
// `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
})
// updateSupplies overwrites supplies_json wholesale, so the pool has to be
// re-read under the expedition's own lock: `exp` was fetched before the coin
// debit, and a second invitee accepting — or the leader's day-burn tick —
// may have rewritten the row since. Folding onto that stale snapshot would
// silently drop their packs or resurrect spent SU.
expMu := p.advExpeditionLock(exp.ID)
expMu.Lock()
fresh, err := getExpedition(exp.ID)
if err != nil || fresh == nil {
expMu.Unlock()
return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool.")
}
pooled := addSupplyPurchase(fresh.Supplies, suppliesPurchase)
err = updateSupplies(exp.ID, pooled)
expMu.Unlock()
if err != nil {
return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool: "+err.Error())
}

View File

@@ -86,24 +86,7 @@ func (p *AdventurePlugin) runZoneCombatRoster(
continue
}
// 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)
player, e, dndChar, err := p.buildZoneCombatants(uid, monster, tier, dmMood)
if err != nil {
if leader {
return bail(err)
@@ -113,6 +96,9 @@ 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
@@ -150,8 +136,11 @@ func (p *AdventurePlugin) runZoneCombatRoster(
}
}
p.grantCombatAchievements(b.uid, seatRes)
persistDnDHPAfterCombat(b.uid, seatRes.PlayerEndHP)
p.postCombatBookkeeping(b.uid, b.dndChar, b.mods.BerserkerRage, seatRes, b.mods)
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)
}
if xp := zoneCombatXP(seatRes, monster.CR, tier); xp > 0 {
if _, err := p.grantDnDXP(b.uid, xp); err != nil {