mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +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"
|
||||
)
|
||||
|
||||
// postCombatBookkeeping is the close-out every fight owes its character,
|
||||
// whatever surface it was fought on: achievements, and the subclass state that
|
||||
// outlives the fight (Berserker exhaustion, Grim Harvest's kill-heal).
|
||||
//
|
||||
// It exists because there are four close-outs — two auto-resolve
|
||||
// (runDungeonCombat, runZoneCombatRoster) and two turn-based
|
||||
// (finishCombatSession, finishPartyCombatSession) — and for a long time only
|
||||
// the auto-resolve pair ran any of this. The same Elite kill therefore paid out
|
||||
// differently depending on whether the player let it auto-resolve or fought it
|
||||
// a round at a time. Route all four through here and the divergence cannot
|
||||
// silently reopen.
|
||||
//
|
||||
// raged is whether the character's Berserker rage was active for this fight;
|
||||
// mods carries the fight-start modifiers Grim Harvest reads. HP persistence is
|
||||
// NOT done here — the turn-based paths already own their own HP writes.
|
||||
func (p *AdventurePlugin) postCombatBookkeeping(
|
||||
userID id.UserID, dndChar *DnDCharacter, raged bool, result CombatResult, mods CombatModifiers,
|
||||
) {
|
||||
p.grantCombatAchievements(userID, result)
|
||||
if err := persistDnDPostCombatSubclass(dndChar, raged, result, mods); err != nil {
|
||||
slog.Error("dnd: post-combat subclass persist", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// grantCombatAchievements checks combat results for achievement-worthy moments.
|
||||
func (p *AdventurePlugin) grantCombatAchievements(userID id.UserID, result CombatResult) {
|
||||
if p.achievements == nil {
|
||||
@@ -87,7 +111,7 @@ func (p *AdventurePlugin) runDungeonCombat(
|
||||
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
||||
applyMagicItemEffects(&playerStats, &playerMods, userID)
|
||||
trySimAutoArm(dndChar)
|
||||
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
||||
if firedName, fired := armAbilityForFight(dndChar, &playerMods); fired {
|
||||
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
|
||||
}
|
||||
applyDnDDungeonMonsterLayer(&enemyStats, loc.Tier)
|
||||
@@ -129,12 +153,8 @@ func (p *AdventurePlugin) runDungeonCombat(
|
||||
// until a player-driven use command lands.
|
||||
consumeFiredHealingItems(userID, countHealEventsFired(result))
|
||||
|
||||
p.grantCombatAchievements(userID, result)
|
||||
|
||||
persistDnDHPAfterCombat(userID, result.PlayerEndHP)
|
||||
if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage, result, playerMods); err != nil {
|
||||
slog.Error("dnd: post-combat subclass persist (dungeon)", "user", userID, "err", err)
|
||||
}
|
||||
p.postCombatBookkeeping(userID, dndChar, playerMods.BerserkerRage, result, playerMods)
|
||||
|
||||
if xp := dungeonCombatXP(result, loc.Tier); xp > 0 {
|
||||
if _, err := p.grantDnDXP(userID, xp); err != nil {
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
|
||||
@@ -51,9 +51,12 @@ func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
|
||||
// nat20/nat1 mood deltas from the whole fight's event log — the run's, so once.
|
||||
scanMoodEventsFromEvents(sess.RunID, sess.TurnLog)
|
||||
|
||||
// Every seat's HP lands on their sheet regardless of how the fight ended.
|
||||
// Every seat's HP lands on their sheet regardless of how the fight ended, and
|
||||
// so does the bookkeeping that outlives the fight — a Berserker who raged and
|
||||
// lost is still exhausted. Both fan out; neither is the owner's alone.
|
||||
for seat := range sess.RosterSize() {
|
||||
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
|
||||
p.postCombatBookkeepingForSeat(sess, seat)
|
||||
}
|
||||
|
||||
switch sess.Status {
|
||||
|
||||
@@ -58,19 +58,10 @@ func (p *AdventurePlugin) buildFightSeats(
|
||||
}
|
||||
for i, uid := range roster {
|
||||
leader := i == 0
|
||||
player, e, _, err := p.buildZoneCombatants(uid, monster, tier, dmMood)
|
||||
if err != nil {
|
||||
if leader {
|
||||
return nil, nil, "", "Couldn't set up the fight: " + err.Error()
|
||||
}
|
||||
slog.Warn("combat: party member left out of fight", "user", uid, "err", err)
|
||||
skip(uid, "Couldn't bring you into the fight: "+err.Error())
|
||||
continue
|
||||
}
|
||||
if leader {
|
||||
enemy = &e
|
||||
}
|
||||
|
||||
// Both refusals below are cheap and neither needs the build, so they run
|
||||
// before it: consuming a seat's armed ability and *then* sitting them out
|
||||
// would spend their rage on a fight they never joined.
|
||||
hp, hpMax := dndHPSnapshot(uid)
|
||||
if hp <= 0 {
|
||||
if leader {
|
||||
@@ -96,7 +87,32 @@ func (p *AdventurePlugin) buildFightSeats(
|
||||
continue
|
||||
}
|
||||
|
||||
seats = append(seats, CombatSeatSetup{UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player})
|
||||
// Consumed exactly once for the fight, here. Every later rebuild
|
||||
// re-applies this id off the seat's statuses rather than re-arming.
|
||||
armed := ""
|
||||
if c, cerr := LoadDnDCharacter(uid); cerr == nil && c != nil {
|
||||
trySimAutoArm(c)
|
||||
armed = consumeArmedAbility(c)
|
||||
}
|
||||
player, e, _, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed)
|
||||
if err != nil {
|
||||
if leader {
|
||||
return nil, nil, "", "Couldn't set up the fight: " + err.Error()
|
||||
}
|
||||
slog.Warn("combat: party member left out of fight", "user", uid, "err", err)
|
||||
skip(uid, "Couldn't bring you into the fight: "+err.Error())
|
||||
continue
|
||||
}
|
||||
if leader {
|
||||
enemy = &e
|
||||
}
|
||||
if armed != "" {
|
||||
slog.Info("combat: armed ability fired (turn-based start)", "user", uid, "ability", armed)
|
||||
}
|
||||
|
||||
seats = append(seats, CombatSeatSetup{
|
||||
UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player, ArmedAbility: armed,
|
||||
})
|
||||
}
|
||||
return seats, enemy, senderSkip, ""
|
||||
}
|
||||
|
||||
@@ -88,6 +88,13 @@ 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.
|
||||
@@ -221,23 +228,26 @@ func (s *ActorStatuses) applyBuffDelta(d turnBuffDelta) {
|
||||
// the Abjuration Arcane Ward is normally non-zero at fight start — the
|
||||
// turn-based build deliberately omits pre-combat consumables and queued casts —
|
||||
// but the full set is seeded for robustness. Returns true if anything was set.
|
||||
func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) bool {
|
||||
return seedActorOneShots(&s.Statuses.ActorStatuses, playerMods)
|
||||
func seedCombatSessionOneShots(s *CombatSession, seat CombatSeatSetup) bool {
|
||||
return seedActorOneShots(&s.Statuses.ActorStatuses, seat)
|
||||
}
|
||||
|
||||
// seedActorOneShots copies one character's fight-start one-shot resources onto
|
||||
// their persisted statuses. Seat 0's live on the session row; a party member's
|
||||
// live on their participant row, and each seat reads its own combatant's mods —
|
||||
// a party Abjurer brings their own Arcane Ward, not the leader's.
|
||||
func seedActorOneShots(st *ActorStatuses, playerMods CombatModifiers) bool {
|
||||
func seedActorOneShots(st *ActorStatuses, seat CombatSeatSetup) bool {
|
||||
playerMods := seat.Mods
|
||||
st.WardCharges = playerMods.WardCharges
|
||||
st.SporeRounds = playerMods.SporeCloud
|
||||
st.ReflectFrac = playerMods.ReflectNext
|
||||
st.AutoCritFirst = playerMods.AutoCritFirst
|
||||
st.ArcaneWardHP = playerMods.ArcaneWardHP
|
||||
st.HealChargesLeft = playerMods.HealItemCharges
|
||||
st.ArmedAbility = seat.ArmedAbility
|
||||
return st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
|
||||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0
|
||||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 ||
|
||||
st.ArmedAbility != ""
|
||||
}
|
||||
|
||||
// CombatParticipant is one party member's seat in a fight, from seat 1 up.
|
||||
@@ -477,13 +487,13 @@ func (p *AdventurePlugin) startPartyCombatSession(
|
||||
|
||||
// Seat 0's one-shots live on the session row; seeding them is a mutation of
|
||||
// sess.Statuses that the save below flushes along with the participants.
|
||||
dirty := seedCombatSessionOneShots(sess, owner.Mods)
|
||||
dirty := seedCombatSessionOneShots(sess, owner)
|
||||
|
||||
if len(seats) > 1 {
|
||||
ps := make([]CombatParticipant, 0, len(seats)-1)
|
||||
for i, s := range seats[1:] {
|
||||
var st ActorStatuses
|
||||
seedActorOneShots(&st, s.Mods)
|
||||
seedActorOneShots(&st, s)
|
||||
ps = append(ps, CombatParticipant{
|
||||
Seat: i + 1, UserID: string(s.UserID), HP: s.HP, HPMax: s.HPMax, Statuses: st,
|
||||
})
|
||||
@@ -525,6 +535,10 @@ type CombatSeatSetup struct {
|
||||
HPMax int
|
||||
Mods CombatModifiers
|
||||
C *Combatant
|
||||
// ArmedAbility is the ability id this seat consumed entering the fight, ""
|
||||
// if they armed nothing. It is persisted onto the seat's statuses so every
|
||||
// later rebuild can re-apply the ability without re-spending it.
|
||||
ArmedAbility string
|
||||
}
|
||||
|
||||
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil).
|
||||
|
||||
@@ -33,11 +33,18 @@ import (
|
||||
// + armed ability) and the monster's bestiary stat block with tier scaling and
|
||||
// the DM-mood combat tilt folded in. The returned dndChar is handed back so
|
||||
// callers can run post-combat subclass persistence without reloading it.
|
||||
//
|
||||
// armed is the ability id already consumed for this fight (consumeArmedAbility,
|
||||
// or armAbilityForFight's return on the auto-resolve paths); "" means none. The
|
||||
// build re-applies it rather than consuming, because the turn-based path calls
|
||||
// this once per player command and a consuming build would spend the ability on
|
||||
// round 1 and drop it for the rest of the fight.
|
||||
func (p *AdventurePlugin) buildZoneCombatants(
|
||||
userID id.UserID,
|
||||
monster DnDMonsterTemplate,
|
||||
tier int,
|
||||
dmMood int,
|
||||
armed string,
|
||||
) (player Combatant, enemy Combatant, dndChar *DnDCharacter, err error) {
|
||||
tilt := dmMoodCombatTilt(dmMood)
|
||||
char, err := loadAdvCharacter(userID)
|
||||
@@ -64,10 +71,7 @@ func (p *AdventurePlugin) buildZoneCombatants(
|
||||
applyRacePassives(&playerStats, &playerMods, dndChar)
|
||||
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
||||
applyMagicItemEffects(&playerStats, &playerMods, userID)
|
||||
trySimAutoArm(dndChar)
|
||||
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
||||
slog.Info("dnd: armed ability fired (turn-based build)", "user", userID, "ability", firedName)
|
||||
}
|
||||
applyAbilityByID(dndChar, armed, &playerMods)
|
||||
|
||||
enemyStats, enemyMods := monster.toCombatStats()
|
||||
// Tier scaling mirrors runZoneCombat: only raise AC/AttackBonus to a
|
||||
@@ -152,7 +156,11 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
|
||||
players := make([]*Combatant, len(seats))
|
||||
var enemy Combatant
|
||||
for seat, uid := range seats {
|
||||
player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood)
|
||||
// The ability this seat armed was consumed once, at fight start, and its
|
||||
// id parked on their statuses. Re-applying it here — not re-consuming —
|
||||
// is what makes a rage last the whole fight instead of one round.
|
||||
st := sess.actorStatusesForSeat(seat)
|
||||
player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
|
||||
}
|
||||
@@ -161,7 +169,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, sess.actorStatusesForSeat(seat))
|
||||
applySessionBuffs(&player, st)
|
||||
players[seat] = &player
|
||||
if seat == 0 {
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -454,15 +454,23 @@ func TestApplySessionBuffs(t *testing.T) {
|
||||
func TestSeedCombatSessionOneShots(t *testing.T) {
|
||||
// Arcane Ward is the one resource normally live at fight start.
|
||||
s := &CombatSession{}
|
||||
if !seedCombatSessionOneShots(s, CombatModifiers{ArcaneWardHP: 15}) {
|
||||
if !seedCombatSessionOneShots(s, CombatSeatSetup{Mods: CombatModifiers{ArcaneWardHP: 15}}) {
|
||||
t.Error("seed should report true when Arcane Ward is present")
|
||||
}
|
||||
if s.Statuses.ArcaneWardHP != 15 {
|
||||
t.Errorf("ArcaneWardHP = %d, want 15", s.Statuses.ArcaneWardHP)
|
||||
}
|
||||
// The armed ability rides along, so a rebuild mid-fight can re-apply it.
|
||||
armed := &CombatSession{}
|
||||
if !seedCombatSessionOneShots(armed, CombatSeatSetup{ArmedAbility: "rage"}) {
|
||||
t.Error("seed should report true when an ability was armed")
|
||||
}
|
||||
if armed.Statuses.ArmedAbility != "rage" {
|
||||
t.Errorf("ArmedAbility = %q, want rage", armed.Statuses.ArmedAbility)
|
||||
}
|
||||
// Nothing to seed → false, statuses untouched.
|
||||
empty := &CombatSession{}
|
||||
if seedCombatSessionOneShots(empty, CombatModifiers{}) {
|
||||
if seedCombatSessionOneShots(empty, CombatSeatSetup{}) {
|
||||
t.Error("seed should report false with no fight-start resources")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,22 +410,52 @@ func trySimAutoArm(c *DnDCharacter) string {
|
||||
return ab.Name
|
||||
}
|
||||
|
||||
// applyArmedAbility checks for a pre-armed ability on the character and
|
||||
// applies its effect to the player's CombatModifiers, then clears the armed
|
||||
// flag. Called from combat_bridge.go before SimulateCombat.
|
||||
func applyArmedAbility(c *DnDCharacter, mods *CombatModifiers) (string, bool) {
|
||||
// consumeArmedAbility disarms the character, returning the ability id that was
|
||||
// armed. It is the *mutating* half of arming: the flag is cleared and saved, so
|
||||
// it must run exactly once per fight, at fight start.
|
||||
//
|
||||
// It is split from applyAbilityByID because a turn-based fight rebuilds its
|
||||
// combatants from the DB on every player command. A rebuild that consumed would
|
||||
// fire the ability on round 1, clear the flag, and then hand every later round a
|
||||
// character with no ability at all — the player pays the resource for one round
|
||||
// of a buff that is supposed to span the fight. So the fight consumes once and
|
||||
// persists the id on the session; each rebuild re-applies it from there.
|
||||
//
|
||||
// An id that is no longer in the ability table is disarmed and reported as "".
|
||||
func consumeArmedAbility(c *DnDCharacter) string {
|
||||
if c == nil || c.ArmedAbility == "" {
|
||||
return ""
|
||||
}
|
||||
armed := c.ArmedAbility
|
||||
c.ArmedAbility = ""
|
||||
_ = SaveDnDCharacter(c)
|
||||
if _, ok := dndActiveAbilities[armed]; !ok {
|
||||
return ""
|
||||
}
|
||||
return armed
|
||||
}
|
||||
|
||||
// applyAbilityByID folds an ability's effect into a freshly-derived set of
|
||||
// CombatModifiers. It is pure with respect to persistence — no DB write, no
|
||||
// disarm — so it is safe to call on every rebuild of an in-flight fight.
|
||||
//
|
||||
// abilityID is what consumeArmedAbility returned at fight start. Empty (nobody
|
||||
// armed anything) and unknown ids are both no-ops.
|
||||
func applyAbilityByID(c *DnDCharacter, abilityID string, mods *CombatModifiers) (string, bool) {
|
||||
if c == nil || abilityID == "" {
|
||||
return "", false
|
||||
}
|
||||
ab, ok := dndActiveAbilities[c.ArmedAbility]
|
||||
ab, ok := dndActiveAbilities[abilityID]
|
||||
if !ok {
|
||||
c.ArmedAbility = ""
|
||||
_ = SaveDnDCharacter(c)
|
||||
return "", false
|
||||
}
|
||||
ab.Apply(c, mods)
|
||||
firedName := ab.Name
|
||||
c.ArmedAbility = ""
|
||||
_ = SaveDnDCharacter(c)
|
||||
return firedName, true
|
||||
return ab.Name, true
|
||||
}
|
||||
|
||||
// armAbilityForFight consumes whatever the character armed and applies it in one
|
||||
// step, for the auto-resolve callers that build a combatant and immediately
|
||||
// fight with it. A turn-based fight must not use this — see consumeArmedAbility.
|
||||
func armAbilityForFight(c *DnDCharacter, mods *CombatModifiers) (string, bool) {
|
||||
return applyAbilityByID(c, consumeArmedAbility(c), mods)
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ func TestSpendAndRefreshResource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyArmedAbility_FighterSecondWind(t *testing.T) {
|
||||
func TestArmAbilityForFight_FighterSecondWind(t *testing.T) {
|
||||
setupAbilitiesTestDB(t)
|
||||
uid := id.UserID("@arm_fighter:example")
|
||||
c := &DnDCharacter{
|
||||
@@ -162,7 +162,7 @@ func TestApplyArmedAbility_FighterSecondWind(t *testing.T) {
|
||||
}
|
||||
|
||||
mods := CombatModifiers{}
|
||||
name, fired := applyArmedAbility(c, &mods)
|
||||
name, fired := armAbilityForFight(c, &mods)
|
||||
if !fired {
|
||||
t.Fatal("ability did not fire")
|
||||
}
|
||||
@@ -181,7 +181,7 @@ func TestApplyArmedAbility_FighterSecondWind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyArmedAbility_MageMagicMissile(t *testing.T) {
|
||||
func TestArmAbilityForFight_MageMagicMissile(t *testing.T) {
|
||||
setupAbilitiesTestDB(t)
|
||||
uid := id.UserID("@arm_mage:example")
|
||||
c := &DnDCharacter{
|
||||
@@ -192,7 +192,7 @@ func TestApplyArmedAbility_MageMagicMissile(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mods := CombatModifiers{}
|
||||
_, fired := applyArmedAbility(c, &mods)
|
||||
_, fired := armAbilityForFight(c, &mods)
|
||||
if !fired {
|
||||
t.Fatal("magic missile did not fire")
|
||||
}
|
||||
@@ -202,10 +202,10 @@ func TestApplyArmedAbility_MageMagicMissile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyArmedAbility_NoArm(t *testing.T) {
|
||||
func TestArmAbilityForFight_NoArm(t *testing.T) {
|
||||
c := &DnDCharacter{Class: ClassFighter, ArmedAbility: ""}
|
||||
mods := CombatModifiers{}
|
||||
_, fired := applyArmedAbility(c, &mods)
|
||||
_, fired := armAbilityForFight(c, &mods)
|
||||
if fired {
|
||||
t.Error("fired with no armed ability")
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
//
|
||||
// Bypassed deliberately (Phase 0 simplifying constraints, doc §2):
|
||||
//
|
||||
// - DB-touching layers: applyMagicItemEffects, applyArmedAbility, and
|
||||
// - DB-touching layers: applyMagicItemEffects, armAbilityForFight, and
|
||||
// the SaveDnDCharacter inside applyPendingCast. The harness is pure
|
||||
// Go; tests run without a sqlite instance.
|
||||
// - Race passives beyond Human (+1 all): neutral baseline, again per §2.
|
||||
|
||||
@@ -321,6 +321,42 @@ func updateSupplies(expID string, s ExpeditionSupplies) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// withExpeditionSupplies serializes one read-modify-write of the shared supply
|
||||
// pool. updateSupplies rewrites supplies_json wholesale, so a caller that folds
|
||||
// its delta onto an *Expedition it read earlier silently discards anything that
|
||||
// landed in between — a member's pooled packs, another writer's spend. Handlers
|
||||
// run one goroutine per event, so those writers genuinely interleave.
|
||||
//
|
||||
// fn is handed a freshly-read expedition under the pool's own lock and returns
|
||||
// the supplies to persist. Callers that keep using their own *Expedition
|
||||
// afterwards must copy the returned pool back onto it.
|
||||
//
|
||||
// advUserLock cannot stand in here: it is keyed by sender, so two members
|
||||
// racing the same expedition row take two different mutexes and exclude nobody.
|
||||
func (p *AdventurePlugin) withExpeditionSupplies(
|
||||
expID string, fn func(fresh *Expedition) (ExpeditionSupplies, error),
|
||||
) (ExpeditionSupplies, error) {
|
||||
mu := p.advExpeditionLock(expID)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
fresh, err := getExpedition(expID)
|
||||
if err != nil {
|
||||
return ExpeditionSupplies{}, err
|
||||
}
|
||||
if fresh == nil {
|
||||
return ExpeditionSupplies{}, fmt.Errorf("expedition %s not found", expID)
|
||||
}
|
||||
next, err := fn(fresh)
|
||||
if err != nil {
|
||||
return ExpeditionSupplies{}, err
|
||||
}
|
||||
if err := updateSupplies(expID, next); err != nil {
|
||||
return ExpeditionSupplies{}, err
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// updateCamp persists camp state. Pass nil to break camp.
|
||||
func updateCamp(expID string, c *CampState) error {
|
||||
var arg any
|
||||
|
||||
@@ -190,10 +190,18 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
|
||||
}
|
||||
}
|
||||
|
||||
exp.Supplies.Current -= cost
|
||||
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
|
||||
// The affordability check above ran against `exp` as it was read, and
|
||||
// nightRolloverBurn may have moved the pool since. Deduct against the pool
|
||||
// as it stands — the same arithmetic, just not onto a stale snapshot.
|
||||
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||
next := fresh.Supplies
|
||||
next.Current -= cost
|
||||
return next, nil
|
||||
})
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't deduct supplies: "+err.Error())
|
||||
}
|
||||
exp.Supplies = pooled
|
||||
camp := &CampState{
|
||||
Active: true,
|
||||
Type: kind,
|
||||
|
||||
@@ -82,34 +82,39 @@ type nightRolloverResult struct {
|
||||
// to finish the rollover; legacy deliverBriefing interleaves processOvernightCamp
|
||||
// between the two so a fortified camp's −5 lands before drift's +3.
|
||||
func (p *AdventurePlugin) nightRolloverBurn(e *Expedition) (float32, error) {
|
||||
// D5-c: Ranger forage runs before the daily burn so the +SU lands on
|
||||
// today's supplies, not tomorrow's. Logged so the end-of-day digest
|
||||
// can surface the gain; pure no-op for non-Ranger characters.
|
||||
if c, err := LoadDnDCharacter(id.UserID(e.UserID)); err == nil && c != nil {
|
||||
if gain := applyRangerForage(e, c, nil); gain > 0 {
|
||||
_ = appendExpeditionLog(e.ID, e.CurrentDay, "forage",
|
||||
fmt.Sprintf("ranger forage +%g SU", gain),
|
||||
flavor.Pick(flavor.HarvestForageSuccess))
|
||||
}
|
||||
}
|
||||
burnOverride := applyZoneTemporalPreBurn(e, e.CurrentDay+1)
|
||||
var newSupplies ExpeditionSupplies
|
||||
var burn float32
|
||||
if burnOverride.Multiplier > 0 {
|
||||
// The temporal override replaces the harsh/siege multiplier, not the
|
||||
// roster's: a party still eats N × 0.8 rations inside a time-warped zone.
|
||||
burn = e.Supplies.DailyBurn * burnOverride.Multiplier * float32(expeditionBurnRatePct(e.ID)) / 100
|
||||
newSupplies = e.Supplies
|
||||
newSupplies.Current -= burn
|
||||
if newSupplies.Current < 0 {
|
||||
newSupplies.Current = 0
|
||||
// Forage and burn land in one write, so they resolve together against the
|
||||
// pool as it stands now — not as `e` last saw it.
|
||||
newSupplies, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||
// D5-c: Ranger forage runs before the daily burn so the +SU lands on
|
||||
// today's supplies, not tomorrow's. Logged so the end-of-day digest
|
||||
// can surface the gain; pure no-op for non-Ranger characters.
|
||||
if c, cerr := LoadDnDCharacter(id.UserID(fresh.UserID)); cerr == nil && c != nil {
|
||||
if gain := applyRangerForage(fresh, c, nil); gain > 0 {
|
||||
_ = appendExpeditionLog(fresh.ID, fresh.CurrentDay, "forage",
|
||||
fmt.Sprintf("ranger forage +%g SU", gain),
|
||||
flavor.Pick(flavor.HarvestForageSuccess))
|
||||
}
|
||||
}
|
||||
newSupplies.ForagedToday = false
|
||||
} else {
|
||||
harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e)
|
||||
newSupplies, burn = applyExpeditionDailyBurn(e, harsh, e.SiegeMode)
|
||||
}
|
||||
if err := updateSupplies(e.ID, newSupplies); err != nil {
|
||||
burnOverride := applyZoneTemporalPreBurn(fresh, fresh.CurrentDay+1)
|
||||
if burnOverride.Multiplier > 0 {
|
||||
// The temporal override replaces the harsh/siege multiplier, not the
|
||||
// roster's: a party still eats N × 0.8 rations inside a time-warped zone.
|
||||
burn = fresh.Supplies.DailyBurn * burnOverride.Multiplier * float32(expeditionBurnRatePct(fresh.ID)) / 100
|
||||
next := fresh.Supplies
|
||||
next.Current -= burn
|
||||
if next.Current < 0 {
|
||||
next.Current = 0
|
||||
}
|
||||
next.ForagedToday = false
|
||||
return next, nil
|
||||
}
|
||||
harsh := fresh.ThreatLevel > 60 || zoneTemporalHarsh(fresh)
|
||||
next, b := applyExpeditionDailyBurn(fresh, harsh, fresh.SiegeMode)
|
||||
burn = b
|
||||
return next, nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := advanceExpeditionDay(e.ID); err != nil {
|
||||
|
||||
@@ -176,20 +176,30 @@ const twoWeeksCacheSize = 3
|
||||
func (p *AdventurePlugin) grantTwoWeeksCache(e *Expedition) string {
|
||||
var lines []string
|
||||
|
||||
if s := e.Supplies; s.DailyBurn > 0 {
|
||||
s.Current += twoWeeksRestockDays * s.DailyBurn
|
||||
if s.Max > 0 && s.Current > s.Max {
|
||||
s.Current = s.Max
|
||||
}
|
||||
if s.Current > e.Supplies.Current {
|
||||
if err := updateSupplies(e.ID, s); err != nil {
|
||||
slog.Error("milestone: two weeks restock failed",
|
||||
"expedition", e.ID, "err", err)
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"📦 Supplies restocked — %.1f of %.1f.", s.Current, s.Max))
|
||||
e.Supplies = s
|
||||
if e.Supplies.DailyBurn > 0 {
|
||||
var restocked bool
|
||||
pooled, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||
s := fresh.Supplies
|
||||
if s.DailyBurn <= 0 {
|
||||
return s, nil
|
||||
}
|
||||
s.Current += twoWeeksRestockDays * s.DailyBurn
|
||||
if s.Max > 0 && s.Current > s.Max {
|
||||
s.Current = s.Max
|
||||
}
|
||||
restocked = s.Current > fresh.Supplies.Current
|
||||
return s, nil
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
slog.Error("milestone: two weeks restock failed",
|
||||
"expedition", e.ID, "err", err)
|
||||
case restocked:
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"📦 Supplies restocked — %.1f of %.1f.", pooled.Current, pooled.Max))
|
||||
e.Supplies = pooled
|
||||
default:
|
||||
e.Supplies = pooled
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,13 +143,17 @@ func (p *AdventurePlugin) regionCmdTravel(ctx MessageContext, exp *Expedition) e
|
||||
// on both paths.
|
||||
func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition, cur, next ExpeditionRegion) (string, error) {
|
||||
// Burn one day of supplies (transit day).
|
||||
siege := exp.SiegeMode
|
||||
harsh := exp.ThreatLevel > 60 || zoneTemporalHarsh(exp)
|
||||
newSupplies, burned := applyExpeditionDailyBurn(exp, harsh, siege)
|
||||
exp.Supplies = newSupplies
|
||||
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
|
||||
var burned float32
|
||||
newSupplies, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||
harsh := fresh.ThreatLevel > 60 || zoneTemporalHarsh(fresh)
|
||||
next, b := applyExpeditionDailyBurn(fresh, harsh, fresh.SiegeMode)
|
||||
burned = b
|
||||
return next, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apply transit supply burn: %w", err)
|
||||
}
|
||||
exp.Supplies = newSupplies
|
||||
if err := advanceExpeditionDay(exp.ID); err != nil {
|
||||
return "", fmt.Errorf("advance expedition day: %w", err)
|
||||
}
|
||||
|
||||
@@ -349,12 +349,14 @@ func (p *AdventurePlugin) applyAmbientEffect(e *Expedition, ev ambientEvent) str
|
||||
return "Something out there is paying attention now."
|
||||
case "pack_rat":
|
||||
drain := float32(0.2) + float32(rng.IntN(2))*float32(0.1) // 0.2 or 0.3
|
||||
ns := e.Supplies
|
||||
ns.Current -= drain
|
||||
if ns.Current < 0 {
|
||||
ns.Current = 0
|
||||
}
|
||||
if err := updateSupplies(e.ID, ns); err != nil {
|
||||
if _, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||
ns := fresh.Supplies
|
||||
ns.Current -= drain
|
||||
if ns.Current < 0 {
|
||||
ns.Current = 0
|
||||
}
|
||||
return ns, nil
|
||||
}); err != nil {
|
||||
slog.Warn("expedition: ambient supply drain", "expedition", e.ID, "err", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -250,10 +250,15 @@ func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision
|
||||
if exp.Supplies.Current < cost {
|
||||
return "", fmt.Errorf("insufficient supplies (have %.1f, need %.1f)", exp.Supplies.Current, cost)
|
||||
}
|
||||
exp.Supplies.Current -= cost
|
||||
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
|
||||
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||
next := fresh.Supplies
|
||||
next.Current -= cost
|
||||
return next, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
exp.Supplies = pooled
|
||||
camp := &CampState{
|
||||
Active: true,
|
||||
Type: d.Kind,
|
||||
|
||||
@@ -178,21 +178,13 @@ func (p *AdventurePlugin) expeditionCmdAccept(ctx MessageContext, c *DnDCharacte
|
||||
if isHol, _ := isHolidayToday(); isHol {
|
||||
suppliesPurchase.StandardPacks++
|
||||
}
|
||||
// updateSupplies overwrites supplies_json wholesale, so the pool has to be
|
||||
// re-read under the expedition's own lock: `exp` was fetched before the coin
|
||||
// debit, and a second invitee accepting — or the leader's day-burn tick —
|
||||
// may have rewritten the row since. Folding onto that stale snapshot would
|
||||
// silently drop their packs or resurrect spent SU.
|
||||
expMu := p.advExpeditionLock(exp.ID)
|
||||
expMu.Lock()
|
||||
fresh, err := getExpedition(exp.ID)
|
||||
if err != nil || fresh == nil {
|
||||
expMu.Unlock()
|
||||
return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool.")
|
||||
}
|
||||
pooled := addSupplyPurchase(fresh.Supplies, suppliesPurchase)
|
||||
err = updateSupplies(exp.ID, pooled)
|
||||
expMu.Unlock()
|
||||
// `exp` was fetched before the coin debit, and a second invitee accepting —
|
||||
// or the leader's day-burn tick — may have rewritten the row since. Folding
|
||||
// onto that stale snapshot would silently drop their packs or resurrect
|
||||
// spent SU, so the fold happens against a fresh read under the pool's lock.
|
||||
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
||||
return addSupplyPurchase(fresh.Supplies, suppliesPurchase), nil
|
||||
})
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool: "+err.Error())
|
||||
}
|
||||
|
||||
@@ -86,7 +86,24 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
||||
continue
|
||||
}
|
||||
|
||||
player, e, dndChar, err := p.buildZoneCombatants(uid, monster, tier, dmMood)
|
||||
// A downed member sits it out. They own no close-out claim — and the
|
||||
// check runs before the arm, so sitting out doesn't cost them the
|
||||
// ability they had readied.
|
||||
if !leader {
|
||||
if hp, _ := dndHPSnapshot(uid); hp <= 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-resolve builds and fights in one breath, so this seat can arm and
|
||||
// apply together. The turn-based path has to split the two — see
|
||||
// consumeArmedAbility.
|
||||
armed := ""
|
||||
if c, cerr := LoadDnDCharacter(uid); cerr == nil && c != nil {
|
||||
trySimAutoArm(c)
|
||||
armed = consumeArmedAbility(c)
|
||||
}
|
||||
player, e, dndChar, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed)
|
||||
if err != nil {
|
||||
if leader {
|
||||
return bail(err)
|
||||
@@ -96,9 +113,6 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
||||
}
|
||||
if leader {
|
||||
enemy = e
|
||||
} else if hp, _ := dndHPSnapshot(uid); hp <= 0 {
|
||||
// A downed member sits it out. They own no close-out claim.
|
||||
continue
|
||||
}
|
||||
|
||||
// Pre-combat one-shots that the turn-based path does NOT share: a queued
|
||||
@@ -136,11 +150,8 @@ func (p *AdventurePlugin) runZoneCombatRoster(
|
||||
}
|
||||
}
|
||||
|
||||
p.grantCombatAchievements(b.uid, seatRes)
|
||||
persistDnDHPAfterCombat(b.uid, seatRes.PlayerEndHP)
|
||||
if err := persistDnDPostCombatSubclass(b.dndChar, b.mods.BerserkerRage, seatRes, b.mods); err != nil {
|
||||
slog.Error("dnd: post-combat subclass persist (zone)", "user", b.uid, "err", err)
|
||||
}
|
||||
p.postCombatBookkeeping(b.uid, b.dndChar, b.mods.BerserkerRage, seatRes, b.mods)
|
||||
|
||||
if xp := zoneCombatXP(seatRes, monster.CR, tier); xp > 0 {
|
||||
if _, err := p.grantDnDXP(b.uid, xp); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user