mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Compare commits
11 Commits
n1-restora
...
n3-p8-enem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1c067452e | ||
|
|
0d18cea59a | ||
|
|
88c5fcdf2f | ||
|
|
d5fecf45d8 | ||
|
|
6be7744e81 | ||
|
|
1f1fbf0251 | ||
|
|
d7a5333048 | ||
|
|
91eeee0826 | ||
|
|
c34e740008 | ||
|
|
a59a544fff | ||
|
|
d76c63be0c |
@@ -270,9 +270,13 @@ func (p *AdventurePlugin) Init() error {
|
||||
go p.expeditionRecapTicker()
|
||||
go p.expeditionAmbientTicker()
|
||||
go p.expeditionAutoRunTicker()
|
||||
go p.expeditionExtractionSweepTicker()
|
||||
|
||||
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
||||
p.arenaCleanupStaleRuns()
|
||||
// Extractions that lapsed while the bot was down: the roster they hold is
|
||||
// blocking those members right now, not an hour from now.
|
||||
p.sweepLapsedExtractions(time.Now().UTC())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -32,10 +32,23 @@ type advActiveEvent struct {
|
||||
//
|
||||
// The per-anchor chances below put a player who hits all three at ~1 event
|
||||
// per week; see TestAnchoredEventWeeklyRate.
|
||||
//
|
||||
// K — the three social/economic anchors miss the pure grind loop: a player who
|
||||
// only mines/forages/fishes (now automatic, done by walking) and clears
|
||||
// single-day dungeons, but never sells, never enters the arena, and never runs
|
||||
// a multi-day expedition to a Night camp, would roll for zero events. The
|
||||
// fourth anchor, ZoneClear, fires on a foreground single-day zone clear — the
|
||||
// climax DM that player *is* reading — so they get the same rough cadence.
|
||||
// Autopilot walks and mid-zone region clears deliberately do not fire it (see
|
||||
// zoneCmdAdvance / the full-clear branch of advanceOnceWithOpts). The per-day
|
||||
// slot still caps everyone at one event, so a player who happens to hit two
|
||||
// anchors in a day can't double up. This rate is the tuning knob for item K —
|
||||
// bump it if telemetry shows the grind-loop player still sees too few.
|
||||
const (
|
||||
advEventChanceDigest = 0.08
|
||||
advEventChanceSell = 0.05
|
||||
advEventChanceArena = 0.05
|
||||
advEventChanceDigest = 0.08
|
||||
advEventChanceSell = 0.05
|
||||
advEventChanceArena = 0.05
|
||||
advEventChanceZoneClear = 0.08
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -44,6 +44,43 @@ func TestAnchoredEventWeeklyRate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestZoneClearAnchorRate pins item K's fourth anchor for the population the
|
||||
// other three miss: a grind-loop player who never sells, never enters the
|
||||
// arena, and never runs a multi-day expedition, but clears single-day zones.
|
||||
// Modelled at two foreground clears per active day (a plausible dungeon
|
||||
// session) they should land in the same ~1 event/week band as the fully-
|
||||
// engaged player — that is the whole point of adding the anchor. The clears/day
|
||||
// assumption and advEventChanceZoneClear are the two tuning knobs; see the K
|
||||
// note in adventure_events.go.
|
||||
func TestZoneClearAnchorRate(t *testing.T) {
|
||||
const clearsPerDay = 2
|
||||
|
||||
const weeks = 20000
|
||||
rng := rand.New(rand.NewPCG(0x5EED, 0x2C)) // "ZC"
|
||||
events := 0
|
||||
for range weeks {
|
||||
for range 7 {
|
||||
firedToday := false
|
||||
for range clearsPerDay {
|
||||
if firedToday {
|
||||
break // one event per player per UTC day
|
||||
}
|
||||
if rng.Float64() < advEventChanceZoneClear {
|
||||
firedToday = true
|
||||
}
|
||||
}
|
||||
if firedToday {
|
||||
events++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
perWeek := float64(events) / weeks
|
||||
if perWeek < 0.8 || perWeek > 1.5 {
|
||||
t.Errorf("zone-clear anchor = %.3f/week, want ~1 (0.8–1.5)", perWeek)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimDailyEventSlot_OnePerDay guards the cap the rate test assumes.
|
||||
func TestClaimDailyEventSlot_OnePerDay(t *testing.T) {
|
||||
uid := id.UserID("@events-slot:example")
|
||||
|
||||
@@ -435,18 +435,8 @@ func (p *AdventurePlugin) handleMasterworkEquipReply(ctx MessageContext, interac
|
||||
return p.SendDM(ctx.Sender, "Equip cancelled.")
|
||||
}
|
||||
|
||||
// Parse number
|
||||
idx := 0
|
||||
for _, c := range reply {
|
||||
if c >= '0' && c <= '9' {
|
||||
idx = idx*10 + int(c-'0')
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
idx-- // 1-indexed to 0-indexed
|
||||
|
||||
if idx < 0 || idx >= len(data.Items) {
|
||||
idx, ok := parseMenuIndex(reply, len(data.Items))
|
||||
if !ok {
|
||||
return p.SendDM(ctx.Sender, "Invalid selection. Reply with a number from the list, or \"cancel\".")
|
||||
}
|
||||
|
||||
|
||||
@@ -140,8 +140,12 @@ func findTemperMaterial(items []AdvItem) (AdvItem, bool) {
|
||||
return AdvItem{}, false
|
||||
}
|
||||
|
||||
// parseTemperIndex reads a leading 1-indexed number and bounds-checks it.
|
||||
func parseTemperIndex(reply string, n int) (int, bool) {
|
||||
// parseMenuIndex reads a leading 1-indexed number off a menu reply and returns
|
||||
// the bounds-checked 0-indexed position. The single parser behind every "reply
|
||||
// with a number from the list" prompt — temper, magic-item equip, masterwork
|
||||
// equip. Leading whitespace and any trailing text after the digits are ignored;
|
||||
// a reply with no leading digit, or one outside [1, n], returns ok=false.
|
||||
func parseMenuIndex(reply string, n int) (int, bool) {
|
||||
idx, parsed := 0, false
|
||||
for _, c := range strings.TrimSpace(reply) {
|
||||
if c < '0' || c > '9' {
|
||||
@@ -214,7 +218,7 @@ func (p *AdventurePlugin) resolveTemperPick(ctx MessageContext, interaction *adv
|
||||
if strings.EqualFold(reply, "cancel") {
|
||||
return p.SendDM(ctx.Sender, "_The forge keeps burning._ Come back when you've decided.")
|
||||
}
|
||||
idx, ok := parseTemperIndex(reply, len(data.Targets))
|
||||
idx, ok := parseMenuIndex(reply, len(data.Targets))
|
||||
if !ok {
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, "Reply with a number from the list, or \"cancel\".")
|
||||
|
||||
@@ -346,9 +346,9 @@ func TestParseTemperIndex(t *testing.T) {
|
||||
{"cancel", 3, 0, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, ok := parseTemperIndex(tc.in, tc.n)
|
||||
got, ok := parseMenuIndex(tc.in, tc.n)
|
||||
if ok != tc.wantOK || (ok && got != tc.want) {
|
||||
t.Errorf("parseTemperIndex(%q, %d) = (%d, %v), want (%d, %v)",
|
||||
t.Errorf("parseMenuIndex(%q, %d) = (%d, %v), want (%d, %v)",
|
||||
tc.in, tc.n, got, ok, tc.want, tc.wantOK)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -101,7 +101,11 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
||||
if refusal != "" {
|
||||
return p.replyDM(ctx, refusal)
|
||||
}
|
||||
enemyHP := enemy.Stats.MaxHP
|
||||
// The persisted session scales enemy HP for a party (solo scales by 1.0, so
|
||||
// this is a no-op there); mirror it here so the entry banner and the opening
|
||||
// round resolve against the same ceiling startPartyCombatSession persisted and
|
||||
// the rebuilt rounds use.
|
||||
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats))
|
||||
|
||||
// Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded
|
||||
// per seat onto the session and its participant rows, so they survive the
|
||||
@@ -130,6 +134,11 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
||||
|
||||
if sess.IsParty() {
|
||||
players := seatCombatants(seats)
|
||||
// Align the in-memory template with the scaled HP persisted above, so the
|
||||
// opening-round settle resolves the enemy against the same MaxHP the
|
||||
// rebuilt rounds do (regen clamp, bloodied-ability threshold). The persist
|
||||
// already happened off the unscaled value, so this does not double-scale.
|
||||
enemy.Stats.MaxHP = enemyHP
|
||||
// The enemy may have won initiative. Resolve everything the round owes
|
||||
// before anyone is asked to act, so the opening block narrates the hit
|
||||
// rather than showing its damage with no explanation.
|
||||
@@ -332,6 +341,10 @@ func continueHint(userID id.UserID) string {
|
||||
// the room's manual combat is done, and a fresh !zone advance clears the room.
|
||||
func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSession, enemy Combatant) string {
|
||||
persistDnDHPAfterCombat(userID, sess.PlayerHP)
|
||||
// Achievements and post-combat subclass state are owed on every terminal
|
||||
// status, not just a win — a Berserker who rages and loses still comes out
|
||||
// of it exhausted. The auto-resolve close-outs have always done this.
|
||||
p.postCombatBookkeepingForSeat(sess, 0)
|
||||
|
||||
run, _ := getZoneRun(sess.RunID)
|
||||
var zone ZoneDefinition
|
||||
@@ -350,28 +363,12 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
|
||||
var b strings.Builder
|
||||
switch sess.Status {
|
||||
case CombatStatusWon:
|
||||
recordZoneKillForUser(userID, sess.EnemyID)
|
||||
applyRoomCombatThreatForUser(userID, elite)
|
||||
// zoneCombatXP only reads PlayerWon + NearDeath off the result.
|
||||
nearDeath := sess.PlayerHPMax > 0 && sess.PlayerHP*5 < sess.PlayerHPMax
|
||||
tier := 1
|
||||
if run != nil {
|
||||
tier = int(zone.Tier)
|
||||
}
|
||||
if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 {
|
||||
if _, err := p.grantDnDXP(userID, xp); err != nil {
|
||||
slog.Error("combat: grantDnDXP turn-based", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
bossOnExpedition := false
|
||||
if !elite {
|
||||
// §8.1 — zone boss defeat drops expedition threat. Silent no-op
|
||||
// for standalone zone runs (no active expedition).
|
||||
if exp, eerr := getActiveExpedition(userID); eerr == nil && exp != nil {
|
||||
_ = applyBossDefeatThreat(exp.ID)
|
||||
bossOnExpedition = true
|
||||
}
|
||||
}
|
||||
bossOnExpedition := p.applyOwnerWinEffects(userID, sess.EnemyID, elite)
|
||||
p.grantSeatWinXP(userID, sess.PlayerHP, sess.PlayerHPMax, monster, tier)
|
||||
if line := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence); line != "" {
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
@@ -395,11 +392,7 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
|
||||
}
|
||||
|
||||
case CombatStatusLost:
|
||||
if run != nil {
|
||||
_, _ = applyMoodEvent(sess.RunID, MoodEventPlayerDeath)
|
||||
}
|
||||
_ = abandonZoneRun(userID)
|
||||
forceExtractExpeditionForRunLoss(userID, "combat death")
|
||||
endRunOnLoss(userID, sess.RunID, true)
|
||||
markAdventureDead(userID, "zone", zone.Display)
|
||||
if line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence); line != "" {
|
||||
b.WriteString(line + "\n")
|
||||
@@ -410,8 +403,7 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
|
||||
// Flee = run ends, light penalty: wounds persist (HP already saved),
|
||||
// but no death timer. Chosen candidate from the migration plan's
|
||||
// open question on flee outcome.
|
||||
_ = abandonZoneRun(userID)
|
||||
forceExtractExpeditionForRunLoss(userID, "combat flee")
|
||||
endRunOnLoss(userID, sess.RunID, false)
|
||||
b.WriteString(fmt.Sprintf("🏃 You broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name))
|
||||
|
||||
default:
|
||||
@@ -598,6 +590,18 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
|
||||
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
|
||||
return PlayerAction{}, noop, msg
|
||||
}
|
||||
// Park the Necromancy kill-heal stash on the casting seat. The
|
||||
// auto-resolve path keeps it on the fight-start CombatModifiers, which
|
||||
// a turn-based fight has nowhere to hold — it rebuilds its combatants
|
||||
// every round. Only a damaging cast stashes (a miss leaves the slot at
|
||||
// 0), and each one overwrites the last, so the stash always describes
|
||||
// the seat's most recent landed spell — which is the only one that can
|
||||
// have been lethal by the time the close-out reads it.
|
||||
if out.GrimHarvestSlot > 0 {
|
||||
as := ct.sess.actorStatusesPtr(seat)
|
||||
as.GrimHarvestSlot = out.GrimHarvestSlot
|
||||
as.GrimHarvestNecrotic = out.GrimHarvestNecrotic
|
||||
}
|
||||
eff = &turnActionEffect{
|
||||
Label: out.Desc,
|
||||
Action: "spell_cast",
|
||||
|
||||
@@ -162,9 +162,11 @@ type CombatModifiers struct {
|
||||
// ArcaneWardHP: flat HP buffer absorbed before player HP. Refilled at the
|
||||
// start of each combat by Abjuration L5+ (2× Mage level, +prof at L7).
|
||||
// Persists across rounds within a single combat; not refunded between fights.
|
||||
// GrimHarvestSlot/Necrotic: snapshot of the queued spell stashed by
|
||||
// applyPendingCast for the post-combat Grim Harvest hook (Necromancy L5+).
|
||||
// Heal fires only if the spell event is what dropped the enemy to 0.
|
||||
// GrimHarvestSlot/Necrotic: snapshot of the damaging spell stashed for the
|
||||
// post-combat Grim Harvest hook (Necromancy L5+) — by applyPendingCast on
|
||||
// the auto-resolve path, and by seatFightStartMods reading the seat's
|
||||
// statuses on the turn-based one. Heal fires only if that spell's event is
|
||||
// what dropped the enemy to 0.
|
||||
ArcaneWardHP int
|
||||
GrimHarvestSlot int
|
||||
GrimHarvestNecrotic bool
|
||||
|
||||
@@ -64,6 +64,12 @@ func simulateParty(players []Combatant, enemy Combatant, phases []CombatPhase) P
|
||||
}
|
||||
|
||||
func simulatePartyWithRNG(players []Combatant, enemy Combatant, phases []CombatPhase, rng *rand.Rand) PartyCombatResult {
|
||||
// Party-only: bump the enemy's max HP so the fight lasts long enough for the
|
||||
// scaled action economy to actually threaten each member. Solo is unchanged
|
||||
// (scale 1.0), so SimulateCombat and the golden do not move.
|
||||
if len(players) > 1 {
|
||||
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, len(players))
|
||||
}
|
||||
enemyStart := enemy.Stats.MaxHP
|
||||
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP {
|
||||
enemyStart = enemy.Stats.StartHP
|
||||
@@ -289,6 +295,141 @@ func roundInitiative(st *combatState, enemy *Combatant, phase *CombatPhase) []in
|
||||
return order
|
||||
}
|
||||
|
||||
// enemyActionsThisRound is how many attack-actions the enemy takes this round
|
||||
// against the seated roster. Solo returns 1 without drawing from the RNG, so both
|
||||
// combat engines collapse to their pre-party behaviour and the characterization
|
||||
// golden and d8prereq corpus do not move.
|
||||
//
|
||||
// A party's action budget is a *fractional expectation* (partyActionExpectation),
|
||||
// realised as floor(exp) actions plus one more with probability frac(exp). The
|
||||
// fraction matters because the action lever is coarse: against a party of two, an
|
||||
// integer 2 actions is a 100% faceroll and 3 is brutal (~45%), with nothing
|
||||
// between — a per-round coin-flip for the extra action is the only way to land
|
||||
// the band in the gap. The single draw is taken only for a party, so the solo RNG
|
||||
// stream is untouched.
|
||||
func enemyActionsThisRound(st *combatState) int {
|
||||
n := len(st.actors)
|
||||
if n < 2 {
|
||||
return 1
|
||||
}
|
||||
exp := partyActionExpectation(n)
|
||||
base := int(exp)
|
||||
if frac := exp - float64(base); frac > 0 && st.randFloat() < frac {
|
||||
base++
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// partyActionExpectation is the expected number of enemy attack-actions per round
|
||||
// against a party of n. A single enemy swing (the pre-P8 behaviour) let each
|
||||
// member absorb ~1/N² of the solo incoming and cleared 100% of T5; the sim band
|
||||
// landed the martial-leader target only near ~2N actions, and HP alone can't touch
|
||||
// a martial (fighter stays at 100% even at enemy HP ×1.6). So the enemy's action
|
||||
// economy is the load-bearing lever, and it is fractional so a two-person party
|
||||
// lands between soloing and a trio instead of snapping to one of two integers
|
||||
// (against a duo, an integer 2 actions is a ~91% faceroll and 3 is a ~45% meat
|
||||
// grinder — 2.4 sits fighter+cleric at ~75%, between solo 70% and trio 90%).
|
||||
//
|
||||
// N≥3 follows 2N−1, the point that gave fighter ~90% / cleric-led ~72% at
|
||||
// HP ×1.15. The whole curve is monotonic by party size and never drops a
|
||||
// composition below its solo clear rate — bringing a friend is never a penalty.
|
||||
func partyActionExpectation(n int) float64 {
|
||||
switch {
|
||||
case n < 2:
|
||||
return 1
|
||||
case n == 2:
|
||||
return 2.4
|
||||
default:
|
||||
return float64(2*n - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// partyEnemyHPScale is the party-only multiplier on the enemy's max HP. Solo
|
||||
// (roster < 2) returns 1.0, so the solo path — and the characterization golden
|
||||
// and the d8prereq corpus — is byte-for-byte untouched. With the action economy
|
||||
// fixed, each member now takes ~1 swing a round, so a longer fight is more
|
||||
// cumulative exposure per member: HP became a live difficulty lever exactly the
|
||||
// way P6e predicted it would once the enemy stopped swinging only once.
|
||||
//
|
||||
// 1.15 is where the P8 sim sweep landed the band: with 2N−1 actions, a party of
|
||||
// three clears the T5 martial-leader band at ~89% (fighter) / ~67% (cleric-led),
|
||||
// clearly safer than soloing (70% / 27%) but with real TPKs and member deaths —
|
||||
// not the 100% faceroll a single enemy swing produced.
|
||||
func partyEnemyHPScale(rosterSize int) float64 {
|
||||
if rosterSize < 2 {
|
||||
return 1.0
|
||||
}
|
||||
return 1.15
|
||||
}
|
||||
|
||||
// scaledEnemyMaxHP applies the party HP scalar to a base max-HP with one rounding
|
||||
// rule, so every call site (the auto-resolve engine, the party session's initial
|
||||
// persist, and the per-turn rebuild) agrees on the same number.
|
||||
func scaledEnemyMaxHP(baseMaxHP, rosterSize int) int {
|
||||
return int(float64(baseMaxHP) * partyEnemyHPScale(rosterSize))
|
||||
}
|
||||
|
||||
// enemyActionPlan is the shared action budget both combat engines resolve an
|
||||
// enemy turn against: how many attack-actions the enemy takes, and whether the
|
||||
// first one reuses the round's already-picked target (and its already-rolled
|
||||
// procs). A cleave/lifesteal ability already spent the first action, so one fewer
|
||||
// follows and none of them reuse the initial target. Both engines call this so
|
||||
// the load-bearing count stays in lockstep even though their per-action bodies
|
||||
// differ.
|
||||
func enemyActionPlan(st *combatState, abilityDealtDamage bool) (count int, reuseFirst bool) {
|
||||
count = enemyActionsThisRound(st)
|
||||
reuseFirst = !abilityDealtDamage
|
||||
if abilityDealtDamage {
|
||||
count--
|
||||
}
|
||||
return count, reuseFirst
|
||||
}
|
||||
|
||||
// enemyRoundSwings resolves the enemy's attacks for one round of the auto-resolve
|
||||
// engine. A solo roster takes exactly one swing at its single seat, reusing the
|
||||
// round's already-rolled target and pet procs, so its RNG stream and event log are
|
||||
// byte-for-byte the pre-P8 engine's. A party faces enemyActionsThisRound() swings,
|
||||
// each re-targeted at a random standing seat with that seat's own pet procs, so the
|
||||
// damage is spread across the roster instead of pinning the round's first target.
|
||||
//
|
||||
// abilityDealtDamage means a cleave/lifesteal already spent the enemy's first
|
||||
// action this round, so one fewer swing follows — for solo that collapses to the
|
||||
// old "the ability stands in for the attack" skip. Returns true if the fight is
|
||||
// decided.
|
||||
func enemyRoundSwings(st *combatState, enemy *Combatant, phase *CombatPhase, seats []CombatResult,
|
||||
target int, abilityDealtDamage, petWhiff, petDeflect, sporeMiss bool) bool {
|
||||
|
||||
swings, reuseFirst := enemyActionPlan(st, abilityDealtDamage)
|
||||
for k := 0; k < swings; k++ {
|
||||
tgt := target
|
||||
sw, sd, sp := petWhiff, petDeflect, sporeMiss
|
||||
if !(reuseFirst && k == 0) {
|
||||
// A fresh target for every swing past the first: re-pick among the
|
||||
// standing seats and roll that seat's own pet procs. This branch never
|
||||
// runs for a solo roster, so it adds no draws to the solo stream.
|
||||
var alive bool
|
||||
if tgt, alive = enemyTargetSeat(st); !alive {
|
||||
return combatOver(st)
|
||||
}
|
||||
st.seat(tgt)
|
||||
sw = st.c.Mods.PetWhiffProc > 0 && st.randFloat() < st.c.Mods.PetWhiffProc
|
||||
sd = st.c.Mods.PetDeflectProc > 0 && st.randFloat() < st.c.Mods.PetDeflectProc
|
||||
if sd {
|
||||
seats[tgt].PetDeflected = true
|
||||
}
|
||||
sp = st.sporeRounds > 0 && st.randFloat() < 0.15
|
||||
}
|
||||
st.seat(tgt)
|
||||
mark := len(st.events)
|
||||
over := resolveEnemyAttack(st, st.c, enemy, phase, &seats[tgt], sw, sd, sp)
|
||||
stampEventSeats(st, mark, tgt)
|
||||
if over && combatOver(st) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// simulatePartyRound runs one round for the whole roster. Returns true if the
|
||||
// fight is over.
|
||||
func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, seats []CombatResult) bool {
|
||||
@@ -383,14 +524,7 @@ func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, s
|
||||
// via Mods.InitiativeBias — +X means they go first more often.
|
||||
for _, s := range roundInitiative(st, enemy, phase) {
|
||||
if s == enemySeat {
|
||||
if abilityDealtDamage {
|
||||
continue
|
||||
}
|
||||
st.seat(target)
|
||||
mark := len(st.events)
|
||||
over := resolveEnemyAttack(st, st.c, enemy, phase, &seats[target], petWhiff, petDeflect, sporeMiss)
|
||||
stampEventSeats(st, mark, target)
|
||||
if over && combatOver(st) {
|
||||
if enemyRoundSwings(st, enemy, phase, seats, target, abilityDealtDamage, petWhiff, petDeflect, sporeMiss) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
@@ -448,6 +582,78 @@ func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, s
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Misty's pair, shared by both engines ─────────────────────────────────────
|
||||
//
|
||||
// These two are the only round-end effects the turn engine did not have its own
|
||||
// copy of. Everything else in endOfRoundForSeat either exists there already (the
|
||||
// pet, the spiritual weapon — fired after a player action rather than at round
|
||||
// end), is deliberately absent (the environmental hazard: turnCombatPhase is a
|
||||
// single flat "Duel" phase with EnvironmentProc at 0), or is replaced by an
|
||||
// explicit player command (the consumable auto-heal, which is `!consume`).
|
||||
//
|
||||
// So they are hoisted rather than re-implemented. A parallel sibling is what let
|
||||
// the two win close-outs drift apart (deferred item D); this pair is now one
|
||||
// list of effects with two callers, and cannot.
|
||||
//
|
||||
// Neither draws from the RNG unless its proc is armed, so a character with no
|
||||
// Misty history rolls exactly the dice it rolled before — the sim corpus and
|
||||
// combat_characterization.golden do not move.
|
||||
|
||||
// mistyCrowdRevenge is the debuff for declining Misty: her crowd takes a swing
|
||||
// at the end of the round. Returns true when it decided the fight — that is,
|
||||
// when it dropped this character, the death save failed, and nobody else is
|
||||
// standing. A downed character in a still-live party returns false.
|
||||
func mistyCrowdRevenge(st *combatState, player *Combatant, phaseName string) bool {
|
||||
if player.Mods.CrowdRevengeProc <= 0 || st.randFloat() >= player.Mods.CrowdRevengeProc {
|
||||
return false
|
||||
}
|
||||
dmg := player.Mods.CrowdRevengeDmg
|
||||
st.playerHP = max(0, st.playerHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "environment", Action: "crowd_revenge",
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Desc: "Misty's crowd",
|
||||
})
|
||||
return st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive()
|
||||
}
|
||||
|
||||
// mistyHeal is the buff's payout. It cannot decide the fight, so it returns
|
||||
// nothing. result may be a scratch value the caller discards (the turn engine's
|
||||
// is) — the durable record is the misty_heal event on the log, which is what
|
||||
// seatCombatResult reads to award combat_misty_clutch.
|
||||
func mistyHeal(st *combatState, player *Combatant, phaseName string, result *CombatResult) {
|
||||
if player.Mods.MistyHealProc <= 0 || st.randFloat() >= player.Mods.MistyHealProc {
|
||||
return
|
||||
}
|
||||
healAmt := player.Mods.MistyHealAmt
|
||||
st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt)
|
||||
result.MistyHealed = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal",
|
||||
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Desc: "Misty",
|
||||
})
|
||||
}
|
||||
|
||||
// seatEndOfRound runs the round-end effects the turn engine owes one seat, in
|
||||
// the order endOfRoundForSeat runs them: the crowd's swing, then — if the seat
|
||||
// survived it — Misty's heal. Returns true when the fight is over.
|
||||
//
|
||||
// It exists because the turn engine's stepRoundEnd never ran either one. A
|
||||
// player carrying Misty's buff lost it by fighting manually; worse, a player
|
||||
// carrying her debuff *escaped* it by doing the same, which needed no discovery
|
||||
// to exploit — just press !attack.
|
||||
func seatEndOfRound(st *combatState, player *Combatant, phaseName string, result *CombatResult) bool {
|
||||
if over := mistyCrowdRevenge(st, player, phaseName); over {
|
||||
return true
|
||||
}
|
||||
if st.playerHP <= 0 {
|
||||
return false
|
||||
}
|
||||
mistyHeal(st, player, phaseName, result)
|
||||
return false
|
||||
}
|
||||
|
||||
// endOfRoundForSeat runs the per-character close of a round against the seat the
|
||||
// cursor already points at: environment, Misty's crowd, the pet, the spiritual
|
||||
// weapon, Misty's heal, and the consumable auto-heal — in that order, which is
|
||||
@@ -470,17 +676,8 @@ func endOfRoundForSeat(st *combatState, phase *CombatPhase, result *CombatResult
|
||||
}
|
||||
|
||||
// Misty crowd revenge (debuff for declining Misty)
|
||||
if player.Mods.CrowdRevengeProc > 0 && st.randFloat() < player.Mods.CrowdRevengeProc {
|
||||
dmg := player.Mods.CrowdRevengeDmg
|
||||
st.playerHP = max(0, st.playerHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "environment", Action: "crowd_revenge",
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Desc: "Misty's crowd",
|
||||
})
|
||||
if st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive() {
|
||||
return true
|
||||
}
|
||||
if over := mistyCrowdRevenge(st, player, phaseName); over {
|
||||
return true
|
||||
}
|
||||
|
||||
// A character the round just killed stops acting, but the fight goes on if
|
||||
@@ -517,16 +714,7 @@ func endOfRoundForSeat(st *combatState, phase *CombatPhase, result *CombatResult
|
||||
}
|
||||
|
||||
// Misty heal
|
||||
if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc {
|
||||
healAmt := player.Mods.MistyHealAmt
|
||||
st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt)
|
||||
result.MistyHealed = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal",
|
||||
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Desc: "Misty",
|
||||
})
|
||||
}
|
||||
mistyHeal(st, player, phaseName, result)
|
||||
|
||||
// Consumable heal: triggers when the character drops below 60% HP. Fires up
|
||||
// to HealItemCharges times per fight (1 = legacy one-shot; bosses can
|
||||
|
||||
@@ -124,31 +124,89 @@ func TestSimulateParty_DownedMemberDoesNotEndTheFight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The enemy swings at one seat per round, not at everyone.
|
||||
func TestSimulateParty_EnemyStrikesOneSeatPerRound(t *testing.T) {
|
||||
// P8 scaling constants: solo is exempt on both levers (1 action, ×1.0 HP) so the
|
||||
// characterization golden and the d8prereq corpus are untouched. A party's action
|
||||
// budget is a fractional expectation — 2.4 for a duo (so it lands between soloing
|
||||
// and a trio, where an integer 2 vs 3 has no room), 2N−1 for N≥3 — plus ×1.15
|
||||
// enemy HP.
|
||||
func TestP8PartyScaling_SoloExemptPartyScaled(t *testing.T) {
|
||||
if got := partyActionExpectation(1); got != 1 {
|
||||
t.Fatalf("solo action expectation = %v, want 1", got)
|
||||
}
|
||||
if got := partyEnemyHPScale(1); got != 1.0 {
|
||||
t.Fatalf("solo HP scale = %v, want 1.0", got)
|
||||
}
|
||||
if got := scaledEnemyMaxHP(200, 1); got != 200 {
|
||||
t.Fatalf("solo enemy HP = %d, want 200 (unscaled)", got)
|
||||
}
|
||||
if got := partyActionExpectation(2); got != 2.4 {
|
||||
t.Fatalf("duo action expectation = %v, want 2.4", got)
|
||||
}
|
||||
for n := 3; n <= 5; n++ {
|
||||
if got, want := partyActionExpectation(n), float64(2*n-1); got != want {
|
||||
t.Fatalf("party of %d: action expectation = %v, want %v", n, got, want)
|
||||
}
|
||||
}
|
||||
if got := partyEnemyHPScale(3); got != 1.15 {
|
||||
t.Fatalf("party HP scale = %v, want 1.15", got)
|
||||
}
|
||||
// int(200*1.15) truncates 229.99… to 229; the 1-HP floor is immaterial.
|
||||
if got := scaledEnemyMaxHP(200, 3); got != 229 {
|
||||
t.Fatalf("party enemy HP = %d, want 229 (trunc 200*1.15)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// P8: the enemy's action economy scales with the roster. A party of N faces up to
|
||||
// N attack-actions a round, each re-targeted, so the enemy's damage spreads across
|
||||
// the roster instead of pinning one seat — the 1/N² exposure that made P6e's party
|
||||
// a 100%-clear faceroll. Solo stays at exactly one swing a round, the pre-party
|
||||
// behaviour the characterization golden pins.
|
||||
func TestSimulateParty_EnemyActionEconomyScalesWithRoster(t *testing.T) {
|
||||
enemyRollsPerRound := func(res PartyCombatResult) map[int]int {
|
||||
perRound := map[int]int{}
|
||||
for _, e := range res.Events {
|
||||
if e.Actor == "enemy" && e.Roll > 0 {
|
||||
perRound[e.Round]++
|
||||
}
|
||||
}
|
||||
return perRound
|
||||
}
|
||||
|
||||
// Solo: never more than one enemy swing in a round.
|
||||
soloTank := baseEnemy()
|
||||
soloTank.Stats.MaxHP = 5000
|
||||
solo := simulatePartyWithRNG(
|
||||
[]Combatant{basePlayer()}, soloTank, dungeonCombatPhases, seededRNG(23))
|
||||
if len(enemyRollsPerRound(solo)) == 0 {
|
||||
t.Fatal("solo: the enemy never attacked")
|
||||
}
|
||||
for round, n := range enemyRollsPerRound(solo) {
|
||||
if n > 1 {
|
||||
t.Fatalf("solo round %d: enemy swung %d times, want at most 1", round, n)
|
||||
}
|
||||
}
|
||||
|
||||
// Party of 3: no round exceeds the roster's action budget, and at least one
|
||||
// round sees more than one swing — the enemy is really taking extra actions.
|
||||
tank := baseEnemy()
|
||||
tank.Stats.MaxHP = 5000
|
||||
|
||||
res := simulatePartyWithRNG(
|
||||
party := simulatePartyWithRNG(
|
||||
[]Combatant{basePlayer(), basePlayer(), basePlayer()}, tank, dungeonCombatPhases, seededRNG(23))
|
||||
|
||||
perRound := map[int]map[int]bool{}
|
||||
for _, e := range res.Events {
|
||||
if e.Actor != "enemy" || e.Roll == 0 {
|
||||
continue
|
||||
}
|
||||
if perRound[e.Round] == nil {
|
||||
perRound[e.Round] = map[int]bool{}
|
||||
}
|
||||
perRound[e.Round][e.Seat] = true
|
||||
budget := int(partyActionExpectation(3))
|
||||
if partyActionExpectation(3) > float64(budget) {
|
||||
budget++ // ceil: the coin-flip round can add one action
|
||||
}
|
||||
if len(perRound) == 0 {
|
||||
t.Fatal("the enemy never attacked")
|
||||
}
|
||||
for round, seats := range perRound {
|
||||
if len(seats) != 1 {
|
||||
t.Fatalf("round %d: enemy attack rolls landed on %d seats, want 1", round, len(seats))
|
||||
sawMulti := false
|
||||
for round, n := range enemyRollsPerRound(party) {
|
||||
if n > budget {
|
||||
t.Fatalf("party round %d: enemy swung %d times, over the %d-action budget", round, n, budget)
|
||||
}
|
||||
if n > 1 {
|
||||
sawMulti = true
|
||||
}
|
||||
}
|
||||
if !sawMulti {
|
||||
t.Fatal("a party of 3 never faced more than one enemy swing in a round — action economy did not scale")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
232
internal/plugin/combat_grim_harvest_turn_test.go
Normal file
232
internal/plugin/combat_grim_harvest_turn_test.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Grim Harvest on the turn-based surface.
|
||||
//
|
||||
// The auto-resolve path stashes the killing spell's slot level on the
|
||||
// fight-start CombatModifiers, where the close-out reads it. A turn-based fight
|
||||
// has nowhere to keep that: it rebuilds its combatants from the session row on
|
||||
// every !attack / !cast. resolveTurnSpell computed the stash into a local mod
|
||||
// set and dropped it on the floor, so a Necromancy Mage who killed with a spell
|
||||
// never healed — on a class that already trails.
|
||||
//
|
||||
// The stash now rides on the casting seat's ActorStatuses, and the close-out
|
||||
// asks whether the *last* spell_cast is the one that dropped the enemy to 0.
|
||||
//
|
||||
// (Empowered Evocation and Overchannel were never broken here: they only move
|
||||
// mods.SpellPreDamage, which resolveTurnSpell already returns as EnemyDamage.)
|
||||
|
||||
// necromancer is a Mage who has reached L5 Grim Harvest.
|
||||
func necromancer(t *testing.T, uid id.UserID, level int) *DnDCharacter {
|
||||
t.Helper()
|
||||
if err := createAdvCharacter(uid, string(uid)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassMage, Subclass: SubclassNecromancy, Level: level,
|
||||
STR: 8, DEX: 12, CON: 12, INT: 16, WIS: 10, CHA: 10,
|
||||
HPMax: 30, HPCurrent: 10, ArmorClass: 12,
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// tough is a stat block a spell can be cast at without any of it mattering.
|
||||
func tough() CombatStats { return CombatStats{MaxHP: 40, AC: 10} }
|
||||
|
||||
// ── the leak itself ──────────────────────────────────────────────────────────
|
||||
|
||||
// resolveTurnSpell must hand the stash back to its caller. Before this, the
|
||||
// slot level lived and died inside the function.
|
||||
func TestResolveTurnSpell_CarriesTheGrimHarvestStashOut(t *testing.T) {
|
||||
c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, INT: 16}
|
||||
|
||||
// magic_missile: auto-hit, force damage, upcast to a 3rd-level slot.
|
||||
spell, _ := lookupSpell("magic_missile")
|
||||
enemy := tough()
|
||||
out, ok := resolveTurnSpell(c, spell, 3, &enemy)
|
||||
if !ok {
|
||||
t.Fatal("magic_missile should be a supported turn spell")
|
||||
}
|
||||
if out.GrimHarvestSlot != 3 {
|
||||
t.Errorf("GrimHarvestSlot = %d, want 3 (the slot it was cast at)", out.GrimHarvestSlot)
|
||||
}
|
||||
if out.GrimHarvestNecrotic {
|
||||
t.Error("magic_missile deals force damage — Necrotic should be false")
|
||||
}
|
||||
|
||||
// blight: auto-hit, necrotic — the flag that triples the heal.
|
||||
spell, _ = lookupSpell("blight")
|
||||
enemy = tough()
|
||||
out, _ = resolveTurnSpell(c, spell, 4, &enemy)
|
||||
if out.GrimHarvestSlot != 4 || !out.GrimHarvestNecrotic {
|
||||
t.Errorf("blight L4: slot=%d necrotic=%v, want 4/true", out.GrimHarvestSlot, out.GrimHarvestNecrotic)
|
||||
}
|
||||
}
|
||||
|
||||
// Nobody else stashes. A Mage below L5 has not learned the harvest, and an
|
||||
// Evocation Mage never will.
|
||||
func TestResolveTurnSpell_NoStashForAnyoneElse(t *testing.T) {
|
||||
spell, _ := lookupSpell("magic_missile")
|
||||
for _, c := range []*DnDCharacter{
|
||||
{Class: ClassMage, Subclass: SubclassNecromancy, Level: 4, INT: 16},
|
||||
{Class: ClassMage, Subclass: SubclassEvocation, Level: 12, INT: 16},
|
||||
{Class: ClassSorcerer, Level: 12, CHA: 16},
|
||||
} {
|
||||
enemy := tough()
|
||||
out, _ := resolveTurnSpell(c, spell, 3, &enemy)
|
||||
if out.GrimHarvestSlot != 0 {
|
||||
t.Errorf("%s/%s L%d stashed slot %d, want 0",
|
||||
c.Class, c.Subclass, c.Level, out.GrimHarvestSlot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── which cast counts ────────────────────────────────────────────────────────
|
||||
|
||||
// The killing-blow check used to break on the first spell_cast it saw. In a
|
||||
// turn-based fight the mage casts every round, so the first one is an opening
|
||||
// cantrip that left the enemy standing — and it vetoed the heal the killing
|
||||
// spell had earned. It is the last cast that has to be lethal.
|
||||
func TestGrimHarvestHeal_ReadsTheLastCastNotTheFirst(t *testing.T) {
|
||||
c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, INT: 16}
|
||||
mods := CombatModifiers{GrimHarvestSlot: 2, GrimHarvestNecrotic: true}
|
||||
|
||||
result := CombatResult{PlayerWon: true, Events: []CombatEvent{
|
||||
{Round: 1, Action: "spell_cast", EnemyHP: 22}, // opening cantrip, enemy stands
|
||||
{Round: 2, Action: "enemy_attack", EnemyHP: 22},
|
||||
{Round: 2, Action: "spell_cast", EnemyHP: 0}, // this one killed it
|
||||
}}
|
||||
if got := grimHarvestHeal(c, result, mods); got != 6 {
|
||||
t.Errorf("heal = %d, want 6 (3× a 2nd-level necrotic slot)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The mirror: the mage softened it up with a spell, then finished it with a
|
||||
// weapon. No spell landed the blow, so no harvest.
|
||||
func TestGrimHarvestHeal_WeaponKillAfterASpellDoesNotHarvest(t *testing.T) {
|
||||
c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, INT: 16}
|
||||
mods := CombatModifiers{GrimHarvestSlot: 2, GrimHarvestNecrotic: true}
|
||||
|
||||
result := CombatResult{PlayerWon: true, Events: []CombatEvent{
|
||||
{Round: 1, Action: "spell_cast", EnemyHP: 9},
|
||||
{Round: 2, Action: "player_attack", EnemyHP: 0},
|
||||
}}
|
||||
if got := grimHarvestHeal(c, result, mods); got != 0 {
|
||||
t.Errorf("heal = %d, want 0 — the sword landed the blow, not the spell", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── the seam through the session ─────────────────────────────────────────────
|
||||
|
||||
// The stash has to survive the round-trip the fight puts it through: parked on
|
||||
// the seat by the cast, carried across commit()'s snapshot, read back by the
|
||||
// close-out.
|
||||
func TestSeatFightStartMods_ReadsTheStashOffTheSeat(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@seatstash:example.org")
|
||||
c := necromancer(t, uid, 5)
|
||||
|
||||
sess := &CombatSession{UserID: string(uid), Status: CombatStatusWon}
|
||||
sess.Statuses.GrimHarvestSlot = 3
|
||||
sess.Statuses.GrimHarvestNecrotic = true
|
||||
|
||||
mods := seatFightStartMods(sess, 0, c)
|
||||
if mods.GrimHarvestSlot != 3 || !mods.GrimHarvestNecrotic {
|
||||
t.Fatalf("seatFightStartMods lost the stash: %+v", mods)
|
||||
}
|
||||
|
||||
// snapshotActor rebuilds ActorStatuses from combatState each commit; fields
|
||||
// with no combatState counterpart must be carried over from the prior
|
||||
// snapshot, the way ArmedAbility is.
|
||||
kept := snapshotActor(&actor{}, sess.Statuses.ActorStatuses)
|
||||
if kept.GrimHarvestSlot != 3 || !kept.GrimHarvestNecrotic {
|
||||
t.Errorf("commit() dropped the stash: %+v", kept)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end at the close-out: a necromancer who ended the fight at 10/30 HP
|
||||
// with a lethal 2nd-level necrotic spell walks away with 6 HP back.
|
||||
func TestPostCombatBookkeepingForSeat_GrimHarvestHealsOnASpellKill(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@harvest:example.org")
|
||||
necromancer(t, uid, 5)
|
||||
|
||||
sess := &CombatSession{
|
||||
UserID: string(uid), Status: CombatStatusWon, Round: 3,
|
||||
PlayerHP: 10, PlayerHPMax: 30, EnemyHP: 0,
|
||||
TurnLog: []CombatEvent{
|
||||
{Round: 1, Action: "spell_cast", EnemyHP: 14},
|
||||
{Round: 3, Action: "spell_cast", EnemyHP: 0},
|
||||
},
|
||||
}
|
||||
sess.Statuses.GrimHarvestSlot = 2
|
||||
sess.Statuses.GrimHarvestNecrotic = true
|
||||
|
||||
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
|
||||
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.HPCurrent != 16 {
|
||||
t.Errorf("HPCurrent = %d after a turn-based spell kill, want 16 (10 + 3×2)", got.HPCurrent)
|
||||
}
|
||||
}
|
||||
|
||||
// A seat with no stash — the mage never cast, or missed every time — is left
|
||||
// exactly as the fight left them.
|
||||
func TestPostCombatBookkeepingForSeat_NoStashNoHeal(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@nostash:example.org")
|
||||
necromancer(t, uid, 5)
|
||||
|
||||
sess := &CombatSession{
|
||||
UserID: string(uid), Status: CombatStatusWon, Round: 2,
|
||||
PlayerHP: 10, PlayerHPMax: 30, EnemyHP: 0,
|
||||
TurnLog: []CombatEvent{{Round: 2, Action: "player_attack", EnemyHP: 0}},
|
||||
}
|
||||
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
|
||||
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.HPCurrent != 10 {
|
||||
t.Errorf("HPCurrent = %d, want 10 — nothing was stashed", got.HPCurrent)
|
||||
}
|
||||
}
|
||||
|
||||
// Party fights: the stash is per-seat, so seat 1's harvest cannot heal seat 0.
|
||||
// This is the same class of bug P5 fixed for mid-fight buffs.
|
||||
func TestGrimHarvestStash_IsPerSeat(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader, member := id.UserID("@leader:example.org"), id.UserID("@member:example.org")
|
||||
fightTestChar(t, leader, 30)
|
||||
necromancer(t, member, 5)
|
||||
|
||||
sess := &CombatSession{
|
||||
UserID: string(leader), Status: CombatStatusWon, Round: 2,
|
||||
PlayerHP: 20, PlayerHPMax: 30, EnemyHP: 0,
|
||||
Participants: []CombatParticipant{{Seat: 1, UserID: string(member), HP: 10, HPMax: 30}},
|
||||
TurnLog: []CombatEvent{
|
||||
{Round: 2, Seat: 1, Action: "spell_cast", EnemyHP: 0},
|
||||
},
|
||||
}
|
||||
sess.Participants[0].Statuses.GrimHarvestSlot = 2
|
||||
sess.Participants[0].Statuses.GrimHarvestNecrotic = true
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
p.postCombatBookkeepingForSeat(sess, 0)
|
||||
p.postCombatBookkeepingForSeat(sess, 1)
|
||||
|
||||
gotLeader, _ := LoadDnDCharacter(leader)
|
||||
if gotLeader.HPCurrent != 30 {
|
||||
t.Errorf("leader HPCurrent = %d, want 30 — the member's harvest is not theirs", gotLeader.HPCurrent)
|
||||
}
|
||||
gotMember, _ := LoadDnDCharacter(member)
|
||||
if gotMember.HPCurrent != 16 {
|
||||
t.Errorf("member HPCurrent = %d, want 16 (10 + 3×2)", gotMember.HPCurrent)
|
||||
}
|
||||
}
|
||||
169
internal/plugin/combat_misty_turn_test.go
Normal file
169
internal/plugin/combat_misty_turn_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Misty's two round-end procs on the turn-based surface.
|
||||
//
|
||||
// Both were built onto every turn-based combatant by DerivePlayerStats and then
|
||||
// never read: stepRoundEnd had no counterpart to endOfRoundForSeat. The buff was
|
||||
// simply lost. The debuff was an exploit — a player who declined Misty escaped
|
||||
// her crowd entirely by fighting with !attack instead of letting the room
|
||||
// auto-resolve, which needed no discovery at all.
|
||||
//
|
||||
// seatEndOfRound is the shared hook. These pin that it fires, that it cannot be
|
||||
// dodged, and that a character with no Misty history is not touched at all —
|
||||
// the property that keeps the sim corpus and the golden file still.
|
||||
|
||||
// mistyState seats one combatant at hp, cursor armed, with a seeded RNG.
|
||||
func mistyState(t *testing.T, player *Combatant, hp int) *combatState {
|
||||
t.Helper()
|
||||
st := testCombatState(hp, 100, 1, rand.New(rand.NewPCG(7, 7)))
|
||||
st.actor.c = player
|
||||
st.actor.hpMax = player.Stats.MaxHP
|
||||
return st
|
||||
}
|
||||
|
||||
// The two procs at certainty, so no test depends on a roll.
|
||||
func mistyCursed(maxHP, dmg int) *Combatant {
|
||||
return &Combatant{
|
||||
Name: "Cursed",
|
||||
Stats: CombatStats{MaxHP: maxHP, AC: 10},
|
||||
Mods: CombatModifiers{CrowdRevengeProc: 1.0, CrowdRevengeDmg: dmg},
|
||||
}
|
||||
}
|
||||
|
||||
func mistyBuffed(maxHP, heal int) *Combatant {
|
||||
return &Combatant{
|
||||
Name: "Buffed",
|
||||
Stats: CombatStats{MaxHP: maxHP, AC: 10},
|
||||
Mods: CombatModifiers{MistyHealProc: 1.0, MistyHealAmt: heal},
|
||||
}
|
||||
}
|
||||
|
||||
// ── the exploit ──────────────────────────────────────────────────────────────
|
||||
|
||||
// The debuff must land at round end in a manual fight, exactly as it does when
|
||||
// the room auto-resolves. Before seatEndOfRound, !attack was a clean escape.
|
||||
func TestSeatEndOfRound_CrowdRevengeCannotBeDodgedByFightingManually(t *testing.T) {
|
||||
st := mistyState(t, mistyCursed(40, 6), 40)
|
||||
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})
|
||||
|
||||
if over {
|
||||
t.Fatal("a 6-damage swing against 40 HP should not end the fight")
|
||||
}
|
||||
if st.playerHP != 34 {
|
||||
t.Errorf("playerHP = %d, want 34 — Misty's crowd took its swing", st.playerHP)
|
||||
}
|
||||
if !hasAction(st.events, "crowd_revenge") {
|
||||
t.Error("no crowd_revenge event on the log")
|
||||
}
|
||||
}
|
||||
|
||||
// The debuff can be lethal, and a lethal one with nobody else standing ends the
|
||||
// fight rather than leaving a corpse to take the next round's turn.
|
||||
func TestSeatEndOfRound_CrowdRevengeCanEndASoloFight(t *testing.T) {
|
||||
st := mistyState(t, mistyCursed(40, 30), 3)
|
||||
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})
|
||||
|
||||
if st.playerHP != 0 {
|
||||
t.Fatalf("playerHP = %d, want 0", st.playerHP)
|
||||
}
|
||||
if !over {
|
||||
t.Error("a solo character dropped by the crowd should end the fight")
|
||||
}
|
||||
}
|
||||
|
||||
// ── the buff ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// The heal fires, caps at max HP, and marks the result so the achievement can
|
||||
// be granted.
|
||||
func TestSeatEndOfRound_MistyHealFiresAndCaps(t *testing.T) {
|
||||
st := mistyState(t, mistyBuffed(40, 12), 34)
|
||||
var res CombatResult
|
||||
seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &res)
|
||||
|
||||
if st.playerHP != 40 {
|
||||
t.Errorf("playerHP = %d, want 40 — the heal caps at max HP", st.playerHP)
|
||||
}
|
||||
if !res.MistyHealed {
|
||||
t.Error("MistyHealed flag not set")
|
||||
}
|
||||
if !hasAction(st.events, "misty_heal") {
|
||||
t.Error("no misty_heal event on the log")
|
||||
}
|
||||
}
|
||||
|
||||
// A character the crowd just dropped is not then healed back up by the same
|
||||
// hook. The heal is for the living.
|
||||
func TestSeatEndOfRound_NoHealForACharacterTheCrowdJustDropped(t *testing.T) {
|
||||
c := mistyCursed(40, 40)
|
||||
c.Mods.MistyHealProc = 1.0
|
||||
c.Mods.MistyHealAmt = 20
|
||||
|
||||
st := mistyState(t, c, 10)
|
||||
st.actors = append(st.actors, &actor{playerHP: 5}) // an ally keeps the fight alive
|
||||
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})
|
||||
|
||||
if over {
|
||||
t.Fatal("an ally is still standing — the fight is not over")
|
||||
}
|
||||
if st.playerHP != 0 {
|
||||
t.Errorf("playerHP = %d, want 0 — the heal must not resurrect them", st.playerHP)
|
||||
}
|
||||
if hasAction(st.events, "misty_heal") {
|
||||
t.Error("misty_heal fired on a downed character")
|
||||
}
|
||||
}
|
||||
|
||||
// ── the property that protects the corpus ────────────────────────────────────
|
||||
|
||||
// A character with no Misty history is untouched: no HP change, no events, and
|
||||
// — because both procs short-circuit before st.randFloat() — no dice drawn. If
|
||||
// the hook drew even one float, every simulated fight would diverge and
|
||||
// combat_characterization.golden would move.
|
||||
func TestSeatEndOfRound_UnbuffedCharacterIsUntouchedAndDrawsNoDice(t *testing.T) {
|
||||
plain := &Combatant{Name: "Plain", Stats: CombatStats{MaxHP: 40, AC: 10}}
|
||||
|
||||
st := mistyState(t, plain, 40)
|
||||
control := mistyState(t, plain, 40) // same seed, never passed to the hook
|
||||
|
||||
if over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{}); over {
|
||||
t.Fatal("an unbuffed character cannot be dropped by a hook that does nothing")
|
||||
}
|
||||
if st.playerHP != 40 || len(st.events) != 0 {
|
||||
t.Errorf("unbuffed character was touched: hp=%d events=%d", st.playerHP, len(st.events))
|
||||
}
|
||||
// The RNG streams must still be in lockstep: the hook consumed nothing.
|
||||
if got, want := st.randFloat(), control.randFloat(); got != want {
|
||||
t.Errorf("seatEndOfRound consumed RNG: next float %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ── the wiring ───────────────────────────────────────────────────────────────
|
||||
|
||||
// The unit tests above call seatEndOfRound directly, which proves the hook is
|
||||
// correct but not that stepRoundEnd calls it. This drives the real session API
|
||||
// through a round_end step. Comment the call out of stepRoundEnd and this test
|
||||
// reports PlayerHP=40 — the exploit, exactly as it shipped.
|
||||
func TestStepRoundEnd_WiresSeatEndOfRound(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
player := mistyCursed(40, 6)
|
||||
enemy := &Combatant{Name: "Target", Stats: CombatStats{MaxHP: 100, AC: 10}}
|
||||
sess := &CombatSession{
|
||||
SessionID: "wiring", UserID: "@u:example.org", Status: CombatStatusActive,
|
||||
PlayerHP: 40, PlayerHPMax: 40, EnemyHP: 100,
|
||||
Phase: CombatPhaseRoundEnd, Round: 1,
|
||||
}
|
||||
if _, err := advanceCombatSession(sess, player, enemy, PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.PlayerHP != 34 {
|
||||
t.Errorf("PlayerHP = %d, want 34 — stepRoundEnd never ran the hook", sess.PlayerHP)
|
||||
}
|
||||
if !hasAction(sess.TurnLog, "crowd_revenge") {
|
||||
t.Error("no crowd_revenge event persisted to the session log")
|
||||
}
|
||||
}
|
||||
@@ -979,6 +979,12 @@ func renderAllySeatEvent(e CombatEvent, name, enemyName string) string {
|
||||
return down(fmt.Sprintf("☠️ %s takes %d from poison.", who, e.Damage))
|
||||
case "environmental":
|
||||
return down(fmt.Sprintf("%s takes %d from the room.", who, e.Damage))
|
||||
case "crowd_revenge":
|
||||
// Deliberately unattributed. An ally sees the damage land — silence
|
||||
// would read as HP vanishing — but Misty's grudge is the owner's own
|
||||
// discovery, and naming her here would spoil it for the whole party.
|
||||
// Same reason misty_heal below reads as a plain recovery.
|
||||
return down(fmt.Sprintf("%s takes %d.", who, e.Damage))
|
||||
case "flat_damage":
|
||||
return fmt.Sprintf("%s deals %d.", who, e.Damage)
|
||||
case "heal_item", "misty_heal":
|
||||
|
||||
@@ -51,9 +51,12 @@ func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
|
||||
// nat20/nat1 mood deltas from the whole fight's event log — the run's, so once.
|
||||
scanMoodEventsFromEvents(sess.RunID, sess.TurnLog)
|
||||
|
||||
// Every seat's HP lands on their sheet regardless of how the fight ended.
|
||||
// Every seat's HP lands on their sheet regardless of how the fight ended, and
|
||||
// so does the bookkeeping that outlives the fight — a Berserker who raged and
|
||||
// lost is still exhausted. Both fan out; neither is the owner's alone.
|
||||
for seat := range sess.RosterSize() {
|
||||
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
|
||||
p.postCombatBookkeepingForSeat(sess, seat)
|
||||
}
|
||||
|
||||
switch sess.Status {
|
||||
@@ -62,8 +65,7 @@ func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
|
||||
case CombatStatusLost:
|
||||
return p.finishPartyLoss(ct, zone, cadence)
|
||||
case CombatStatusFled:
|
||||
_ = abandonZoneRun(owner)
|
||||
forceExtractExpeditionForRunLoss(owner, "combat flee")
|
||||
endRunOnLoss(owner, sess.RunID, false)
|
||||
return p.eachSeat(ct, fmt.Sprintf(
|
||||
"🏃 The party broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name))
|
||||
default:
|
||||
@@ -81,16 +83,7 @@ func (p *AdventurePlugin) finishPartyWin(
|
||||
sess := ct.sess
|
||||
owner := id.UserID(sess.UserID)
|
||||
|
||||
recordZoneKillForUser(owner, sess.EnemyID)
|
||||
applyRoomCombatThreatForUser(owner, elite)
|
||||
|
||||
bossOnExpedition := false
|
||||
if !elite {
|
||||
if exp, eerr := getActiveExpedition(owner); eerr == nil && exp != nil {
|
||||
_ = applyBossDefeatThreat(exp.ID)
|
||||
bossOnExpedition = true
|
||||
}
|
||||
}
|
||||
bossOnExpedition := p.applyOwnerWinEffects(owner, sess.EnemyID, elite)
|
||||
|
||||
tier := 1
|
||||
if run != nil {
|
||||
@@ -107,14 +100,8 @@ func (p *AdventurePlugin) finishPartyWin(
|
||||
uid := id.UserID(sess.seatUserID(seat))
|
||||
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
|
||||
|
||||
// A member who went down before the killing blow still earns the kill —
|
||||
// but at a fifth of their pool or less, the XP path calls it near-death.
|
||||
nearDeath := hpMax > 0 && hp*5 < hpMax
|
||||
if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 {
|
||||
if _, err := p.grantDnDXP(uid, xp); err != nil {
|
||||
slog.Error("combat: party grantDnDXP", "user", uid, "err", err)
|
||||
}
|
||||
}
|
||||
// A member who went down before the killing blow still earns the kill.
|
||||
p.grantSeatWinXP(uid, hp, hpMax, monster, tier)
|
||||
|
||||
var b strings.Builder
|
||||
if shared != "" && seat == 0 {
|
||||
@@ -153,11 +140,7 @@ func (p *AdventurePlugin) finishPartyLoss(ct *combatTurn, zone ZoneDefinition, c
|
||||
sess := ct.sess
|
||||
owner := id.UserID(sess.UserID)
|
||||
|
||||
if run, _ := getZoneRun(sess.RunID); run != nil {
|
||||
_, _ = applyMoodEvent(sess.RunID, MoodEventPlayerDeath)
|
||||
}
|
||||
_ = abandonZoneRun(owner)
|
||||
forceExtractExpeditionForRunLoss(owner, "combat death")
|
||||
endRunOnLoss(owner, sess.RunID, true)
|
||||
|
||||
line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence)
|
||||
out := make([]string, sess.RosterSize())
|
||||
@@ -181,3 +164,62 @@ func (p *AdventurePlugin) eachSeat(ct *combatTurn, block string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Shared terminal effects (item D) ─────────────────────────────────────────
|
||||
//
|
||||
// finishCombatSession (solo, combat_cmd.go) and finishPartyWin/finishPartyLoss
|
||||
// (party, above) still write their own player-facing text — the solo line says
|
||||
// "You finished", the party line says "The party stands", and the death-on-win
|
||||
// rule is deliberately roster-size-dependent (see item E). But the *effects*
|
||||
// they fire were hand-copied lists, and item A drifted exactly there. These
|
||||
// helpers are the single copy of each effect; both close-outs route through
|
||||
// them so the effect lists cannot diverge again.
|
||||
|
||||
// applyOwnerWinEffects fires the terminal effects a win owes the run and
|
||||
// expedition — the zone-kill record, room threat, and the boss-defeat threat
|
||||
// drop — once, through the row's owner (a party member owns neither row).
|
||||
// Returns whether the kill was a zone boss on an active expedition; both
|
||||
// close-outs use it to reframe the final line as the expedition's climax.
|
||||
func (p *AdventurePlugin) applyOwnerWinEffects(owner id.UserID, enemyID string, elite bool) (bossOnExpedition bool) {
|
||||
recordZoneKillForUser(owner, enemyID)
|
||||
applyRoomCombatThreatForUser(owner, elite)
|
||||
if !elite {
|
||||
// §8.1 — a zone boss defeat drops expedition threat. Silent no-op for a
|
||||
// standalone zone run with no active expedition.
|
||||
if exp, eerr := getActiveExpedition(owner); eerr == nil && exp != nil {
|
||||
_ = applyBossDefeatThreat(exp.ID)
|
||||
bossOnExpedition = true
|
||||
}
|
||||
}
|
||||
return bossOnExpedition
|
||||
}
|
||||
|
||||
// grantSeatWinXP pays one combatant their kill XP. A seat that finished at a
|
||||
// fifth of its pool or less takes the near-death path. Full XP per person,
|
||||
// solo or party, per the accessibility-over-crunch stance.
|
||||
func (p *AdventurePlugin) grantSeatWinXP(uid id.UserID, hp, hpMax int, monster DnDMonsterTemplate, tier int) {
|
||||
nearDeath := hpMax > 0 && hp*5 < hpMax
|
||||
if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 {
|
||||
if _, err := p.grantDnDXP(uid, xp); err != nil {
|
||||
slog.Error("combat: grantDnDXP", "user", uid, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// endRunOnLoss tears down the owner's run and wrapping expedition when a fight
|
||||
// ends in death or flight. On a death (not a flee) it also stamps the run's
|
||||
// mood event. Owner-scoped, fires once; the per-seat death mark stays with the
|
||||
// caller, since it is decided per HP and gated on roster size.
|
||||
func endRunOnLoss(owner id.UserID, runID string, death bool) {
|
||||
if death {
|
||||
if run, _ := getZoneRun(runID); run != nil {
|
||||
_, _ = applyMoodEvent(runID, MoodEventPlayerDeath)
|
||||
}
|
||||
}
|
||||
_ = abandonZoneRun(owner)
|
||||
reason := "combat flee"
|
||||
if death {
|
||||
reason = "combat death"
|
||||
}
|
||||
forceExtractExpeditionForRunLoss(owner, reason)
|
||||
}
|
||||
|
||||
@@ -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,24 @@ type ActorStatuses struct {
|
||||
// and druids with no sustained DPS once their burst landed.
|
||||
ConcentrationDmg int `json:"concentration_dmg,omitempty"`
|
||||
|
||||
// ArmedAbility is the id of the active ability this character armed and
|
||||
// spent entering the fight (rage, second_wind, …). The resource is already
|
||||
// debited and the character disarmed; this is the record that lets every
|
||||
// rebuild of an in-flight fight re-apply the ability's mods, and lets the
|
||||
// close-out know a rage fired. Empty when nothing was armed.
|
||||
ArmedAbility string `json:"armed_ability,omitempty"`
|
||||
|
||||
// GrimHarvestSlot / GrimHarvestNecrotic snapshot the most recent damaging
|
||||
// spell this seat cast, for the Necromancy Mage's post-combat kill-heal.
|
||||
// The auto-resolve path carries the same pair on CombatModifiers, stashed
|
||||
// once by applyPendingCast; the turn-based path can cast every round, so
|
||||
// the stash lives here and each damaging cast overwrites it. Whether the
|
||||
// heal actually fires is decided at close-out by grimHarvestHeal, which
|
||||
// asks whether the *last* spell_cast event is the one that dropped the
|
||||
// enemy to 0 — so a stale stash from an earlier, non-lethal cast is inert.
|
||||
GrimHarvestSlot int `json:"grim_harvest_slot,omitempty"`
|
||||
GrimHarvestNecrotic bool `json:"grim_harvest_necrotic,omitempty"`
|
||||
|
||||
// Once-per-fight class/race/subclass one-shots: the "already used" flags.
|
||||
// Without persistence these reset every round on resume, letting a Halfling
|
||||
// reroll a nat 1 or an Orc rage every single round of a turn-based fight.
|
||||
@@ -221,23 +239,26 @@ func (s *ActorStatuses) applyBuffDelta(d turnBuffDelta) {
|
||||
// the Abjuration Arcane Ward is normally non-zero at fight start — the
|
||||
// turn-based build deliberately omits pre-combat consumables and queued casts —
|
||||
// but the full set is seeded for robustness. Returns true if anything was set.
|
||||
func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) bool {
|
||||
return seedActorOneShots(&s.Statuses.ActorStatuses, playerMods)
|
||||
func seedCombatSessionOneShots(s *CombatSession, seat CombatSeatSetup) bool {
|
||||
return seedActorOneShots(&s.Statuses.ActorStatuses, seat)
|
||||
}
|
||||
|
||||
// seedActorOneShots copies one character's fight-start one-shot resources onto
|
||||
// their persisted statuses. Seat 0's live on the session row; a party member's
|
||||
// live on their participant row, and each seat reads its own combatant's mods —
|
||||
// a party Abjurer brings their own Arcane Ward, not the leader's.
|
||||
func seedActorOneShots(st *ActorStatuses, playerMods CombatModifiers) bool {
|
||||
func seedActorOneShots(st *ActorStatuses, seat CombatSeatSetup) bool {
|
||||
playerMods := seat.Mods
|
||||
st.WardCharges = playerMods.WardCharges
|
||||
st.SporeRounds = playerMods.SporeCloud
|
||||
st.ReflectFrac = playerMods.ReflectNext
|
||||
st.AutoCritFirst = playerMods.AutoCritFirst
|
||||
st.ArcaneWardHP = playerMods.ArcaneWardHP
|
||||
st.HealChargesLeft = playerMods.HealItemCharges
|
||||
st.ArmedAbility = seat.ArmedAbility
|
||||
return st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
|
||||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0
|
||||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 ||
|
||||
st.ArmedAbility != ""
|
||||
}
|
||||
|
||||
// CombatParticipant is one party member's seat in a fight, from seat 1 up.
|
||||
@@ -467,7 +488,10 @@ func (p *AdventurePlugin) startPartyCombatSession(
|
||||
if len(seats) == 0 {
|
||||
return nil, fmt.Errorf("start combat session: empty roster")
|
||||
}
|
||||
enemyHP := enemy.Stats.MaxHP
|
||||
// Party-only enemy HP bump (solo roster scales by 1.0). The per-turn rebuild
|
||||
// in partyCombatantsForSession applies the identical scalar to the enemy's
|
||||
// Stats.MaxHP, so the persisted current HP and the rebuilt max never drift.
|
||||
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats))
|
||||
owner := seats[0]
|
||||
sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID,
|
||||
owner.HP, owner.HPMax, enemyHP, enemyHP)
|
||||
@@ -477,13 +501,13 @@ func (p *AdventurePlugin) startPartyCombatSession(
|
||||
|
||||
// Seat 0's one-shots live on the session row; seeding them is a mutation of
|
||||
// sess.Statuses that the save below flushes along with the participants.
|
||||
dirty := seedCombatSessionOneShots(sess, owner.Mods)
|
||||
dirty := seedCombatSessionOneShots(sess, owner)
|
||||
|
||||
if len(seats) > 1 {
|
||||
ps := make([]CombatParticipant, 0, len(seats)-1)
|
||||
for i, s := range seats[1:] {
|
||||
var st ActorStatuses
|
||||
seedActorOneShots(&st, s.Mods)
|
||||
seedActorOneShots(&st, s)
|
||||
ps = append(ps, CombatParticipant{
|
||||
Seat: i + 1, UserID: string(s.UserID), HP: s.HP, HPMax: s.HPMax, Statuses: st,
|
||||
})
|
||||
@@ -525,6 +549,10 @@ type CombatSeatSetup struct {
|
||||
HPMax int
|
||||
Mods CombatModifiers
|
||||
C *Combatant
|
||||
// ArmedAbility is the ability id this seat consumed entering the fight, ""
|
||||
// if they armed nothing. It is persisted onto the seat's statuses so every
|
||||
// later rebuild can re-apply the ability without re-spending it.
|
||||
ArmedAbility string
|
||||
}
|
||||
|
||||
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil).
|
||||
|
||||
@@ -33,11 +33,18 @@ import (
|
||||
// + armed ability) and the monster's bestiary stat block with tier scaling and
|
||||
// the DM-mood combat tilt folded in. The returned dndChar is handed back so
|
||||
// callers can run post-combat subclass persistence without reloading it.
|
||||
//
|
||||
// armed is the ability id already consumed for this fight (consumeArmedAbility,
|
||||
// or armAbilityForFight's return on the auto-resolve paths); "" means none. The
|
||||
// build re-applies it rather than consuming, because the turn-based path calls
|
||||
// this once per player command and a consuming build would spend the ability on
|
||||
// round 1 and drop it for the rest of the fight.
|
||||
func (p *AdventurePlugin) buildZoneCombatants(
|
||||
userID id.UserID,
|
||||
monster DnDMonsterTemplate,
|
||||
tier int,
|
||||
dmMood int,
|
||||
armed string,
|
||||
) (player Combatant, enemy Combatant, dndChar *DnDCharacter, err error) {
|
||||
tilt := dmMoodCombatTilt(dmMood)
|
||||
char, err := loadAdvCharacter(userID)
|
||||
@@ -64,10 +71,7 @@ func (p *AdventurePlugin) buildZoneCombatants(
|
||||
applyRacePassives(&playerStats, &playerMods, dndChar)
|
||||
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
||||
applyMagicItemEffects(&playerStats, &playerMods, userID)
|
||||
trySimAutoArm(dndChar)
|
||||
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
||||
slog.Info("dnd: armed ability fired (turn-based build)", "user", userID, "ability", firedName)
|
||||
}
|
||||
applyAbilityByID(dndChar, armed, &playerMods)
|
||||
|
||||
enemyStats, enemyMods := monster.toCombatStats()
|
||||
// Tier scaling mirrors runZoneCombat: only raise AC/AttackBonus to a
|
||||
@@ -152,7 +156,11 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
|
||||
players := make([]*Combatant, len(seats))
|
||||
var enemy Combatant
|
||||
for seat, uid := range seats {
|
||||
player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood)
|
||||
// The ability this seat armed was consumed once, at fight start, and its
|
||||
// id parked on their statuses. Re-applying it here — not re-consuming —
|
||||
// is what makes a rage last the whole fight instead of one round.
|
||||
st := sess.actorStatusesForSeat(seat)
|
||||
player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
|
||||
}
|
||||
@@ -161,18 +169,107 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
|
||||
// one-shots (ward/spore/…) live on their persisted statuses and flow
|
||||
// through the turn engine's resume/commit cycle, so only the persistent
|
||||
// stat deltas are applied here — and only that seat's own.
|
||||
applySessionBuffs(&player, sess.actorStatusesForSeat(seat))
|
||||
applySessionBuffs(&player, st)
|
||||
players[seat] = &player
|
||||
if seat == 0 {
|
||||
// The enemy build reads only (monster, tier, dmMood): every seat
|
||||
// rebuilds the identical stat block, so seat 0's copy is the fight's.
|
||||
// Only the *player* half of the build varies by seat.
|
||||
enemy = e
|
||||
// Party-only enemy HP bump, re-derived each turn from the template so
|
||||
// it never compounds. Matches the scalar startPartyCombatSession used
|
||||
// for the initial persist; solo (roster 1) scales by 1.0.
|
||||
if sess.IsParty() {
|
||||
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, sess.RosterSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
return players, &enemy, nil
|
||||
}
|
||||
|
||||
// seatFightStartMods re-derives the modifiers a finished fight's close-out still
|
||||
// needs: the Berserker's rage flag, which decides whether the character owes a
|
||||
// point of exhaustion, and the Necromancy Mage's Grim Harvest stash.
|
||||
//
|
||||
// It re-applies the seat's armed ability to an empty mod set rather than
|
||||
// rebuilding the whole combatant: no Apply writes to the character (they read
|
||||
// level and HP and write only mods), so this is pure, and the passive/equipment
|
||||
// layers a full build would add are not read by any post-combat hook.
|
||||
//
|
||||
// The Grim Harvest pair is not re-derived but read back off the seat's
|
||||
// statuses, where castActionForSeat parked it: the spell that stashed it was
|
||||
// cast mid-fight, not at fight start, and nothing in the character sheet still
|
||||
// remembers which one it was.
|
||||
func seatFightStartMods(sess *CombatSession, seat int, c *DnDCharacter) CombatModifiers {
|
||||
var mods CombatModifiers
|
||||
st := sess.actorStatusesForSeat(seat)
|
||||
applyAbilityByID(c, st.ArmedAbility, &mods)
|
||||
mods.GrimHarvestSlot = st.GrimHarvestSlot
|
||||
mods.GrimHarvestNecrotic = st.GrimHarvestNecrotic
|
||||
return mods
|
||||
}
|
||||
|
||||
// seatCombatResult reconstructs, for one seat of a finished turn-based fight,
|
||||
// the slice of CombatResult that the post-combat hooks actually read. The turn
|
||||
// engine never builds a CombatResult — it persists a session and a shared event
|
||||
// log — so the close-out has to assemble one.
|
||||
//
|
||||
// MistyHealed is read back off the seat's event log rather than off a flag: the
|
||||
// turn engine's CombatResult is a scratch value it discards between steps, and
|
||||
// the log is the only thing that survives to the close-out. This is how
|
||||
// combat_pet_save has always been detected.
|
||||
//
|
||||
// SniperKilled stays false. Arina's proc is a pre-combat one-shot, not a
|
||||
// round-end effect, so seatEndOfRound does not carry it and the turn engine
|
||||
// still has no place to fire it — combat_sniper_kill remains unreachable from a
|
||||
// manual kill.
|
||||
//
|
||||
// NearDeath mirrors the auto-resolve engine's win threshold (below 15% of max);
|
||||
// its loss-side meaning is unused here, since the only hook that reads it gates
|
||||
// on PlayerWon.
|
||||
func seatCombatResult(sess *CombatSession, seat int) CombatResult {
|
||||
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
|
||||
won := sess.Status == CombatStatusWon
|
||||
events := eventsForSeat(sess.TurnLog, seat)
|
||||
return CombatResult{
|
||||
PlayerWon: won,
|
||||
Events: events,
|
||||
PlayerEndHP: hp,
|
||||
EnemyEndHP: sess.EnemyHP,
|
||||
TotalRounds: sess.Round,
|
||||
NearDeath: won && hp > 0 && float64(hp) < float64(max(1, hpMax))*0.15,
|
||||
MistyHealed: hasAction(events, "misty_heal"),
|
||||
}
|
||||
}
|
||||
|
||||
// hasAction reports whether the event log holds at least one event with the
|
||||
// given Action. The one-line "did this happen in the fight?" scan the close-out
|
||||
// uses to read a proc's outcome off the log rather than a flag.
|
||||
func hasAction(events []CombatEvent, action string) bool {
|
||||
for _, e := range events {
|
||||
if e.Action == action {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// postCombatBookkeepingForSeat is postCombatBookkeeping for one seat of a
|
||||
// terminal turn-based session — the bridge between what the turn engine
|
||||
// persisted and what the shared close-out expects.
|
||||
func (p *AdventurePlugin) postCombatBookkeepingForSeat(sess *CombatSession, seat int) {
|
||||
uid := id.UserID(sess.seatUserID(seat))
|
||||
// Loaded after the caller's persistDnDHPAfterCombat, so a Grim-Harvest-style
|
||||
// hook that heals off HPCurrent reads the fight's real ending HP.
|
||||
c, err := LoadDnDCharacter(uid)
|
||||
if err != nil || c == nil {
|
||||
slog.Error("combat: post-combat bookkeeping skipped, no sheet", "user", uid, "err", err)
|
||||
return
|
||||
}
|
||||
mods := seatFightStartMods(sess, seat, c)
|
||||
p.postCombatBookkeeping(uid, c, mods.BerserkerRage, seatCombatResult(sess, seat), mods)
|
||||
}
|
||||
|
||||
// applySessionBuffs re-derives the persistent stat effect of every mid-fight
|
||||
// buff onto one rebuilt character. The buffs are stored as accumulated deltas
|
||||
// (diffTurnBuff produced them against that character's state at cast time), so
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,10 +414,11 @@ func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) {
|
||||
te.stepPlayerTurn(action)
|
||||
te.stampSeat(0, acting)
|
||||
case CombatPhaseEnemyTurn:
|
||||
// stepEnemyTurn self-stamps each attack-action to the seat it targeted: a
|
||||
// party's enemy re-targets across the roster within one turn, so there is
|
||||
// no single seat the whole phase lands on. Solo stamps seat 0 throughout,
|
||||
// exactly as the old blanket stamp did.
|
||||
te.stepEnemyTurn()
|
||||
// stepEnemyTurn seats its target before anything resolves, and the whole
|
||||
// phase lands on that one character.
|
||||
te.stampSeat(0, te.st.seatIdx)
|
||||
case CombatPhaseRoundEnd:
|
||||
te.stepRoundEnd()
|
||||
case CombatPhaseOver:
|
||||
@@ -615,34 +616,39 @@ func (te *turnEngine) stepEnemyTurn() {
|
||||
return
|
||||
}
|
||||
te.st.seat(target)
|
||||
// A control spell cast last phase forfeits the enemy's attack this round —
|
||||
// unless the enemy is fear_immune, in which case the control fizzled and it
|
||||
// acts as normal.
|
||||
// A control spell cast last phase forfeits the enemy's whole turn (every
|
||||
// attack-action) this round — unless the enemy is fear_immune, in which case
|
||||
// the control fizzled and it acts as normal.
|
||||
if te.st.enemySkipFirst {
|
||||
te.st.enemySkipFirst = false
|
||||
mark := len(te.st.events)
|
||||
if enemyImmuneToControl(te.enemy, te.st) {
|
||||
te.st.events = append(te.st.events, CombatEvent{
|
||||
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "fear_resist",
|
||||
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
|
||||
})
|
||||
te.stampSeat(mark, target)
|
||||
} else {
|
||||
te.st.events = append(te.st.events, CombatEvent{
|
||||
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held",
|
||||
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
|
||||
})
|
||||
te.stampSeat(mark, target)
|
||||
te.advance()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Monster ability fires at the top of the enemy turn. cleave / lifesteal
|
||||
// resolve their own damage and stand in for the attack action this round;
|
||||
// every other effect (poison / stun / enrage / armor_break) is a rider that
|
||||
// leaves the multiattack below intact. applyAbility returns true when the
|
||||
// player went down without a save.
|
||||
// Monster ability fires once, at the top of the enemy turn, against the
|
||||
// initial target. cleave / lifesteal resolve their own damage and stand in
|
||||
// for the enemy's first attack-action this round; every other effect (poison
|
||||
// / stun / enrage / armor_break) is a rider that leaves the multiattack below
|
||||
// intact. applyAbility returns true when the player went down without a save.
|
||||
abilityDealtDamage := false
|
||||
if te.enemy.Ability != nil && turnAbilityFires(te.enemy.Ability, te.st, te.enemy) {
|
||||
mark := len(te.st.events)
|
||||
if applyAbility(te.st, te.st.c, te.enemy, &turnCombatPhase, te.result) {
|
||||
te.stampSeat(mark, target)
|
||||
// The target went down without a save. The fight only ends if it
|
||||
// took the last member with it; otherwise the enemy has spent its
|
||||
// turn on that kill.
|
||||
@@ -653,57 +659,90 @@ func (te *turnEngine) stepEnemyTurn() {
|
||||
}
|
||||
return
|
||||
}
|
||||
te.stampSeat(mark, target)
|
||||
switch te.enemy.Ability.Effect {
|
||||
case "cleave", "lifesteal":
|
||||
abilityDealtDamage = true
|
||||
}
|
||||
}
|
||||
|
||||
if !abilityDealtDamage {
|
||||
// Pet defensive procs are a single proc per enemy turn: roll once, then
|
||||
// spend it on the first swing only. Whiff makes that one swing a
|
||||
// guaranteed miss; deflect halves its damage. Against a multiattack the
|
||||
// remaining swings resolve normally — a single proc shouldn't nullify a
|
||||
// boss's whole multiattack round. (This deliberately diverges from the
|
||||
// auto-resolve engine's apply-to-all model.)
|
||||
petWhiff := te.st.c.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.st.c.Mods.PetWhiffProc
|
||||
petDeflect := te.st.c.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.st.c.Mods.PetDeflectProc
|
||||
if petDeflect {
|
||||
te.result.PetDeflected = true
|
||||
}
|
||||
|
||||
// SRD multiattack: each profile entry is one attack roll resolved
|
||||
// through the shared primitive. A registered elite/boss swings its full
|
||||
// profile; everyone else gets a single attack from the template stats.
|
||||
// resolveEnemyAttack returns true when the fight is decided — either the
|
||||
// player went down without a death save, or a reflect consumable killed
|
||||
// the enemy. Disambiguate by inspecting HP.
|
||||
for i, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) {
|
||||
swing := *te.enemy
|
||||
swing.Stats.Attack = atk.Damage
|
||||
swing.Stats.AttackBonus = atk.AttackBonus
|
||||
// Spend the proc on the first swing only; later swings see false.
|
||||
swingWhiff := petWhiff && i == 0
|
||||
swingDeflect := petDeflect && i == 0
|
||||
decided := resolveEnemyAttack(te.st, te.st.c, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false)
|
||||
if te.st.playerHP <= 0 {
|
||||
// The target is down. The enemy stops swinging at a corpse; the
|
||||
// fight ends only if the roster is empty.
|
||||
if !te.st.anyAlive() {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
if decided || te.st.enemyHP <= 0 {
|
||||
te.finish(CombatStatusWon)
|
||||
// The enemy takes enemyActionsThisRound() attack-actions, each its full
|
||||
// SRD multiattack, re-targeting a fresh standing seat per action so a party's
|
||||
// damage is spread across the roster rather than pinning the first target.
|
||||
// A solo roster is exactly one action against its one seat — no re-target is
|
||||
// drawn — so its event stream and RNG draws are unchanged. When the ability
|
||||
// already dealt damage it was the first action, so one fewer follows; for solo
|
||||
// that collapses to the old "the ability stood in for the attack" skip.
|
||||
actions, reuseFirstTarget := enemyActionPlan(te.st, abilityDealtDamage)
|
||||
for a := 0; a < actions; a++ {
|
||||
if !(reuseFirstTarget && a == 0) {
|
||||
tgt, alive := te.enemyTarget()
|
||||
if !alive {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
}
|
||||
te.st.seat(tgt)
|
||||
target = tgt
|
||||
}
|
||||
if te.enemyAttackAction(target) {
|
||||
return
|
||||
}
|
||||
}
|
||||
te.advance()
|
||||
}
|
||||
|
||||
// enemyAttackAction resolves one enemy attack-action — the full SRD multiattack
|
||||
// profile — against the seat the cursor already points at, and stamps every event
|
||||
// it emits to that seat (the party's enemy re-targets across the roster within one
|
||||
// turn, so the whole-turn blanket stamp in step() no longer holds). Registered
|
||||
// elites/bosses swing their full profile; everyone else gets a single attack from
|
||||
// the template stats.
|
||||
//
|
||||
// Returns true only when it decided the fight (te.finish has already been called),
|
||||
// so the caller ends the enemy turn. It returns false when the target dropped but
|
||||
// the roster is still alive — this action is over, and the caller re-targets the
|
||||
// next one.
|
||||
func (te *turnEngine) enemyAttackAction(seat int) (finished bool) {
|
||||
mark := len(te.st.events)
|
||||
// Pet defensive procs are a single proc per attack-action: roll once, then
|
||||
// spend on the first swing only. Whiff makes that one swing a guaranteed miss;
|
||||
// deflect halves its damage. Against a multiattack the remaining swings resolve
|
||||
// normally — a single proc shouldn't nullify a boss's whole multiattack.
|
||||
// (This deliberately diverges from the auto-resolve engine's apply-to-all.)
|
||||
petWhiff := te.st.c.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.st.c.Mods.PetWhiffProc
|
||||
petDeflect := te.st.c.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.st.c.Mods.PetDeflectProc
|
||||
if petDeflect {
|
||||
te.result.PetDeflected = true
|
||||
}
|
||||
|
||||
for i, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) {
|
||||
swing := *te.enemy
|
||||
swing.Stats.Attack = atk.Damage
|
||||
swing.Stats.AttackBonus = atk.AttackBonus
|
||||
swingWhiff := petWhiff && i == 0
|
||||
swingDeflect := petDeflect && i == 0
|
||||
decided := resolveEnemyAttack(te.st, te.st.c, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false)
|
||||
if te.st.playerHP <= 0 {
|
||||
te.stampSeat(mark, seat)
|
||||
// The target is down. The enemy stops swinging at a corpse; the fight
|
||||
// ends only if the roster is empty, otherwise this action is over and
|
||||
// the caller re-targets.
|
||||
if !te.st.anyAlive() {
|
||||
te.finish(CombatStatusLost)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if decided || te.st.enemyHP <= 0 {
|
||||
te.stampSeat(mark, seat)
|
||||
te.finish(CombatStatusWon)
|
||||
return true
|
||||
}
|
||||
}
|
||||
te.stampSeat(mark, seat)
|
||||
return false
|
||||
}
|
||||
|
||||
// turnAbilityFires decides whether a monster ability triggers this enemy turn.
|
||||
// The auto-resolve engine gates abilities on its named phase clock (Opening /
|
||||
// Clash / Decisive); the turn-based duel has no phase clock, so the phase tag
|
||||
@@ -756,7 +795,9 @@ func (te *turnEngine) stepRoundEnd() {
|
||||
// Concentration aura (Spirit Guardians et al.): the lingering spell bites
|
||||
// the enemy each round it stays up. Concentration is per-caster, so every
|
||||
// seat holding one pulses. Ticks before enemy regen so a lethal pulse
|
||||
// settles the fight before the enemy knits its wounds back.
|
||||
// settles the fight before the enemy knits its wounds back — and before
|
||||
// Misty's crowd swings, so a caster whose aura would end the round is not
|
||||
// robbed of the win by an end-of-round debuff.
|
||||
for i := range st.actors {
|
||||
st.seat(i)
|
||||
if st.concentrationDmg <= 0 || st.enemyHP <= 0 || st.playerHP <= 0 {
|
||||
@@ -772,6 +813,24 @@ func (te *turnEngine) stepRoundEnd() {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Misty's crowd, then Misty's heal — per seat, after the round's other
|
||||
// damage (including the concentration pulse above) has landed so the heal
|
||||
// can answer it, which is the order endOfRoundForSeat uses. Both no-op (and
|
||||
// draw no RNG) for a character with no Misty history, which is every
|
||||
// simulated one.
|
||||
for i := range st.actors {
|
||||
st.seat(i)
|
||||
if st.playerHP <= 0 {
|
||||
continue
|
||||
}
|
||||
mark := len(st.events)
|
||||
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, te.result)
|
||||
te.stampSeat(mark, i)
|
||||
if over {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
}
|
||||
}
|
||||
st.seat(0)
|
||||
// Regenerate (monster ability): the enemy knits its wounds at round end.
|
||||
if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -224,6 +224,28 @@ func getActiveExpedition(userID id.UserID) (*Expedition, error) {
|
||||
return e, err
|
||||
}
|
||||
|
||||
// ownedLiveExpedition returns the expedition this player owns and has not yet
|
||||
// finished with — 'active' or 'extracting'. An extracted expedition is not
|
||||
// over: it holds its roster for the whole resume window (releaseParty is not
|
||||
// called on it), so the owner is still the only person who can close it.
|
||||
//
|
||||
// Active rows sort first, so a leader who owns both — extracted from one, then
|
||||
// started another — resolves to the one they are standing in.
|
||||
func ownedLiveExpedition(userID id.UserID) (*Expedition, error) {
|
||||
row := db.Get().QueryRow(`
|
||||
SELECT`+expeditionSelectCols+`
|
||||
FROM dnd_expedition e
|
||||
WHERE e.user_id = ?
|
||||
AND e.status IN ('active', 'extracting')
|
||||
ORDER BY (e.status = 'active') DESC, e.start_date DESC
|
||||
LIMIT 1`, string(userID))
|
||||
e, err := scanExpedition(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return e, err
|
||||
}
|
||||
|
||||
// getExpedition fetches by ID regardless of status. Test/admin use.
|
||||
func getExpedition(id string) (*Expedition, error) {
|
||||
row := db.Get().QueryRow(`
|
||||
@@ -321,6 +343,42 @@ func updateSupplies(expID string, s ExpeditionSupplies) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// withExpeditionSupplies serializes one read-modify-write of the shared supply
|
||||
// pool. updateSupplies rewrites supplies_json wholesale, so a caller that folds
|
||||
// its delta onto an *Expedition it read earlier silently discards anything that
|
||||
// landed in between — a member's pooled packs, another writer's spend. Handlers
|
||||
// run one goroutine per event, so those writers genuinely interleave.
|
||||
//
|
||||
// fn is handed a freshly-read expedition under the pool's own lock and returns
|
||||
// the supplies to persist. Callers that keep using their own *Expedition
|
||||
// afterwards must copy the returned pool back onto it.
|
||||
//
|
||||
// advUserLock cannot stand in here: it is keyed by sender, so two members
|
||||
// racing the same expedition row take two different mutexes and exclude nobody.
|
||||
func (p *AdventurePlugin) withExpeditionSupplies(
|
||||
expID string, fn func(fresh *Expedition) (ExpeditionSupplies, error),
|
||||
) (ExpeditionSupplies, error) {
|
||||
mu := p.advExpeditionLock(expID)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
fresh, err := getExpedition(expID)
|
||||
if err != nil {
|
||||
return ExpeditionSupplies{}, err
|
||||
}
|
||||
if fresh == nil {
|
||||
return ExpeditionSupplies{}, fmt.Errorf("expedition %s not found", expID)
|
||||
}
|
||||
next, err := fn(fresh)
|
||||
if err != nil {
|
||||
return ExpeditionSupplies{}, err
|
||||
}
|
||||
if err := updateSupplies(expID, next); err != nil {
|
||||
return ExpeditionSupplies{}, err
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// updateCamp persists camp state. Pass nil to break camp.
|
||||
func updateCamp(expID string, c *CampState) error {
|
||||
var arg any
|
||||
@@ -349,9 +407,18 @@ func setExpeditionRunID(expID, runID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// abandonExpedition flags the active expedition as abandoned. Idempotent.
|
||||
// abandonExpedition flags the player's live expedition as abandoned. Idempotent.
|
||||
//
|
||||
// It spans 'extracting' as well as 'active': an extracted expedition still owns
|
||||
// its roster, so abandoning is the leader's only way to free their party
|
||||
// without paying to `!resume` first. Character reset (dnd_setup) leans on this
|
||||
// too — a rerolled leader who left an extracted party behind would otherwise
|
||||
// strand every member until the sweeper reaped the row.
|
||||
//
|
||||
// One row per call, active first, so the run-spawn rollback in expeditionCmdStart
|
||||
// tears down the expedition it just created rather than an older extracted one.
|
||||
func abandonExpedition(userID id.UserID) error {
|
||||
e, err := getActiveExpedition(userID)
|
||||
e, err := ownedLiveExpedition(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -322,6 +322,37 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
"You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
|
||||
zone.Display, existing.CurrentDay))
|
||||
}
|
||||
// A leader who extracted still holds their roster for the resume window, and
|
||||
// `!resume` only ever reaches the *newest* extracted row. Starting fresh on
|
||||
// top of one would orphan it: unreachable, un-reapable until the sweeper
|
||||
// catches it, with every member still seated and refused a run of their own.
|
||||
//
|
||||
// Only a row with a roster blocks. A solo extraction strands nobody, so
|
||||
// walking away from it stays a normal thing to do.
|
||||
if pending, _ := getResumableExpedition(ctx.Sender); pending != nil {
|
||||
switch {
|
||||
case extractionLapsed(pending, time.Now().UTC()):
|
||||
// Past the window — reap it here rather than make them wait an hour
|
||||
// for the sweeper, and let the new expedition proceed. Route through
|
||||
// the shared reap so the freed members hear about it, same as the
|
||||
// sweeper and `!expedition abandon` do.
|
||||
if err := p.reapLapsedExtraction(pending); err != nil {
|
||||
slog.Warn("expedition: reap lapsed on start", "expedition", pending.ID, "err", err)
|
||||
}
|
||||
default:
|
||||
// A roster still holds; block. On a roster-read error, assume it is
|
||||
// occupied and refuse — proceeding would orphan a party we could not
|
||||
// confirm was empty, the one outcome this guard exists to prevent. A
|
||||
// solo extraction (n == 1) strands nobody, so walking away is fine.
|
||||
n, err := partySize(pending.ID)
|
||||
if err != nil || n > 1 {
|
||||
zone, _ := getZone(pending.ZoneID)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You extracted from **%s** on Day %d and your party is still waiting on you. `!resume` to lead them back in, or `!expedition abandon` to let it go — until you do one or the other, none of them can start a run of their own.",
|
||||
zone.Display, pending.CurrentDay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cost := float64(purchase.Cost())
|
||||
if p.euro == nil {
|
||||
@@ -610,6 +641,15 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
}
|
||||
if exp == nil {
|
||||
// An extracted expedition is still the owner's to close — it holds the
|
||||
// roster until the resume window lapses. Without this, a leader who
|
||||
// wanted out had to pay to `!resume` first just to abandon.
|
||||
if exp, err = getResumableExpedition(ctx.Sender); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
}
|
||||
isLeader = exp != nil
|
||||
}
|
||||
if exp == nil {
|
||||
return p.SendDM(ctx.Sender, "No active expedition to abandon.")
|
||||
}
|
||||
@@ -619,15 +659,37 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
|
||||
"Only your party leader can abandon the expedition. `!expedition leave` to walk out alone.")
|
||||
}
|
||||
zone, _ := getZone(exp.ZoneID)
|
||||
extracted := exp.Status == ExpeditionStatusExtracting
|
||||
audience := expeditionAudience(exp) // read before abandonExpedition disbands the roster
|
||||
if err := abandonExpedition(ctx.Sender); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error())
|
||||
}
|
||||
markActedToday(ctx.Sender)
|
||||
_ = retireAllRegionRuns(exp)
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "")
|
||||
if err := p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
// An extracted party is standing in town, not in the dungeon: their supplies
|
||||
// are already spent and their loot is already banked. Say the true thing.
|
||||
body := fmt.Sprintf(
|
||||
"Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.",
|
||||
zone.Display, exp.CurrentDay)); err != nil {
|
||||
zone.Display, exp.CurrentDay)
|
||||
if extracted {
|
||||
body = fmt.Sprintf(
|
||||
"You let the expedition in **%s** go. Day %d is where it ends — loot, XP, and coins are kept. The dungeon remembers.",
|
||||
zone.Display, exp.CurrentDay)
|
||||
}
|
||||
// The roster is being disbanded out from under the members; they hear it from
|
||||
// their leader rather than discovering it the next time a command works again.
|
||||
for _, uid := range audience {
|
||||
if uid == ctx.Sender {
|
||||
continue
|
||||
}
|
||||
if err := p.SendDM(uid, fmt.Sprintf(
|
||||
"Your leader called off the expedition in **%s** on Day %d. You're free to start a run of your own.",
|
||||
zone.Display, exp.CurrentDay)); err != nil {
|
||||
slog.Warn("expedition: abandon DM failed", "user", uid, "expedition", exp.ID, "err", err)
|
||||
}
|
||||
}
|
||||
if err := p.SendDM(ctx.Sender, body); err != nil {
|
||||
return err
|
||||
}
|
||||
// Emergence seam: see maybeRollPetArrivalOnEmerge.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
const (
|
||||
extractResumeWindow = 7 * 24 * time.Hour
|
||||
forcedExtractCoinTaxFrac = 0.20
|
||||
extractionSweepInterval = time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -218,6 +219,94 @@ func getResumableExpedition(userID id.UserID) (*Expedition, error) {
|
||||
return e, err
|
||||
}
|
||||
|
||||
// extractionLapsed reports whether an 'extracting' row is past its resume
|
||||
// window and should be reaped. A NULL completed_at counts as lapsed: the column
|
||||
// is the only clock the window has, and a row without one can never expire.
|
||||
func extractionLapsed(e *Expedition, now time.Time) bool {
|
||||
return e.CompletedAt == nil || now.Sub(*e.CompletedAt) > extractResumeWindow
|
||||
}
|
||||
|
||||
// loadLapsedExtractions returns every extracted expedition whose resume window
|
||||
// has closed, regardless of owner.
|
||||
func loadLapsedExtractions(now time.Time) ([]*Expedition, error) {
|
||||
rows, err := db.Get().Query(`
|
||||
SELECT`+expeditionSelectCols+`
|
||||
FROM dnd_expedition e
|
||||
WHERE e.status = 'extracting'
|
||||
AND (e.completed_at IS NULL OR e.completed_at < ?)`,
|
||||
now.Add(-extractResumeWindow))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanExpeditionRows(rows)
|
||||
}
|
||||
|
||||
// sweepLapsedExtractions closes out every extraction whose seven-day window has
|
||||
// run out, which releases its roster.
|
||||
//
|
||||
// Without this the window is a promise the code never keeps. `handleResumeCmd`
|
||||
// expires a lapsed row lazily, but only the *leader* can call `!resume` — so a
|
||||
// leader who quits, forgets, or simply starts a different expedition leaves the
|
||||
// row 'extracting' forever, and releaseParty (which fires only on a terminal
|
||||
// status) never runs. Every member stays seated, and assertNotAdventuring keeps
|
||||
// refusing them a run of their own. `!expedition leave` is their escape, but a
|
||||
// player should not have to find it.
|
||||
func (p *AdventurePlugin) sweepLapsedExtractions(now time.Time) {
|
||||
exps, err := loadLapsedExtractions(now)
|
||||
if err != nil {
|
||||
slog.Error("expedition: load lapsed extractions", "err", err)
|
||||
return
|
||||
}
|
||||
for _, e := range exps {
|
||||
if err := p.reapLapsedExtraction(e); err != nil {
|
||||
slog.Warn("expedition: expire lapsed extraction", "expedition", e.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reapLapsedExtraction closes one lapsed extraction and tells its roster the
|
||||
// way back has shut. It is the single reap-with-notify path: the hourly
|
||||
// sweeper loops it, and expeditionCmdStart calls it when a player starts a new
|
||||
// expedition on top of one whose window has already closed — so a member is
|
||||
// never silently unseated by whichever path happens to reach the row first.
|
||||
//
|
||||
// The audience is read before the close-out: completeExpedition disbands the
|
||||
// roster, and expeditionAudience reads that roster.
|
||||
func (p *AdventurePlugin) reapLapsedExtraction(e *Expedition) error {
|
||||
audience := expeditionAudience(e)
|
||||
if err := completeExpedition(e.ID, ExpeditionStatusFailed); err != nil {
|
||||
return err
|
||||
}
|
||||
zone, _ := getZone(e.ZoneID)
|
||||
body := fmt.Sprintf(
|
||||
"🕯 **The way back has closed — %s**\n\nYour extracted expedition sat past its seven-day resume window, and the dungeon reshaped without you. Day %d is where it ends.\n\nThe loot, XP, and coins you carried out are still yours. `!expedition list` when you're ready for the next one.",
|
||||
zone.Display, e.CurrentDay)
|
||||
for _, uid := range audience {
|
||||
if err := p.SendDM(uid, body); err != nil {
|
||||
slog.Warn("expedition: lapsed-extraction DM failed", "user", uid, "expedition", e.ID, "err", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// expeditionExtractionSweepTicker reaps lapsed extractions hourly. The window is
|
||||
// seven days, so the cadence only bounds how long a freed member waits to hear
|
||||
// about it.
|
||||
func (p *AdventurePlugin) expeditionExtractionSweepTicker() {
|
||||
ticker := time.NewTicker(extractionSweepInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.sweepLapsedExtractions(time.Now().UTC())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resumeExpedition flips an extracting row back to 'active' with fresh
|
||||
// supplies. Threat/temporal/region state are preserved as-is.
|
||||
func resumeExpedition(expID string, supplies ExpeditionSupplies) error {
|
||||
@@ -323,8 +412,9 @@ func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error
|
||||
if exp == nil {
|
||||
return p.SendDM(ctx.Sender, "No extracted expedition to resume. Use `!expedition start <zone>` to begin a new one.")
|
||||
}
|
||||
if exp.CompletedAt == nil || time.Since(*exp.CompletedAt) > extractResumeWindow {
|
||||
// Expire it so it doesn't keep resurfacing.
|
||||
if extractionLapsed(exp, time.Now().UTC()) {
|
||||
// Expire it so it doesn't keep resurfacing. The hourly sweeper would get
|
||||
// here on its own; this keeps the refusal and the reap in one breath.
|
||||
_ = completeExpedition(exp.ID, ExpeditionStatusFailed)
|
||||
return p.SendDM(ctx.Sender,
|
||||
"That extraction is past its 7-day resume window — the dungeon has reshaped without you. Start a new expedition.")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -310,6 +310,13 @@ type turnSpellOutcome struct {
|
||||
PlayerHeal int
|
||||
EnemySkip bool
|
||||
Desc string
|
||||
|
||||
// GrimHarvestSlot / GrimHarvestNecrotic are the Necromancy Mage's kill-heal
|
||||
// stash for this cast, zero for everyone else. Unlike the fields above they
|
||||
// outlive the casting round: the caller parks them on the seat's
|
||||
// ActorStatuses and the close-out decides whether the cast was lethal.
|
||||
GrimHarvestSlot int
|
||||
GrimHarvestNecrotic bool
|
||||
}
|
||||
|
||||
// resolveTurnSpell resolves a spell as a turn-based player action, reusing the
|
||||
@@ -344,6 +351,8 @@ func resolveTurnSpell(c *DnDCharacter, spell SpellDefinition, slotLevel int, ene
|
||||
out.EnemyDamage = mods.SpellPreDamage
|
||||
out.EnemySkip = mods.SpellEnemySkipFirst
|
||||
out.Desc = mods.SpellPreDamageDesc
|
||||
out.GrimHarvestSlot = mods.GrimHarvestSlot
|
||||
out.GrimHarvestNecrotic = mods.GrimHarvestNecrotic
|
||||
if out.Desc == "" {
|
||||
out.Desc = spell.Name
|
||||
}
|
||||
|
||||
@@ -934,11 +934,22 @@ func grimHarvestHeal(c *DnDCharacter, result CombatResult, mods CombatModifiers)
|
||||
if !result.PlayerWon || mods.GrimHarvestSlot <= 0 {
|
||||
return 0
|
||||
}
|
||||
// The pre-combat spell killed the enemy iff the spell_cast event itself
|
||||
// dropped EnemyHP to 0. If a later round-event finished the kill, no heal.
|
||||
for _, ev := range result.Events {
|
||||
if ev.Action == "spell_cast" {
|
||||
if ev.EnemyHP > 0 {
|
||||
// The spell killed the enemy iff the spell_cast event itself dropped EnemyHP
|
||||
// to 0. If a later round-event (a weapon swing, the pet, a concentration
|
||||
// aura re-tick) finished the kill, no heal.
|
||||
//
|
||||
// It is the *last* spell_cast that has to be lethal, not the first. The
|
||||
// auto-resolve path casts once, pre-combat, so the two coincide there; the
|
||||
// turn-based path can cast every round, and there the stash on the seat's
|
||||
// statuses describes that last cast. Reading the first event would let a
|
||||
// non-lethal opening cantrip veto a heal the killing spell had earned.
|
||||
//
|
||||
// No spell_cast event at all cannot happen while the stash is set — both
|
||||
// surfaces emit one for every damaging cast — so the loop falling through
|
||||
// is not a case, just an absence.
|
||||
for i := len(result.Events) - 1; i >= 0; i-- {
|
||||
if result.Events[i].Action == "spell_cast" {
|
||||
if result.Events[i].EnemyHP > 0 {
|
||||
return 0
|
||||
}
|
||||
break
|
||||
|
||||
@@ -471,6 +471,12 @@ type advanceResult struct {
|
||||
// harvest ran (Entry/Trap/Elite/Boss rooms, forks, stops).
|
||||
harvest autoHarvestResult
|
||||
harvestFooter string
|
||||
// zoneCleared is true only when this step was a *full* zone clear
|
||||
// (reason == stopComplete and not a mid-zone region clear). The K
|
||||
// event anchor reads it so a foreground `!zone advance` clear can roll
|
||||
// the grind-loop player's mid-day event — a mid-zone region clear does
|
||||
// not, since the run continues and it would fire per region.
|
||||
zoneCleared bool
|
||||
}
|
||||
|
||||
// zoneCmdAdvance resolves the room the player is currently standing in,
|
||||
@@ -495,7 +501,16 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, err.Error())
|
||||
}
|
||||
return p.streamOrSend(ctx.Sender, res.preStream, res.intro, res.phases, res.final)
|
||||
sendErr := p.streamOrSend(ctx.Sender, res.preStream, res.intro, res.phases, res.final)
|
||||
// K fourth event anchor: a foreground single-day zone clear is a presence
|
||||
// moment for the grind-loop player the other three anchors miss. Rolled
|
||||
// after the clear DM so any triggered event lands behind it, and only here
|
||||
// — zoneCmdAdvance is the foreground path; autopilot walks go through
|
||||
// runAutopilotWalk and never reach this.
|
||||
if res.zoneCleared {
|
||||
p.maybeFireAnchoredEvent(ctx.Sender, advEventChanceZoneClear)
|
||||
}
|
||||
return sendErr
|
||||
}
|
||||
|
||||
// advanceOnce runs the single-room advance pipeline and returns a
|
||||
@@ -737,7 +752,8 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin
|
||||
// and point at the next one — "Cleared {zone}. Run complete." reads
|
||||
// wrong right before the auto-advance transit block (and is shared with
|
||||
// manual `!region travel`, which advances next).
|
||||
if region, next, midZone := midZoneRegionClear(ctx.Sender, run.RunID); midZone {
|
||||
region, next, midZone := midZoneRegionClear(ctx.Sender, run.RunID)
|
||||
if midZone {
|
||||
b.WriteString(fmt.Sprintf("🏁 **Cleared %s.** The way to %s opens ahead.\n\n", region.Name, next.Name))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Run complete.\n\n", zone.Display))
|
||||
@@ -763,11 +779,12 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin
|
||||
}
|
||||
}
|
||||
return advanceResult{
|
||||
preStream: preStream,
|
||||
intro: intro,
|
||||
phases: phases,
|
||||
final: b.String(),
|
||||
reason: stopComplete,
|
||||
preStream: preStream,
|
||||
intro: intro,
|
||||
phases: phases,
|
||||
final: b.String(),
|
||||
reason: stopComplete,
|
||||
zoneCleared: !midZone,
|
||||
}, nil
|
||||
}
|
||||
if forkMsg != "" {
|
||||
|
||||
@@ -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,
|
||||
|
||||
158
internal/plugin/expedition_extract_sweep_test.go
Normal file
158
internal/plugin/expedition_extract_sweep_test.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// seedExtraction inserts an 'extracting' row whose window closed `age` ago.
|
||||
// A negative age is a live extraction.
|
||||
func seedExtraction(t *testing.T, expeditionID string, owner id.UserID, age time.Duration) {
|
||||
t.Helper()
|
||||
seedExpedition(t, expeditionID, owner, ExpeditionStatusExtracting)
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE dnd_expedition SET completed_at = ? WHERE expedition_id = ?`,
|
||||
time.Now().UTC().Add(-extractResumeWindow-age), expeditionID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func seatedOn(t *testing.T, member id.UserID) string {
|
||||
t.Helper()
|
||||
e, err := seatedExpeditionFor(member)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.ID
|
||||
}
|
||||
|
||||
// The window is the only thing bounding how long a leader's roster holds their
|
||||
// party. Nothing else flips an 'extracting' row terminal unless the leader
|
||||
// personally types !resume, so if the sweep misses, members are stuck forever.
|
||||
func TestSweepLapsedExtractions_ReleasesTheRosterItStranded(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader := id.UserID("@sweep-leader:example.org")
|
||||
member := id.UserID("@sweep-member:example.org")
|
||||
|
||||
seedExtraction(t, "exp-lapsed", leader, time.Hour)
|
||||
if err := joinParty("exp-lapsed", member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := seatedOn(t, member); got != "exp-lapsed" {
|
||||
t.Fatalf("member seated on %q before sweep, want exp-lapsed", got)
|
||||
}
|
||||
|
||||
(&AdventurePlugin{}).sweepLapsedExtractions(time.Now().UTC())
|
||||
|
||||
e, err := getExpedition("exp-lapsed")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.Status != ExpeditionStatusFailed {
|
||||
t.Errorf("status = %q, want failed", e.Status)
|
||||
}
|
||||
if got := seatedOn(t, member); got != "" {
|
||||
t.Errorf("member still seated on %q after sweep", got)
|
||||
}
|
||||
if n, _ := partySize("exp-lapsed"); n != 1 {
|
||||
t.Errorf("roster survived the sweep: partySize = %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The mirror: a leader who extracted an hour ago still has six days to !resume,
|
||||
// and their party must still be there when they do.
|
||||
func TestSweepLapsedExtractions_LeavesALiveExtractionAlone(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader := id.UserID("@sweep-live-leader:example.org")
|
||||
member := id.UserID("@sweep-live-member:example.org")
|
||||
|
||||
seedExtraction(t, "exp-live", leader, -6*24*time.Hour) // one day in
|
||||
if err := joinParty("exp-live", member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
(&AdventurePlugin{}).sweepLapsedExtractions(time.Now().UTC())
|
||||
|
||||
e, _ := getExpedition("exp-live")
|
||||
if e.Status != ExpeditionStatusExtracting {
|
||||
t.Errorf("status = %q, want extracting — the resume window is still open", e.Status)
|
||||
}
|
||||
if got := seatedOn(t, member); got != "exp-live" {
|
||||
t.Errorf("member unseated from a resumable expedition (seated on %q)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// completed_at is the only clock the window has. A row without one can never
|
||||
// lapse, so it must read as already lapsed rather than as immortal.
|
||||
func TestExtractionLapsed_NullCompletedAtIsLapsed(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
if !extractionLapsed(&Expedition{}, now) {
|
||||
t.Error("nil completed_at should read as lapsed")
|
||||
}
|
||||
fresh := now.Add(-time.Hour)
|
||||
if extractionLapsed(&Expedition{CompletedAt: &fresh}, now) {
|
||||
t.Error("an hour-old extraction is not lapsed")
|
||||
}
|
||||
old := now.Add(-extractResumeWindow - time.Second)
|
||||
if !extractionLapsed(&Expedition{CompletedAt: &old}, now) {
|
||||
t.Error("a row past the window is lapsed")
|
||||
}
|
||||
}
|
||||
|
||||
// A leader must be able to let an extracted expedition go without paying to
|
||||
// !resume it first — that is the escape hatch !expedition start points them at.
|
||||
func TestAbandonExpedition_ReachesAnExtractedRowAndFreesTheParty(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader := id.UserID("@abandon-extracted-leader:example.org")
|
||||
member := id.UserID("@abandon-extracted-member:example.org")
|
||||
|
||||
seedExtraction(t, "exp-extracted", leader, -24*time.Hour) // live, resumable
|
||||
if err := joinParty("exp-extracted", member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := abandonExpedition(leader); err != nil {
|
||||
t.Fatalf("abandonExpedition on an extracting row: %v", err)
|
||||
}
|
||||
e, _ := getExpedition("exp-extracted")
|
||||
if e.Status != ExpeditionStatusAbandoned {
|
||||
t.Errorf("status = %q, want abandoned", e.Status)
|
||||
}
|
||||
if got := seatedOn(t, member); got != "" {
|
||||
t.Errorf("member still seated on %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// expeditionCmdStart's run-spawn rollback calls abandonExpedition to tear down
|
||||
// the row it just created. A leader who also owns an older extracted row must
|
||||
// not have that one reaped instead.
|
||||
func TestAbandonExpedition_PrefersTheActiveRowOverAnExtractedOne(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader := id.UserID("@abandon-prefers-active:example.org")
|
||||
|
||||
seedExtraction(t, "exp-old-extracted", leader, -24*time.Hour)
|
||||
seedExpedition(t, "exp-new-active", leader, ExpeditionStatusActive)
|
||||
|
||||
if err := abandonExpedition(leader); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e, _ := getExpedition("exp-new-active"); e.Status != ExpeditionStatusAbandoned {
|
||||
t.Errorf("active row status = %q, want abandoned", e.Status)
|
||||
}
|
||||
if e, _ := getExpedition("exp-old-extracted"); e.Status != ExpeditionStatusExtracting {
|
||||
t.Errorf("extracted row was collateral damage: status = %q", e.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbandonExpedition_NoLiveRow(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
if err := abandonExpedition(id.UserID("@abandon-nothing:example.org")); err != ErrNoActiveExpedition {
|
||||
t.Errorf("err = %v, want ErrNoActiveExpedition", err)
|
||||
}
|
||||
}
|
||||
@@ -115,18 +115,6 @@ func partySize(expeditionID string) (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// openParty seats the leader, converting a solo expedition row into a party of
|
||||
// one. It is idempotent: a second call on the same expedition is a no-op, so
|
||||
// the invite path can call it unconditionally before adding a member.
|
||||
func openParty(expeditionID string, leaderID id.UserID) error {
|
||||
_, err := db.Get().Exec(`
|
||||
INSERT INTO expedition_party (expedition_id, user_id, role)
|
||||
VALUES (?, ?, 'leader')
|
||||
ON CONFLICT (expedition_id, user_id) DO NOTHING`,
|
||||
expeditionID, string(leaderID))
|
||||
return err
|
||||
}
|
||||
|
||||
// joinParty seats a member. It refuses a full roster, a duplicate, and a player
|
||||
// who already leads or rides an expedition of their own — the "one active
|
||||
// expedition per user" rule that startExpedition enforces in code, extended to
|
||||
@@ -171,9 +159,11 @@ func joinParty(expeditionID string, userID id.UserID) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// seatLeader is openParty inside a caller's transaction: it reads the owner off
|
||||
// the expedition row rather than trusting a passed-in id, so the roster's leader
|
||||
// can never disagree with dnd_expedition.user_id.
|
||||
// seatLeader converts a solo expedition row into a party of one, inside a
|
||||
// caller's transaction. It reads the owner off the expedition row rather than
|
||||
// trusting a passed-in id, so the roster's leader can never disagree with
|
||||
// dnd_expedition.user_id. Idempotent (ON CONFLICT DO NOTHING), so the invite
|
||||
// path can call it unconditionally before adding a member.
|
||||
func seatLeader(tx *sql.Tx, expeditionID string) error {
|
||||
var owner string
|
||||
err := tx.QueryRow(
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -22,6 +22,24 @@ func seedExpedition(t *testing.T, expeditionID string, owner id.UserID, status s
|
||||
}
|
||||
}
|
||||
|
||||
// seatLeaderFixture seats the expedition's leader the way the invite path does —
|
||||
// through seatLeader in its own transaction — reading the owner off the row that
|
||||
// seedExpedition already wrote. Replaces the retired openParty helper.
|
||||
func seatLeaderFixture(t *testing.T, expeditionID string) {
|
||||
t.Helper()
|
||||
tx, err := db.Get().Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := seatLeader(tx, expeditionID); err != nil {
|
||||
_ = tx.Rollback()
|
||||
t.Fatalf("seatLeader %s: %v", expeditionID, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParty_SoloExpeditionHasNoRoster(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
owner := id.UserID("@solo:example.org")
|
||||
@@ -53,10 +71,9 @@ func TestParty_OpenIsIdempotent(t *testing.T) {
|
||||
owner := id.UserID("@lead:example.org")
|
||||
seedExpedition(t, "exp-1", owner, "active")
|
||||
|
||||
// seatLeader is idempotent — a second call on the same expedition is a no-op.
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := openParty("exp-1", owner); err != nil {
|
||||
t.Fatalf("openParty #%d: %v", i+1, err)
|
||||
}
|
||||
seatLeaderFixture(t, "exp-1")
|
||||
}
|
||||
members, err := partyMembers("exp-1")
|
||||
if err != nil {
|
||||
@@ -71,9 +88,7 @@ func TestParty_JoinSeatsMembersLeaderFirst(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
owner := id.UserID("@lead2:example.org")
|
||||
seedExpedition(t, "exp-2", owner, "active")
|
||||
if err := openParty("exp-2", owner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seatLeaderFixture(t, "exp-2")
|
||||
for _, u := range []id.UserID{"@b:x", "@c:x"} {
|
||||
if err := joinParty("exp-2", u); err != nil {
|
||||
t.Fatalf("joinParty %s: %v", u, err)
|
||||
@@ -127,9 +142,7 @@ func TestParty_JoinRefusesFullRoster(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
owner := id.UserID("@lead3:example.org")
|
||||
seedExpedition(t, "exp-3", owner, "active")
|
||||
if err := openParty("exp-3", owner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seatLeaderFixture(t, "exp-3")
|
||||
for _, u := range []id.UserID{"@b:x", "@c:x"} {
|
||||
if err := joinParty("exp-3", u); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -145,9 +158,7 @@ func TestParty_JoinRefusesDuplicate(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
owner := id.UserID("@lead4:example.org")
|
||||
seedExpedition(t, "exp-4", owner, "active")
|
||||
if err := openParty("exp-4", owner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seatLeaderFixture(t, "exp-4")
|
||||
if err := joinParty("exp-4", "@b:x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -167,12 +178,8 @@ func TestParty_JoinRefusesPlayerBusyElsewhere(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
seedExpedition(t, "exp-5", "@lead5:example.org", "active")
|
||||
seedExpedition(t, "exp-6", "@lead6:example.org", "active")
|
||||
if err := openParty("exp-5", "@lead5:example.org"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := openParty("exp-6", "@lead6:example.org"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seatLeaderFixture(t, "exp-5")
|
||||
seatLeaderFixture(t, "exp-6")
|
||||
|
||||
// Owns their own active expedition.
|
||||
seedExpedition(t, "exp-own", "@busy:example.org", "active")
|
||||
@@ -198,9 +205,7 @@ func TestParty_LeaveRemovesMemberButNotLeader(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
owner := id.UserID("@lead7:example.org")
|
||||
seedExpedition(t, "exp-7", owner, "active")
|
||||
if err := openParty("exp-7", owner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seatLeaderFixture(t, "exp-7")
|
||||
if err := joinParty("exp-7", "@b:x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -217,9 +222,7 @@ func TestParty_LeaveRemovesMemberButNotLeader(t *testing.T) {
|
||||
}
|
||||
// Leaving frees the member for their own next run.
|
||||
seedExpedition(t, "exp-7b", "@lead7b:example.org", "active")
|
||||
if err := openParty("exp-7b", "@lead7b:example.org"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seatLeaderFixture(t, "exp-7b")
|
||||
if err := joinParty("exp-7b", "@b:x"); err != nil {
|
||||
t.Errorf("departed member could not rejoin elsewhere: %v", err)
|
||||
}
|
||||
@@ -229,9 +232,7 @@ func TestParty_DisbandFreesEveryone(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
owner := id.UserID("@lead8:example.org")
|
||||
seedExpedition(t, "exp-8", owner, "complete")
|
||||
if err := openParty("exp-8", owner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seatLeaderFixture(t, "exp-8")
|
||||
if err := joinParty("exp-8", "@b:x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -242,9 +243,7 @@ func TestParty_DisbandFreesEveryone(t *testing.T) {
|
||||
t.Errorf("after disband, partySize = %d, want 1 (no rows)", n)
|
||||
}
|
||||
seedExpedition(t, "exp-8b", "@lead8b:example.org", "active")
|
||||
if err := openParty("exp-8b", "@lead8b:example.org"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seatLeaderFixture(t, "exp-8b")
|
||||
if err := joinParty("exp-8b", "@b:x"); err != nil {
|
||||
t.Errorf("disbanded member still looks busy: %v", err)
|
||||
}
|
||||
@@ -257,9 +256,7 @@ func TestParty_ActiveExpeditionForResolvesBothRoles(t *testing.T) {
|
||||
owner := id.UserID("@lead9:example.org")
|
||||
member := id.UserID("@member9:example.org")
|
||||
seedExpedition(t, "exp-9", owner, "active")
|
||||
if err := openParty("exp-9", owner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seatLeaderFixture(t, "exp-9")
|
||||
if err := joinParty("exp-9", member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -579,18 +579,8 @@ func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction
|
||||
return p.SendDM(ctx.Sender, "Equip cancelled.")
|
||||
}
|
||||
|
||||
idx := 0
|
||||
parsed := false
|
||||
for _, c := range reply {
|
||||
if c >= '0' && c <= '9' {
|
||||
idx = idx*10 + int(c-'0')
|
||||
parsed = true
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
idx-- // 1-indexed → 0-indexed
|
||||
if !parsed || idx < 0 || idx >= len(data.Items) {
|
||||
idx, ok := parseMenuIndex(reply, len(data.Items))
|
||||
if !ok {
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, "Invalid selection. Reply with a number from the list, or \"cancel\".")
|
||||
}
|
||||
|
||||
132
internal/plugin/message_sink_test.go
Normal file
132
internal/plugin/message_sink_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// captureSink is the test implementation of MessageSink. It records every
|
||||
// outbound message a handler emits so a test can assert on a handler whose
|
||||
// only observable output is a DM or a room announcement — the paths that had
|
||||
// no seam below the live client before this. Install it with installSink.
|
||||
type captureSink struct {
|
||||
msgs []outboundMessage
|
||||
}
|
||||
|
||||
func (s *captureSink) Capture(m outboundMessage) (id.EventID, error) {
|
||||
s.msgs = append(s.msgs, m)
|
||||
// A stable, non-empty id keeps the *ID send variants honest for callers
|
||||
// that expect an event id back (e.g. one they would later edit).
|
||||
return id.EventID("$sink"), nil
|
||||
}
|
||||
|
||||
// dmsTo returns the text of every DM captured for a given user, in order.
|
||||
func (s *captureSink) dmsTo(user id.UserID) []string {
|
||||
var out []string
|
||||
for _, m := range s.msgs {
|
||||
if m.ToUser == user {
|
||||
out = append(out, m.Text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// roomMsgs returns the text of every room message captured for a room.
|
||||
func (s *captureSink) roomMsgs(room id.RoomID) []string {
|
||||
var out []string
|
||||
for _, m := range s.msgs {
|
||||
if m.ToRoom == room {
|
||||
out = append(out, m.Text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// installSink points a plugin's outbound sends at a fresh captureSink and
|
||||
// returns it. The Base carries no client in these tests, so without the sink
|
||||
// SendDM would silently no-op — which is exactly why these paths were untestable.
|
||||
func installSink(p *AdventurePlugin) *captureSink {
|
||||
s := &captureSink{}
|
||||
p.Sink = s
|
||||
return s
|
||||
}
|
||||
|
||||
// TestMessageSink_CapturesDMAndRoom is the seam's own unit test: it proves both
|
||||
// the DM and room-message routes divert into the sink with the right target and
|
||||
// text, and that the *ID variants report back the id the sink chose.
|
||||
func TestMessageSink_CapturesDMAndRoom(t *testing.T) {
|
||||
p := &AdventurePlugin{}
|
||||
sink := installSink(p)
|
||||
|
||||
user := id.UserID("@player:example.org")
|
||||
room := id.RoomID("!hall:example.org")
|
||||
|
||||
if err := p.SendDM(user, "a direct message"); err != nil {
|
||||
t.Fatalf("SendDM: %v", err)
|
||||
}
|
||||
if err := p.SendMessage(room, "a room announcement"); err != nil {
|
||||
t.Fatalf("SendMessage: %v", err)
|
||||
}
|
||||
evID, err := p.SendDMID(user, "an editable dm")
|
||||
if err != nil {
|
||||
t.Fatalf("SendDMID: %v", err)
|
||||
}
|
||||
if evID != "$sink" {
|
||||
t.Errorf("SendDMID returned %q, want the sink's id", evID)
|
||||
}
|
||||
|
||||
if got := sink.dmsTo(user); len(got) != 2 || got[0] != "a direct message" || got[1] != "an editable dm" {
|
||||
t.Errorf("DMs to %s = %v", user, got)
|
||||
}
|
||||
if got := sink.roomMsgs(room); len(got) != 1 || got[0] != "a room announcement" {
|
||||
t.Errorf("room messages = %v", got)
|
||||
}
|
||||
// A DM must not leak into the room bucket, or vice versa.
|
||||
if len(sink.roomMsgs(id.RoomID(user))) != 0 {
|
||||
t.Error("a DM was miscaptured as a room message")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpeditionCmdLeave_DMsBothEnds drives the real handler behind review
|
||||
// fix #2 end to end — the member soft-lock path — and asserts on the two DMs it
|
||||
// sends. Before the sink this was unreachable in a unit test: SendDM no-ops
|
||||
// without a client, so nothing could observe that the leader was notified and
|
||||
// the departing member was told they turned back.
|
||||
func TestExpeditionCmdLeave_DMsBothEnds(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
owner := id.UserID("@lead-leave:example.org")
|
||||
member := id.UserID("@member-leave:example.org")
|
||||
seedExpedition(t, "exp-leave", owner, "active")
|
||||
seatLeaderFixture(t, "exp-leave")
|
||||
if err := joinParty("exp-leave", member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
sink := installSink(p)
|
||||
|
||||
if err := p.expeditionCmdLeave(MessageContext{Sender: member}); err != nil {
|
||||
t.Fatalf("expeditionCmdLeave: %v", err)
|
||||
}
|
||||
|
||||
// The departing member is freed and told so.
|
||||
memberDMs := sink.dmsTo(member)
|
||||
if len(memberDMs) != 1 || memberDMs[0] != "You turn back for town. Your supplies stay with the party." {
|
||||
t.Errorf("member DMs = %v", memberDMs)
|
||||
}
|
||||
// The leader is notified their party shrank.
|
||||
leaderDMs := sink.dmsTo(owner)
|
||||
if len(leaderDMs) != 1 || leaderDMs[0] == "" {
|
||||
t.Fatalf("leader DMs = %v, want one departure notice", leaderDMs)
|
||||
}
|
||||
if !strings.Contains(leaderDMs[0], "turned back") || !strings.Contains(leaderDMs[0], "supplies stay") {
|
||||
t.Errorf("leader notice = %q", leaderDMs[0])
|
||||
}
|
||||
|
||||
// And the DB actually reflects the departure the DMs described.
|
||||
if n, _ := partySize("exp-leave"); n != 1 {
|
||||
t.Errorf("after leave, partySize = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,30 @@ var (
|
||||
type Base struct {
|
||||
Client *mautrix.Client
|
||||
Prefix string
|
||||
|
||||
// Sink, when non-nil, receives every outbound message (DM or room) the
|
||||
// plugin would otherwise hand to the Matrix client, and the client is
|
||||
// never touched. It is the test seam for handlers whose only observable
|
||||
// output is a DM — beginCombatTurn's terminal close-out,
|
||||
// expeditionCmdLeave, the arena season announcement. Production leaves it
|
||||
// nil, so behaviour is unchanged. See captureSink in the test files.
|
||||
Sink MessageSink
|
||||
}
|
||||
|
||||
// outboundMessage is one message a handler tried to send. Exactly one of
|
||||
// ToUser (a DM) or ToRoom (a room message) is set.
|
||||
type outboundMessage struct {
|
||||
ToUser id.UserID
|
||||
ToRoom id.RoomID
|
||||
Text string
|
||||
}
|
||||
|
||||
// MessageSink captures a plugin's outbound messages in place of the live
|
||||
// client. Capture returns the event ID to report back to the caller, so the
|
||||
// *ID send variants keep working. Unexported param by design: only in-package
|
||||
// tests implement it.
|
||||
type MessageSink interface {
|
||||
Capture(outboundMessage) (id.EventID, error)
|
||||
}
|
||||
|
||||
// NewBase creates a Base with default prefix "!".
|
||||
@@ -560,6 +584,10 @@ func textContent(text string) *event.MessageEventContent {
|
||||
|
||||
// SendMessage sends a message to a room, auto-formatting Markdown as HTML.
|
||||
func (b *Base) SendMessage(roomID id.RoomID, text string) error {
|
||||
if b != nil && b.Sink != nil {
|
||||
_, err := b.Sink.Capture(outboundMessage{ToRoom: roomID, Text: text})
|
||||
return err
|
||||
}
|
||||
content := textContent(text)
|
||||
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||
if err != nil {
|
||||
@@ -570,6 +598,9 @@ func (b *Base) SendMessage(roomID id.RoomID, text string) error {
|
||||
|
||||
// SendMessageID sends a message and returns the event ID.
|
||||
func (b *Base) SendMessageID(roomID id.RoomID, text string) (id.EventID, error) {
|
||||
if b != nil && b.Sink != nil {
|
||||
return b.Sink.Capture(outboundMessage{ToRoom: roomID, Text: text})
|
||||
}
|
||||
content := textContent(text)
|
||||
resp, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||
if err != nil {
|
||||
@@ -707,6 +738,10 @@ func IsDMRoom(roomID id.RoomID, userID id.UserID) bool {
|
||||
// No-op when the Matrix client is nil (which happens in unit tests that
|
||||
// construct plugins without a real client).
|
||||
func (b *Base) SendDM(userID id.UserID, text string) error {
|
||||
if b != nil && b.Sink != nil {
|
||||
_, err := b.Sink.Capture(outboundMessage{ToUser: userID, Text: text})
|
||||
return err
|
||||
}
|
||||
if b == nil || b.Client == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -719,6 +754,9 @@ func (b *Base) SendDM(userID id.UserID, text string) error {
|
||||
|
||||
// SendDMID sends a direct message and returns the event ID (for later editing).
|
||||
func (b *Base) SendDMID(userID id.UserID, text string) (id.EventID, error) {
|
||||
if b != nil && b.Sink != nil {
|
||||
return b.Sink.Capture(outboundMessage{ToUser: userID, Text: text})
|
||||
}
|
||||
roomID, err := b.GetDMRoom(userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -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