mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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
This commit is contained in:
294
internal/plugin/combat_armed_ability_test.go
Normal file
294
internal/plugin/combat_armed_ability_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,30 @@ import (
|
|||||||
"maunium.net/go/mautrix/id"
|
"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.
|
// grantCombatAchievements checks combat results for achievement-worthy moments.
|
||||||
func (p *AdventurePlugin) grantCombatAchievements(userID id.UserID, result CombatResult) {
|
func (p *AdventurePlugin) grantCombatAchievements(userID id.UserID, result CombatResult) {
|
||||||
if p.achievements == nil {
|
if p.achievements == nil {
|
||||||
@@ -87,7 +111,7 @@ func (p *AdventurePlugin) runDungeonCombat(
|
|||||||
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
||||||
applyMagicItemEffects(&playerStats, &playerMods, userID)
|
applyMagicItemEffects(&playerStats, &playerMods, userID)
|
||||||
trySimAutoArm(dndChar)
|
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)
|
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
|
||||||
}
|
}
|
||||||
applyDnDDungeonMonsterLayer(&enemyStats, loc.Tier)
|
applyDnDDungeonMonsterLayer(&enemyStats, loc.Tier)
|
||||||
@@ -129,12 +153,8 @@ func (p *AdventurePlugin) runDungeonCombat(
|
|||||||
// until a player-driven use command lands.
|
// until a player-driven use command lands.
|
||||||
consumeFiredHealingItems(userID, countHealEventsFired(result))
|
consumeFiredHealingItems(userID, countHealEventsFired(result))
|
||||||
|
|
||||||
p.grantCombatAchievements(userID, result)
|
|
||||||
|
|
||||||
persistDnDHPAfterCombat(userID, result.PlayerEndHP)
|
persistDnDHPAfterCombat(userID, result.PlayerEndHP)
|
||||||
if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage, result, playerMods); err != nil {
|
p.postCombatBookkeeping(userID, dndChar, playerMods.BerserkerRage, result, playerMods)
|
||||||
slog.Error("dnd: post-combat subclass persist (dungeon)", "user", userID, "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if xp := dungeonCombatXP(result, loc.Tier); xp > 0 {
|
if xp := dungeonCombatXP(result, loc.Tier); xp > 0 {
|
||||||
if _, err := p.grantDnDXP(userID, xp); err != nil {
|
if _, err := p.grantDnDXP(userID, xp); err != nil {
|
||||||
|
|||||||
@@ -332,6 +332,10 @@ func continueHint(userID id.UserID) string {
|
|||||||
// the room's manual combat is done, and a fresh !zone advance clears the room.
|
// 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 {
|
func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSession, enemy Combatant) string {
|
||||||
persistDnDHPAfterCombat(userID, sess.PlayerHP)
|
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)
|
run, _ := getZoneRun(sess.RunID)
|
||||||
var zone ZoneDefinition
|
var zone ZoneDefinition
|
||||||
|
|||||||
@@ -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.
|
// nat20/nat1 mood deltas from the whole fight's event log — the run's, so once.
|
||||||
scanMoodEventsFromEvents(sess.RunID, sess.TurnLog)
|
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() {
|
for seat := range sess.RosterSize() {
|
||||||
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
|
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
|
||||||
|
p.postCombatBookkeepingForSeat(sess, seat)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch sess.Status {
|
switch sess.Status {
|
||||||
|
|||||||
@@ -58,19 +58,10 @@ func (p *AdventurePlugin) buildFightSeats(
|
|||||||
}
|
}
|
||||||
for i, uid := range roster {
|
for i, uid := range roster {
|
||||||
leader := i == 0
|
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)
|
hp, hpMax := dndHPSnapshot(uid)
|
||||||
if hp <= 0 {
|
if hp <= 0 {
|
||||||
if leader {
|
if leader {
|
||||||
@@ -96,7 +87,32 @@ func (p *AdventurePlugin) buildFightSeats(
|
|||||||
continue
|
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, ""
|
return seats, enemy, senderSkip, ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ type ActorStatuses struct {
|
|||||||
// and druids with no sustained DPS once their burst landed.
|
// and druids with no sustained DPS once their burst landed.
|
||||||
ConcentrationDmg int `json:"concentration_dmg,omitempty"`
|
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.
|
// Once-per-fight class/race/subclass one-shots: the "already used" flags.
|
||||||
// Without persistence these reset every round on resume, letting a Halfling
|
// Without persistence these reset every round on resume, letting a Halfling
|
||||||
// reroll a nat 1 or an Orc rage every single round of a turn-based fight.
|
// reroll a nat 1 or an Orc rage every single round of a turn-based fight.
|
||||||
@@ -221,23 +228,26 @@ func (s *ActorStatuses) applyBuffDelta(d turnBuffDelta) {
|
|||||||
// the Abjuration Arcane Ward is normally non-zero at fight start — the
|
// the Abjuration Arcane Ward is normally non-zero at fight start — the
|
||||||
// turn-based build deliberately omits pre-combat consumables and queued casts —
|
// turn-based build deliberately omits pre-combat consumables and queued casts —
|
||||||
// but the full set is seeded for robustness. Returns true if anything was set.
|
// but the full set is seeded for robustness. Returns true if anything was set.
|
||||||
func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) bool {
|
func seedCombatSessionOneShots(s *CombatSession, seat CombatSeatSetup) bool {
|
||||||
return seedActorOneShots(&s.Statuses.ActorStatuses, playerMods)
|
return seedActorOneShots(&s.Statuses.ActorStatuses, seat)
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedActorOneShots copies one character's fight-start one-shot resources onto
|
// 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
|
// 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 —
|
// 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.
|
// 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.WardCharges = playerMods.WardCharges
|
||||||
st.SporeRounds = playerMods.SporeCloud
|
st.SporeRounds = playerMods.SporeCloud
|
||||||
st.ReflectFrac = playerMods.ReflectNext
|
st.ReflectFrac = playerMods.ReflectNext
|
||||||
st.AutoCritFirst = playerMods.AutoCritFirst
|
st.AutoCritFirst = playerMods.AutoCritFirst
|
||||||
st.ArcaneWardHP = playerMods.ArcaneWardHP
|
st.ArcaneWardHP = playerMods.ArcaneWardHP
|
||||||
st.HealChargesLeft = playerMods.HealItemCharges
|
st.HealChargesLeft = playerMods.HealItemCharges
|
||||||
|
st.ArmedAbility = seat.ArmedAbility
|
||||||
return st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
|
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.
|
// CombatParticipant is one party member's seat in a fight, from seat 1 up.
|
||||||
@@ -477,13 +487,13 @@ func (p *AdventurePlugin) startPartyCombatSession(
|
|||||||
|
|
||||||
// Seat 0's one-shots live on the session row; seeding them is a mutation of
|
// 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.
|
// sess.Statuses that the save below flushes along with the participants.
|
||||||
dirty := seedCombatSessionOneShots(sess, owner.Mods)
|
dirty := seedCombatSessionOneShots(sess, owner)
|
||||||
|
|
||||||
if len(seats) > 1 {
|
if len(seats) > 1 {
|
||||||
ps := make([]CombatParticipant, 0, len(seats)-1)
|
ps := make([]CombatParticipant, 0, len(seats)-1)
|
||||||
for i, s := range seats[1:] {
|
for i, s := range seats[1:] {
|
||||||
var st ActorStatuses
|
var st ActorStatuses
|
||||||
seedActorOneShots(&st, s.Mods)
|
seedActorOneShots(&st, s)
|
||||||
ps = append(ps, CombatParticipant{
|
ps = append(ps, CombatParticipant{
|
||||||
Seat: i + 1, UserID: string(s.UserID), HP: s.HP, HPMax: s.HPMax, Statuses: st,
|
Seat: i + 1, UserID: string(s.UserID), HP: s.HP, HPMax: s.HPMax, Statuses: st,
|
||||||
})
|
})
|
||||||
@@ -525,6 +535,10 @@ type CombatSeatSetup struct {
|
|||||||
HPMax int
|
HPMax int
|
||||||
Mods CombatModifiers
|
Mods CombatModifiers
|
||||||
C *Combatant
|
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).
|
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil).
|
||||||
|
|||||||
@@ -33,11 +33,18 @@ import (
|
|||||||
// + armed ability) and the monster's bestiary stat block with tier scaling and
|
// + 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
|
// the DM-mood combat tilt folded in. The returned dndChar is handed back so
|
||||||
// callers can run post-combat subclass persistence without reloading it.
|
// 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(
|
func (p *AdventurePlugin) buildZoneCombatants(
|
||||||
userID id.UserID,
|
userID id.UserID,
|
||||||
monster DnDMonsterTemplate,
|
monster DnDMonsterTemplate,
|
||||||
tier int,
|
tier int,
|
||||||
dmMood int,
|
dmMood int,
|
||||||
|
armed string,
|
||||||
) (player Combatant, enemy Combatant, dndChar *DnDCharacter, err error) {
|
) (player Combatant, enemy Combatant, dndChar *DnDCharacter, err error) {
|
||||||
tilt := dmMoodCombatTilt(dmMood)
|
tilt := dmMoodCombatTilt(dmMood)
|
||||||
char, err := loadAdvCharacter(userID)
|
char, err := loadAdvCharacter(userID)
|
||||||
@@ -64,10 +71,7 @@ func (p *AdventurePlugin) buildZoneCombatants(
|
|||||||
applyRacePassives(&playerStats, &playerMods, dndChar)
|
applyRacePassives(&playerStats, &playerMods, dndChar)
|
||||||
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
||||||
applyMagicItemEffects(&playerStats, &playerMods, userID)
|
applyMagicItemEffects(&playerStats, &playerMods, userID)
|
||||||
trySimAutoArm(dndChar)
|
applyAbilityByID(dndChar, armed, &playerMods)
|
||||||
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
|
||||||
slog.Info("dnd: armed ability fired (turn-based build)", "user", userID, "ability", firedName)
|
|
||||||
}
|
|
||||||
|
|
||||||
enemyStats, enemyMods := monster.toCombatStats()
|
enemyStats, enemyMods := monster.toCombatStats()
|
||||||
// Tier scaling mirrors runZoneCombat: only raise AC/AttackBonus to a
|
// 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))
|
players := make([]*Combatant, len(seats))
|
||||||
var enemy Combatant
|
var enemy Combatant
|
||||||
for seat, uid := range seats {
|
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 {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
|
return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
|
||||||
}
|
}
|
||||||
@@ -161,7 +169,7 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
|
|||||||
// one-shots (ward/spore/…) live on their persisted statuses and flow
|
// one-shots (ward/spore/…) live on their persisted statuses and flow
|
||||||
// through the turn engine's resume/commit cycle, so only the persistent
|
// through the turn engine's resume/commit cycle, so only the persistent
|
||||||
// stat deltas are applied here — and only that seat's own.
|
// stat deltas are applied here — and only that seat's own.
|
||||||
applySessionBuffs(&player, sess.actorStatusesForSeat(seat))
|
applySessionBuffs(&player, st)
|
||||||
players[seat] = &player
|
players[seat] = &player
|
||||||
if seat == 0 {
|
if seat == 0 {
|
||||||
// The enemy build reads only (monster, tier, dmMood): every seat
|
// The enemy build reads only (monster, tier, dmMood): every seat
|
||||||
@@ -173,6 +181,64 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
|
|||||||
return players, &enemy, nil
|
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
|
// applySessionBuffs re-derives the persistent stat effect of every mid-fight
|
||||||
// buff onto one rebuilt character. The buffs are stored as accumulated deltas
|
// buff onto one rebuilt character. The buffs are stored as accumulated deltas
|
||||||
// (diffTurnBuff produced them against that character's state at cast time), so
|
// (diffTurnBuff produced them against that character's state at cast time), so
|
||||||
|
|||||||
@@ -454,15 +454,23 @@ func TestApplySessionBuffs(t *testing.T) {
|
|||||||
func TestSeedCombatSessionOneShots(t *testing.T) {
|
func TestSeedCombatSessionOneShots(t *testing.T) {
|
||||||
// Arcane Ward is the one resource normally live at fight start.
|
// Arcane Ward is the one resource normally live at fight start.
|
||||||
s := &CombatSession{}
|
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")
|
t.Error("seed should report true when Arcane Ward is present")
|
||||||
}
|
}
|
||||||
if s.Statuses.ArcaneWardHP != 15 {
|
if s.Statuses.ArcaneWardHP != 15 {
|
||||||
t.Errorf("ArcaneWardHP = %d, want 15", s.Statuses.ArcaneWardHP)
|
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.
|
// Nothing to seed → false, statuses untouched.
|
||||||
empty := &CombatSession{}
|
empty := &CombatSession{}
|
||||||
if seedCombatSessionOneShots(empty, CombatModifiers{}) {
|
if seedCombatSessionOneShots(empty, CombatSeatSetup{}) {
|
||||||
t.Error("seed should report false with no fight-start resources")
|
t.Error("seed should report false with no fight-start resources")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -410,22 +410,52 @@ func trySimAutoArm(c *DnDCharacter) string {
|
|||||||
return ab.Name
|
return ab.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyArmedAbility checks for a pre-armed ability on the character and
|
// consumeArmedAbility disarms the character, returning the ability id that was
|
||||||
// applies its effect to the player's CombatModifiers, then clears the armed
|
// armed. It is the *mutating* half of arming: the flag is cleared and saved, so
|
||||||
// flag. Called from combat_bridge.go before SimulateCombat.
|
// it must run exactly once per fight, at fight start.
|
||||||
func applyArmedAbility(c *DnDCharacter, mods *CombatModifiers) (string, bool) {
|
//
|
||||||
|
// 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 == "" {
|
if c == nil || c.ArmedAbility == "" {
|
||||||
return "", false
|
return ""
|
||||||
}
|
}
|
||||||
ab, ok := dndActiveAbilities[c.ArmedAbility]
|
armed := c.ArmedAbility
|
||||||
if !ok {
|
|
||||||
c.ArmedAbility = ""
|
c.ArmedAbility = ""
|
||||||
_ = SaveDnDCharacter(c)
|
_ = 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]
|
||||||
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
ab.Apply(c, mods)
|
ab.Apply(c, mods)
|
||||||
firedName := ab.Name
|
return ab.Name, true
|
||||||
c.ArmedAbility = ""
|
}
|
||||||
_ = SaveDnDCharacter(c)
|
|
||||||
return firedName, 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ func TestSpendAndRefreshResource(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyArmedAbility_FighterSecondWind(t *testing.T) {
|
func TestArmAbilityForFight_FighterSecondWind(t *testing.T) {
|
||||||
setupAbilitiesTestDB(t)
|
setupAbilitiesTestDB(t)
|
||||||
uid := id.UserID("@arm_fighter:example")
|
uid := id.UserID("@arm_fighter:example")
|
||||||
c := &DnDCharacter{
|
c := &DnDCharacter{
|
||||||
@@ -162,7 +162,7 @@ func TestApplyArmedAbility_FighterSecondWind(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mods := CombatModifiers{}
|
mods := CombatModifiers{}
|
||||||
name, fired := applyArmedAbility(c, &mods)
|
name, fired := armAbilityForFight(c, &mods)
|
||||||
if !fired {
|
if !fired {
|
||||||
t.Fatal("ability did not fire")
|
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)
|
setupAbilitiesTestDB(t)
|
||||||
uid := id.UserID("@arm_mage:example")
|
uid := id.UserID("@arm_mage:example")
|
||||||
c := &DnDCharacter{
|
c := &DnDCharacter{
|
||||||
@@ -192,7 +192,7 @@ func TestApplyArmedAbility_MageMagicMissile(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
mods := CombatModifiers{}
|
mods := CombatModifiers{}
|
||||||
_, fired := applyArmedAbility(c, &mods)
|
_, fired := armAbilityForFight(c, &mods)
|
||||||
if !fired {
|
if !fired {
|
||||||
t.Fatal("magic missile did not fire")
|
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: ""}
|
c := &DnDCharacter{Class: ClassFighter, ArmedAbility: ""}
|
||||||
mods := CombatModifiers{}
|
mods := CombatModifiers{}
|
||||||
_, fired := applyArmedAbility(c, &mods)
|
_, fired := armAbilityForFight(c, &mods)
|
||||||
if fired {
|
if fired {
|
||||||
t.Error("fired with no armed ability")
|
t.Error("fired with no armed ability")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import (
|
|||||||
//
|
//
|
||||||
// Bypassed deliberately (Phase 0 simplifying constraints, doc §2):
|
// 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
|
// the SaveDnDCharacter inside applyPendingCast. The harness is pure
|
||||||
// Go; tests run without a sqlite instance.
|
// Go; tests run without a sqlite instance.
|
||||||
// - Race passives beyond Human (+1 all): neutral baseline, again per §2.
|
// - Race passives beyond Human (+1 all): neutral baseline, again per §2.
|
||||||
|
|||||||
@@ -321,6 +321,42 @@ func updateSupplies(expID string, s ExpeditionSupplies) error {
|
|||||||
return err
|
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.
|
// updateCamp persists camp state. Pass nil to break camp.
|
||||||
func updateCamp(expID string, c *CampState) error {
|
func updateCamp(expID string, c *CampState) error {
|
||||||
var arg any
|
var arg any
|
||||||
|
|||||||
@@ -190,10 +190,18 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exp.Supplies.Current -= cost
|
// The affordability check above ran against `exp` as it was read, and
|
||||||
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
|
// 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())
|
return p.SendDM(ctx.Sender, "Couldn't deduct supplies: "+err.Error())
|
||||||
}
|
}
|
||||||
|
exp.Supplies = pooled
|
||||||
camp := &CampState{
|
camp := &CampState{
|
||||||
Active: true,
|
Active: true,
|
||||||
Type: kind,
|
Type: kind,
|
||||||
|
|||||||
@@ -82,34 +82,39 @@ type nightRolloverResult struct {
|
|||||||
// to finish the rollover; legacy deliverBriefing interleaves processOvernightCamp
|
// to finish the rollover; legacy deliverBriefing interleaves processOvernightCamp
|
||||||
// between the two so a fortified camp's −5 lands before drift's +3.
|
// between the two so a fortified camp's −5 lands before drift's +3.
|
||||||
func (p *AdventurePlugin) nightRolloverBurn(e *Expedition) (float32, error) {
|
func (p *AdventurePlugin) nightRolloverBurn(e *Expedition) (float32, error) {
|
||||||
|
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
|
// 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
|
// today's supplies, not tomorrow's. Logged so the end-of-day digest
|
||||||
// can surface the gain; pure no-op for non-Ranger characters.
|
// can surface the gain; pure no-op for non-Ranger characters.
|
||||||
if c, err := LoadDnDCharacter(id.UserID(e.UserID)); err == nil && c != nil {
|
if c, cerr := LoadDnDCharacter(id.UserID(fresh.UserID)); cerr == nil && c != nil {
|
||||||
if gain := applyRangerForage(e, c, nil); gain > 0 {
|
if gain := applyRangerForage(fresh, c, nil); gain > 0 {
|
||||||
_ = appendExpeditionLog(e.ID, e.CurrentDay, "forage",
|
_ = appendExpeditionLog(fresh.ID, fresh.CurrentDay, "forage",
|
||||||
fmt.Sprintf("ranger forage +%g SU", gain),
|
fmt.Sprintf("ranger forage +%g SU", gain),
|
||||||
flavor.Pick(flavor.HarvestForageSuccess))
|
flavor.Pick(flavor.HarvestForageSuccess))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
burnOverride := applyZoneTemporalPreBurn(e, e.CurrentDay+1)
|
burnOverride := applyZoneTemporalPreBurn(fresh, fresh.CurrentDay+1)
|
||||||
var newSupplies ExpeditionSupplies
|
|
||||||
var burn float32
|
|
||||||
if burnOverride.Multiplier > 0 {
|
if burnOverride.Multiplier > 0 {
|
||||||
// The temporal override replaces the harsh/siege multiplier, not the
|
// 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.
|
// 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
|
burn = fresh.Supplies.DailyBurn * burnOverride.Multiplier * float32(expeditionBurnRatePct(fresh.ID)) / 100
|
||||||
newSupplies = e.Supplies
|
next := fresh.Supplies
|
||||||
newSupplies.Current -= burn
|
next.Current -= burn
|
||||||
if newSupplies.Current < 0 {
|
if next.Current < 0 {
|
||||||
newSupplies.Current = 0
|
next.Current = 0
|
||||||
}
|
}
|
||||||
newSupplies.ForagedToday = false
|
next.ForagedToday = false
|
||||||
} else {
|
return next, nil
|
||||||
harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e)
|
|
||||||
newSupplies, burn = applyExpeditionDailyBurn(e, harsh, e.SiegeMode)
|
|
||||||
}
|
}
|
||||||
if err := updateSupplies(e.ID, newSupplies); err != 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
|
return 0, err
|
||||||
}
|
}
|
||||||
if err := advanceExpeditionDay(e.ID); err != nil {
|
if err := advanceExpeditionDay(e.ID); err != nil {
|
||||||
|
|||||||
@@ -176,20 +176,30 @@ const twoWeeksCacheSize = 3
|
|||||||
func (p *AdventurePlugin) grantTwoWeeksCache(e *Expedition) string {
|
func (p *AdventurePlugin) grantTwoWeeksCache(e *Expedition) string {
|
||||||
var lines []string
|
var lines []string
|
||||||
|
|
||||||
if s := e.Supplies; s.DailyBurn > 0 {
|
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
|
s.Current += twoWeeksRestockDays * s.DailyBurn
|
||||||
if s.Max > 0 && s.Current > s.Max {
|
if s.Max > 0 && s.Current > s.Max {
|
||||||
s.Current = s.Max
|
s.Current = s.Max
|
||||||
}
|
}
|
||||||
if s.Current > e.Supplies.Current {
|
restocked = s.Current > fresh.Supplies.Current
|
||||||
if err := updateSupplies(e.ID, s); err != nil {
|
return s, nil
|
||||||
|
})
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
slog.Error("milestone: two weeks restock failed",
|
slog.Error("milestone: two weeks restock failed",
|
||||||
"expedition", e.ID, "err", err)
|
"expedition", e.ID, "err", err)
|
||||||
} else {
|
case restocked:
|
||||||
lines = append(lines, fmt.Sprintf(
|
lines = append(lines, fmt.Sprintf(
|
||||||
"📦 Supplies restocked — %.1f of %.1f.", s.Current, s.Max))
|
"📦 Supplies restocked — %.1f of %.1f.", pooled.Current, pooled.Max))
|
||||||
e.Supplies = s
|
e.Supplies = pooled
|
||||||
}
|
default:
|
||||||
|
e.Supplies = pooled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,13 +143,17 @@ func (p *AdventurePlugin) regionCmdTravel(ctx MessageContext, exp *Expedition) e
|
|||||||
// on both paths.
|
// on both paths.
|
||||||
func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition, cur, next ExpeditionRegion) (string, error) {
|
func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition, cur, next ExpeditionRegion) (string, error) {
|
||||||
// Burn one day of supplies (transit day).
|
// Burn one day of supplies (transit day).
|
||||||
siege := exp.SiegeMode
|
var burned float32
|
||||||
harsh := exp.ThreatLevel > 60 || zoneTemporalHarsh(exp)
|
newSupplies, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||||
newSupplies, burned := applyExpeditionDailyBurn(exp, harsh, siege)
|
harsh := fresh.ThreatLevel > 60 || zoneTemporalHarsh(fresh)
|
||||||
exp.Supplies = newSupplies
|
next, b := applyExpeditionDailyBurn(fresh, harsh, fresh.SiegeMode)
|
||||||
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
|
burned = b
|
||||||
|
return next, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
return "", fmt.Errorf("apply transit supply burn: %w", err)
|
return "", fmt.Errorf("apply transit supply burn: %w", err)
|
||||||
}
|
}
|
||||||
|
exp.Supplies = newSupplies
|
||||||
if err := advanceExpeditionDay(exp.ID); err != nil {
|
if err := advanceExpeditionDay(exp.ID); err != nil {
|
||||||
return "", fmt.Errorf("advance expedition day: %w", err)
|
return "", fmt.Errorf("advance expedition day: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -349,12 +349,14 @@ func (p *AdventurePlugin) applyAmbientEffect(e *Expedition, ev ambientEvent) str
|
|||||||
return "Something out there is paying attention now."
|
return "Something out there is paying attention now."
|
||||||
case "pack_rat":
|
case "pack_rat":
|
||||||
drain := float32(0.2) + float32(rng.IntN(2))*float32(0.1) // 0.2 or 0.3
|
drain := float32(0.2) + float32(rng.IntN(2))*float32(0.1) // 0.2 or 0.3
|
||||||
ns := e.Supplies
|
if _, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||||
|
ns := fresh.Supplies
|
||||||
ns.Current -= drain
|
ns.Current -= drain
|
||||||
if ns.Current < 0 {
|
if ns.Current < 0 {
|
||||||
ns.Current = 0
|
ns.Current = 0
|
||||||
}
|
}
|
||||||
if err := updateSupplies(e.ID, ns); err != nil {
|
return ns, nil
|
||||||
|
}); err != nil {
|
||||||
slog.Warn("expedition: ambient supply drain", "expedition", e.ID, "err", err)
|
slog.Warn("expedition: ambient supply drain", "expedition", e.ID, "err", err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,10 +250,15 @@ func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision
|
|||||||
if exp.Supplies.Current < cost {
|
if exp.Supplies.Current < cost {
|
||||||
return "", fmt.Errorf("insufficient supplies (have %.1f, need %.1f)", exp.Supplies.Current, cost)
|
return "", fmt.Errorf("insufficient supplies (have %.1f, need %.1f)", exp.Supplies.Current, cost)
|
||||||
}
|
}
|
||||||
exp.Supplies.Current -= cost
|
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||||
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
|
next := fresh.Supplies
|
||||||
|
next.Current -= cost
|
||||||
|
return next, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
exp.Supplies = pooled
|
||||||
camp := &CampState{
|
camp := &CampState{
|
||||||
Active: true,
|
Active: true,
|
||||||
Type: d.Kind,
|
Type: d.Kind,
|
||||||
|
|||||||
@@ -178,21 +178,13 @@ func (p *AdventurePlugin) expeditionCmdAccept(ctx MessageContext, c *DnDCharacte
|
|||||||
if isHol, _ := isHolidayToday(); isHol {
|
if isHol, _ := isHolidayToday(); isHol {
|
||||||
suppliesPurchase.StandardPacks++
|
suppliesPurchase.StandardPacks++
|
||||||
}
|
}
|
||||||
// updateSupplies overwrites supplies_json wholesale, so the pool has to be
|
// `exp` was fetched before the coin debit, and a second invitee accepting —
|
||||||
// re-read under the expedition's own lock: `exp` was fetched before the coin
|
// or the leader's day-burn tick — may have rewritten the row since. Folding
|
||||||
// debit, and a second invitee accepting — or the leader's day-burn tick —
|
// onto that stale snapshot would silently drop their packs or resurrect
|
||||||
// may have rewritten the row since. Folding onto that stale snapshot would
|
// spent SU, so the fold happens against a fresh read under the pool's lock.
|
||||||
// silently drop their packs or resurrect spent SU.
|
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||||
expMu := p.advExpeditionLock(exp.ID)
|
return addSupplyPurchase(fresh.Supplies, suppliesPurchase), nil
|
||||||
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 {
|
if err != nil {
|
||||||
return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool: "+err.Error())
|
return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool: "+err.Error())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,24 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
|||||||
continue
|
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 err != nil {
|
||||||
if leader {
|
if leader {
|
||||||
return bail(err)
|
return bail(err)
|
||||||
@@ -96,9 +113,6 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
|||||||
}
|
}
|
||||||
if leader {
|
if leader {
|
||||||
enemy = e
|
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
|
// 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)
|
persistDnDHPAfterCombat(b.uid, seatRes.PlayerEndHP)
|
||||||
if err := persistDnDPostCombatSubclass(b.dndChar, b.mods.BerserkerRage, seatRes, b.mods); err != nil {
|
p.postCombatBookkeeping(b.uid, b.dndChar, b.mods.BerserkerRage, seatRes, b.mods)
|
||||||
slog.Error("dnd: post-combat subclass persist (zone)", "user", b.uid, "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if xp := zoneCombatXP(seatRes, monster.CR, tier); xp > 0 {
|
if xp := zoneCombatXP(seatRes, monster.CR, tier); xp > 0 {
|
||||||
if _, err := p.grantDnDXP(b.uid, xp); err != nil {
|
if _, err := p.grantDnDXP(b.uid, xp); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user