diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index a7a3219..914b708 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -43,11 +43,15 @@ func (p *AdventurePlugin) replyDM(ctx MessageContext, text string) error { // ── !fight ────────────────────────────────────────────────────────────────── func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error { - userMu := p.advUserLock(ctx.Sender) - userMu.Lock() - defer userMu.Unlock() + // Resolve the roster before locking — a member's `!fight` opens the leader's + // fight, under the leader's lock, and seat 0 names that leader. fightRoster + // deliberately does not touch getActiveZoneRun: that lookup carries the §4.3 + // idle reap, and it must only ever fire under the lock, on its owner's behalf. + roster := fightRoster(ctx.Sender) + release := p.lockCombatFight(roster[0], ctx.Sender) + defer release() - run, err := getActiveZoneRun(ctx.Sender) + run, _, err := activeZoneRunFor(ctx.Sender) if err != nil { return p.replyDM(ctx, "Couldn't read run state: "+err.Error()) } @@ -89,18 +93,19 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error { return p.replyDM(ctx, "_(No bestiary entry for this encounter — file a bug. `!zone abandon` to bail.)_") } - player, enemy, _, err := p.buildZoneCombatants(ctx.Sender, monster, int(zone.Tier), run.DMMood) - if err != nil { - return p.replyDM(ctx, "Couldn't set up the fight: "+err.Error()) - } - - playerHP, playerMax := dndHPSnapshot(ctx.Sender) - if playerHP <= 0 { - return p.replyDM(ctx, "You're in no shape to fight. `!rest` first.") + // Seat the whole party, leader first. A solo player is a one-seat roster and + // takes the path they always took: one build, one INSERT, no participant rows. + seats, enemy, senderSkip, refusal := p.buildFightSeats(ctx.Sender, roster, monster, int(zone.Tier), run.DMMood) + if refusal != "" { + return p.replyDM(ctx, refusal) } enemyHP := enemy.Stats.MaxHP - sess, err := startCombatSession(ctx.Sender, run.RunID, encID, monster.ID, playerHP, playerMax, enemyHP, enemyHP) + // Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded + // per seat onto the session and its participant rows, so they survive the + // turn engine's resume/commit cycle. The pet rolls per-turn inside the + // engine, so there's no fight-start roll. + sess, err := p.startPartyCombatSession(run.RunID, encID, monster.ID, enemy, seats) if err != nil { if err == ErrCombatSessionAlreadyActive { return p.replyDM(ctx, "You're already in a fight. Finish it with `!attack` / `!flee`.") @@ -108,15 +113,6 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error { return p.replyDM(ctx, "Couldn't start the fight: "+err.Error()) } - // Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto - // the session so they survive the turn engine's resume/commit cycle. The - // pet now rolls per-turn inside the engine, so there's no fight-start roll. - if seedCombatSessionOneShots(sess, player.Mods) { - if err := saveCombatSession(sess); err != nil { - slog.Error("combat: seed session one-shots", "user", ctx.Sender, "err", err) - } - } - var b strings.Builder if isBoss { if line := composeBossEntry(zone.ID, run.RunID, run.CurrentRoom); line != "" { @@ -129,13 +125,51 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error { } b.WriteString(fmt.Sprintf("⚔️ **Elite — %s** (HP %d, AC %d)\n", monster.Name, enemyHP, enemy.Stats.AC)) } - b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", playerHP, playerMax)) - if curios := activeMagicItemsLine(ctx.Sender); curios != "" { + + if sess.IsParty() { + players := seatCombatants(seats) + // 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. + opening, serr := settleCombatSession(sess, players, enemy) + if serr != nil { + return p.replyDM(ctx, "Couldn't resolve the round: "+serr.Error()) + } + ct := &combatTurn{sess: sess, players: players, enemy: enemy, seat: 0, uid: roster[0]} + outcomes := p.closePartyRound(ct) + if !ctx.Silent { + // The opening block is per-reader for the same reason a round's + // narration is: "You: 40/40 HP" has to be the reader's own pool. + p.announcePartyFightStart(sess, players, enemy, b.String(), opening, outcomes) + } + // A member the roster left behind is owed an answer of their own: nothing + // above was addressed to them, because they are not seated. + if senderSkip != "" { + return p.replyDM(ctx, senderSkip) + } + return nil + } + + // One seat. Usually that is a solo player fighting their own fight, and this + // is the block they have always been sent. It can also be a leader whose only + // companion was left behind — in which case the block is the leader's and the + // sender is owed the reason they are not in it. + owner := id.UserID(sess.UserID) + b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", sess.PlayerHP, sess.PlayerHPMax)) + if curios := activeMagicItemsLine(owner); curios != "" { b.WriteString(curios) b.WriteString("\n") } b.WriteString("\n") b.WriteString(combatTurnPrompt(sess)) + if senderSkip != "" { + if !ctx.Silent { + if err := p.SendDM(owner, b.String()); err != nil { + slog.Error("combat: fight-start DM to leader failed", "user", owner, "err", err) + } + } + return p.replyDM(ctx, senderSkip) + } return p.replyDM(ctx, b.String()) } @@ -280,10 +314,14 @@ func combatTurnPrompt(sess *CombatSession) string { // wrong surface — point them at `!expedition run` instead. Standalone // zone runs still advance with `!zone advance`. func continueHint(userID id.UserID) string { - if exp, err := getActiveExpedition(userID); err == nil && exp != nil { - return "`!expedition run` to keep going." + exp, isLeader, err := activeExpeditionFor(userID) + switch { + case err != nil || exp == nil: + return "`!zone advance` to move on." + case !isLeader: + return "Your leader marches the party on." } - return "`!zone advance` to move on." + return "`!expedition run` to keep going." } // finishCombatSession runs the post-fight side effects once a CombatSession diff --git a/internal/plugin/combat_party_start.go b/internal/plugin/combat_party_start.go new file mode 100644 index 0000000..97ed3ae --- /dev/null +++ b/internal/plugin/combat_party_start.go @@ -0,0 +1,196 @@ +package plugin + +import ( + "fmt" + "log/slog" + "strings" + + "maunium.net/go/mautrix/id" +) + +// N3/P6c — seating the party at the doorway. +// +// P5 built startPartyCombatSession and left it with no production caller: there +// was no roster to seat it with. P6b filled the roster. This is the join. +// +// The rule the whole file turns on is that **seat 0 is the expedition leader**. +// Every seat-0 invariant P4 and P5 laid down rests on it: combat_session.user_id +// is the fight's lock key, the run- and expedition-scoped close-out effects fire +// once through it, and `!flee` is refused to any seat but zero. A party whose +// seat 0 were merely "whoever typed !fight" would flee the leader's run on a +// member's say-so. + +// fightRoster is the seating order for a fight opened on this run: the +// expedition's leader first, then their party in join order. A bare zone run — +// and a solo expedition, whose roster table is empty — resolves to the one +// player who owns it. +// +// It doubles as the answer to "under whose lock is this fight taken", since +// roster[0] is the session's owner. It is resolved *before* the lock, so it +// must not touch getActiveZoneRun — that lookup carries the §4.3 idle reap. +func fightRoster(sender id.UserID) []id.UserID { + e, _, err := activeExpeditionFor(sender) + if err != nil || e == nil { + return []id.UserID{sender} + } + return expeditionAudience(e) +} + +// buildFightSeats turns a roster into the seats that will actually sit down, and +// the enemy they face. A member who is down, or somehow already fighting, is left +// out: they sit this one out rather than blocking the party. The leader is not +// optional — if seat 0 cannot fight, nobody does, and `refusal` says why in terms +// of whoever typed `!fight`. +// +// senderSkip is the sender's own reason for being left out, empty when they are +// seated. Without it a downed member's `!fight` opens the party's fight and then +// answers them with silence. +// +// The enemy is built once. Every seat's build derives the identical stat block +// from (monster, tier, dmMood); only the player half varies. +func (p *AdventurePlugin) buildFightSeats( + sender id.UserID, roster []id.UserID, monster DnDMonsterTemplate, tier, dmMood int, +) (seats []CombatSeatSetup, enemy *Combatant, senderSkip, refusal string) { + skip := func(uid id.UserID, why string) { + if uid == sender { + senderSkip = why + } + } + 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 + } + + hp, hpMax := dndHPSnapshot(uid) + if hp <= 0 { + if leader { + return nil, nil, "", seatZeroRefusal(sender, uid, + "You're in no shape to fight. `!rest` first.", + "Your party leader is in no shape to fight. The fight waits for them.") + } + skip(uid, "You're in no shape to fight — the party goes in without you. `!rest` when you can.") + continue + } + // A member's live fight lives on a combat_participant row, so + // hasActiveCombatSession — which keys on combat_session.user_id — would + // answer "no" for them mid-fight. The caller has already ruled out this + // room's own encounter, so anything found here is a different fight. + if s, serr := activeCombatSessionFor(uid); serr == nil && s != nil { + if leader { + return nil, nil, "", seatZeroRefusal(sender, uid, + "You're already in a fight. Finish it with `!attack` / `!flee`.", + "Your party leader is already in a fight somewhere else.") + } + slog.Info("combat: party member busy in another fight", "user", uid, "session", s.SessionID) + skip(uid, "You're already in a fight elsewhere — the party goes in without you.") + continue + } + + seats = append(seats, CombatSeatSetup{UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player}) + } + return seats, enemy, senderSkip, "" +} + +// seatCombatants is the roster's freshly-built characters in seating order — the +// same slice partyCombatantsForSession would rebuild from the persisted session, +// threaded through from the build that seated them instead. +func seatCombatants(seats []CombatSeatSetup) []*Combatant { + players := make([]*Combatant, len(seats)) + for i, s := range seats { + players[i] = s.C + } + return players +} + +// seatZeroRefusal picks the copy for a blocked seat 0. The leader hears about +// themselves in the second person; a member hears who the party is waiting on. +func seatZeroRefusal(sender, leader id.UserID, own, aboutLeader string) string { + if sender == leader { + return own + } + return aboutLeader +} + +// announcePartyFightStart DMs every seated member the opening block: the shared +// header, their own HP and curios, the roster's initiative order, whatever the +// enemy did before anyone could stop it, and either "your move" or who the round +// is waiting on. +// +// opening carries the events of a round-1 enemy turn — the monster can win +// initiative, and a party that reads "your move" over an unnarrated 12-point hit +// has been lied to. outcomes, when set, is the per-seat close-out of a fight that +// ended before it started. +// +// Solo does not come through here — handleFightCmd answers the one player who +// typed, with the bytes it always sent. +func (p *AdventurePlugin) announcePartyFightStart( + sess *CombatSession, players []*Combatant, enemy *Combatant, header string, + opening []CombatEvent, outcomes []string, +) { + acting, waiting := actingSeat(sess, players, enemy) + names := make([]string, len(players)) + for i, c := range players { + names[i] = c.Name + } + for seat, uid := range sess.SeatUserIDs() { + var b strings.Builder + b.WriteString(header) + b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", sess.seatHP(seat), sess.seatHPMax(seat))) + if curios := activeMagicItemsLine(id.UserID(uid)); curios != "" { + b.WriteString(curios + "\n") + } + b.WriteString("\n**Marching order:** ") + b.WriteString(strings.Join(initiativeNames(players, sess, enemy), " → ")) + b.WriteString("\n\n") + if len(opening) > 0 { + b.WriteString(RenderPartyTurnRound(opening, names, enemy.Name, seat)) + b.WriteString("\n\n") + } + switch { + case seat < len(outcomes): + b.WriteString(outcomes[seat]) + case !waiting: + b.WriteString(fmt.Sprintf("**Round %d.** The round is resolving.", sess.Round)) + case seat == acting: + b.WriteString(partyMovePrompt(sess.Round)) + default: + b.WriteString(fmt.Sprintf("**Round %d.** Waiting on **%s**.", sess.Round, players[acting].Name)) + } + if err := p.SendDM(id.UserID(uid), b.String()); err != nil { + slog.Error("combat: party fight-start DM failed", "user", uid, "err", err) + } + } +} + +// partyMovePrompt is the line a seat sees when the round is its own. `!flee` is +// missing on purpose: in a party it is the leader's call, and P5 refuses it to +// every other seat. +func partyMovePrompt(round int) string { + return fmt.Sprintf("**Round %d.** Your move — `!attack`, `!cast `, `!consume `.", round) +} + +// initiativeNames renders this round's turn order for the opening block. It is +// the party's one look at the initiative P3 rolls for them — the number itself +// stays hidden (accessibility over crunch); the order is the useful part. +func initiativeNames(players []*Combatant, sess *CombatSession, enemy *Combatant) []string { + order := turnOrder(sess, sess.Round, players, enemy) + names := make([]string, 0, len(order)) + for _, seat := range order { + if seat == enemySeat { + names = append(names, enemy.Name) + continue + } + names = append(names, players[seat].Name) + } + return names +} diff --git a/internal/plugin/combat_party_start_test.go b/internal/plugin/combat_party_start_test.go new file mode 100644 index 0000000..7d8f05a --- /dev/null +++ b/internal/plugin/combat_party_start_test.go @@ -0,0 +1,308 @@ +package plugin + +import ( + "strings" + "testing" + + "maunium.net/go/mautrix/id" +) + +// N3/P6c — seating the roster, and paying for it. +// +// The invariant every test here defends is the same one: a solo player must be +// unable to tell that parties exist. The seat-0 owner, the burn rate, and the +// opening block are all shapes a solo fight has already written thousands of +// times, and the balance corpus is the receipt. + +// ── who owns the fight ─────────────────────────────────────────────────────── + +// roster[0] is the fight's owner: its lock key and its session row. + +// A bare zone run — no expedition row anywhere — must not fall over looking for +// a leader. This is the `!zone enter` path, which predates expeditions entirely. +func TestFightRoster_BareZoneRunOwnsItsOwnFight(t *testing.T) { + setupEmptyTestDB(t) + wanderer := id.UserID("@wanderer:example.org") + + if got := fightRoster(wanderer); len(got) != 1 || got[0] != wanderer { + t.Errorf("fightRoster(wanderer) = %v, want just the player themselves", got) + } +} + +// The reason the lookup exists: a member's `!fight` opens the *leader's* fight, +// under the leader's lock and on the leader's session row. +func TestFightRoster_MembersFightBelongsToTheLeader(t *testing.T) { + setupEmptyTestDB(t) + leader := id.UserID("@lead:example.org") + member := id.UserID("@member:example.org") + seedExpedition(t, "exp-1", leader, "active") + if err := joinParty("exp-1", member); err != nil { + t.Fatal(err) + } + + for _, who := range []id.UserID{member, leader} { + if got := fightRoster(who)[0]; got != leader { + t.Errorf("fightRoster(%s)[0] = %q, want the leader %q", who, got, leader) + } + } +} + +// ── who sits down ──────────────────────────────────────────────────────────── + +func TestFightRoster_SoloSeatsExactlyOne(t *testing.T) { + setupEmptyTestDB(t) + solo := id.UserID("@solo:example.org") + seedExpedition(t, "exp-solo", solo, "active") + + roster := fightRoster(solo) + if len(roster) != 1 || roster[0] != solo { + t.Fatalf("solo roster = %v, want [%s]", roster, solo) + } +} + +// Seat 0 is the leader whoever typed `!fight`. Every seat-0 invariant in the +// combat layer — the lock key, the once-only close-out effects, the leader-only +// `!flee` — reads the roster's head, so a member-first ordering would flee the +// leader's run on a member's say-so. +func TestFightRoster_LeaderIsAlwaysSeatZero(t *testing.T) { + setupEmptyTestDB(t) + leader := id.UserID("@lead:example.org") + member := id.UserID("@member:example.org") + seedExpedition(t, "exp-1", leader, "active") + if err := joinParty("exp-1", member); err != nil { + t.Fatal(err) + } + + for _, who := range []id.UserID{leader, member} { + roster := fightRoster(who) + if len(roster) != 2 { + t.Fatalf("fightRoster(%s) = %v, want 2 seats", who, roster) + } + if roster[0] != leader { + t.Errorf("fightRoster(%s) seats %q at 0, want the leader", who, roster[0]) + } + } +} + +// ── who actually gets a seat ───────────────────────────────────────────────── + +// fightTestChar creates the full character stack buildZoneCombatants reads: +// the adventure character, then the D&D sheet its HP snapshot comes from. +func fightTestChar(t *testing.T, uid id.UserID, hp int) { + t.Helper() + if err := createAdvCharacter(uid, string(uid)); err != nil { + t.Fatal(err) + } + c := &DnDCharacter{ + UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5, + STR: 14, DEX: 12, CON: 14, INT: 10, WIS: 10, CHA: 10, + HPMax: 30, HPCurrent: hp, ArmorClass: 14, + } + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } +} + +func TestBuildFightSeats_SoloSeatsExactlyThePlayer(t *testing.T) { + setupEmptyTestDB(t) + solo := id.UserID("@solo:example.org") + fightTestChar(t, solo, 30) + + seats, enemy, skip, refusal := (&AdventurePlugin{}).buildFightSeats( + solo, []id.UserID{solo}, dndBestiary["goblin"], 1, 0) + if refusal != "" || skip != "" { + t.Fatalf("solo fight refused: %s / %s", refusal, skip) + } + if len(seats) != 1 || seats[0].UserID != solo { + t.Fatalf("seats = %+v, want exactly the solo player", seats) + } + if seats[0].HP != 30 || seats[0].HPMax != 30 { + t.Errorf("seat HP = %d/%d, want 30/30", seats[0].HP, seats[0].HPMax) + } + if seats[0].C == nil || seats[0].C.Name == "" { + t.Errorf("seat carries no built combatant: %+v", seats[0]) + } + if enemy == nil || enemy.Name == "" || enemy.Stats.MaxHP <= 0 { + t.Errorf("enemy not built: %+v", enemy) + } +} + +// A downed member is not a blocked party: they sit the fight out, and the seats +// that remain still line up leader-first. +func TestBuildFightSeats_DownedMemberSitsOut(t *testing.T) { + setupEmptyTestDB(t) + leader := id.UserID("@lead:example.org") + standing := id.UserID("@standing:example.org") + downed := id.UserID("@downed:example.org") + fightTestChar(t, leader, 30) + fightTestChar(t, standing, 25) + fightTestChar(t, downed, 0) + + roster := []id.UserID{leader, downed, standing} + seats, _, skip, refusal := (&AdventurePlugin{}).buildFightSeats( + leader, roster, dndBestiary["goblin"], 1, 0) + if refusal != "" { + t.Fatalf("party refused over a downed member: %s", refusal) + } + if skip != "" { + t.Errorf("the leader was seated, so nothing was skipped on their behalf: %q", skip) + } + if len(seats) != 2 { + t.Fatalf("seated %d, want the leader and the standing member", len(seats)) + } + if seats[0].UserID != leader || seats[1].UserID != standing { + t.Errorf("seats = [%s %s], want [leader standing]", seats[0].UserID, seats[1].UserID) + } + + // The one who was left behind typed `!fight` too, and silence is not an answer. + _, _, skip, refusal = (&AdventurePlugin{}).buildFightSeats( + downed, roster, dndBestiary["goblin"], 1, 0) + if refusal != "" { + t.Fatalf("a downed member must not refuse the party's fight: %s", refusal) + } + if !strings.Contains(skip, "`!rest`") { + t.Errorf("the downed member should be told to rest, got %q", skip) + } +} + +// Seat 0 is not optional, and the copy depends on who is reading it. +func TestBuildFightSeats_DownedLeaderRefusesTheFightForEveryone(t *testing.T) { + setupEmptyTestDB(t) + leader := id.UserID("@lead:example.org") + member := id.UserID("@member:example.org") + fightTestChar(t, leader, 0) + fightTestChar(t, member, 25) + + roster := []id.UserID{leader, member} + p := &AdventurePlugin{} + + seats, _, _, refusal := p.buildFightSeats(leader, roster, dndBestiary["goblin"], 1, 0) + if len(seats) != 0 || refusal == "" { + t.Fatalf("downed leader seated %d players, refusal %q", len(seats), refusal) + } + if !strings.Contains(refusal, "`!rest`") { + t.Errorf("the leader should be told to rest, got %q", refusal) + } + + _, _, _, refusal = p.buildFightSeats(member, roster, dndBestiary["goblin"], 1, 0) + if !strings.Contains(refusal, "leader") { + t.Errorf("the member should be told it is the leader holding things up, got %q", refusal) + } + if strings.Contains(refusal, "`!rest`") { + t.Errorf("the member cannot rest on the leader's behalf, got %q", refusal) + } +} + +// ── the opening block ──────────────────────────────────────────────────────── + +// initiativeNames walks the turn order, which carries the enemy as sentinel seat +// -1. Indexing players with it would panic on every party's first `!fight`. +func TestInitiativeNames_RendersTheEnemySentinelNotAPanic(t *testing.T) { + players, enemy := biasedParty() + sess := partySession(CombatPhasePlayerTurn, 100, 100, 100) + + names := initiativeNames(players, sess, enemy) + if len(names) != 4 { + t.Fatalf("order = %v, want 3 players + the enemy", names) + } + // biasedParty stacks initiative 300/200/100 against a speed-1 enemy. + if want := []string{"Ada", "Bram", "Cass", enemy.Name}; strings.Join(names, ",") != strings.Join(want, ",") { + t.Errorf("order = %v, want %v", names, want) + } +} + +func TestInitiativeNames_SoloIsPlayerThenEnemy(t *testing.T) { + p := basePlayer() + e := baseEnemy() + sess := turnSession(CombatPhasePlayerTurn, 100, 100) + + if got := initiativeNames([]*Combatant{&p}, sess, &e); len(got) != 2 || got[1] != e.Name { + t.Errorf("solo order = %v, want [player, enemy]", got) + } +} + +// ── what the party eats ────────────────────────────────────────────────────── + +// The rate a solo expedition burns at is a tuned constant that the whole +// difficulty corpus (Phase 3-B / 5-B) was measured against. It must come out of +// the party-aware path untouched. +func TestExpeditionBurnRatePct_SoloIsTheShippedRate(t *testing.T) { + setupEmptyTestDB(t) + solo := id.UserID("@solo:example.org") + seedExpedition(t, "exp-solo", solo, "active") + + if got := expeditionBurnRatePct("exp-solo"); got != phase5BDailyBurnRatePct { + t.Errorf("solo burn rate = %d, want the shipped %d", got, phase5BDailyBurnRatePct) + } + // An expedition with no roster row at all is the pre-N3 world: same answer. + if got := expeditionBurnRatePct("exp-never-seen"); got != phase5BDailyBurnRatePct { + t.Errorf("rosterless burn rate = %d, want the shipped %d", got, phase5BDailyBurnRatePct) + } +} + +// N × 0.8: a party eats more than one player and less than N of them. +func TestExpeditionBurnRatePct_PartyEatsMoreButNotProRata(t *testing.T) { + setupEmptyTestDB(t) + leader := id.UserID("@lead:example.org") + seedExpedition(t, "exp-1", leader, "active") + + want := map[int]int{1: 50, 2: 80, 3: 120} + for seat := 1; seat <= 2; seat++ { + if err := joinParty("exp-1", id.UserID(memberID(seat))); err != nil { + t.Fatal(err) + } + size := seat + 1 + if got := expeditionBurnRatePct("exp-1"); got != want[size] { + t.Errorf("party of %d burns at %d%%, want %d%%", size, got, want[size]) + } + } +} + +// Bit-identity, not approximate agreement: a solo expedition's supplies snapshot +// after the party-aware burn must equal the one applyDailyBurn produced. +func TestApplyExpeditionDailyBurn_SoloMatchesApplyDailyBurnExactly(t *testing.T) { + setupEmptyTestDB(t) + solo := id.UserID("@solo:example.org") + seedExpedition(t, "exp-solo", solo, "active") + + supplies := ExpeditionSupplies{Current: 40, Max: 40, DailyBurn: 3, HarshMod: 1.5} + e := &Expedition{ID: "exp-solo", UserID: string(solo), Supplies: supplies} + + for _, tc := range []struct{ harsh, siege bool }{{false, false}, {true, false}, {false, true}} { + wantS, wantBurn := applyDailyBurn(supplies, tc.harsh, tc.siege) + gotS, gotBurn := applyExpeditionDailyBurn(e, tc.harsh, tc.siege) + if gotBurn != wantBurn || gotS != wantS { + t.Errorf("harsh=%v siege=%v: got (%v, %g), want (%v, %g)", + tc.harsh, tc.siege, gotS, gotBurn, wantS, wantBurn) + } + } +} + +func TestApplyExpeditionDailyBurn_PartyOfThreeBurnsTwoPointFourShares(t *testing.T) { + setupEmptyTestDB(t) + leader := id.UserID("@lead:example.org") + seedExpedition(t, "exp-1", leader, "active") + for seat := 1; seat <= 2; seat++ { + if err := joinParty("exp-1", id.UserID(memberID(seat))); err != nil { + t.Fatal(err) + } + } + + supplies := ExpeditionSupplies{Current: 40, Max: 40, DailyBurn: 3, HarshMod: 1} + e := &Expedition{ID: "exp-1", UserID: string(leader), Supplies: supplies} + + _, solo := applyDailyBurn(supplies, false, false) + _, party := applyExpeditionDailyBurn(e, false, false) + + // DailyBurn 3 at the party-of-3 rate of 120% — exactly 2.4 solo shares, and + // exactly the value the int rate yields. A float32 0.8 would land at 3.6000001. + if want := float32(3.6); party != want { + t.Errorf("party of 3 burned %v SU, want %v", party, want) + } + if party >= solo*3 { + t.Errorf("party of 3 burned %g, which is no better than three solo runs (%g)", party, solo*3) + } + if party <= solo { + t.Errorf("party of 3 burned %g, no more than one player alone (%g)", party, solo) + } +} diff --git a/internal/plugin/combat_party_turn.go b/internal/plugin/combat_party_turn.go index 9036f79..2a73a42 100644 --- a/internal/plugin/combat_party_turn.go +++ b/internal/plugin/combat_party_turn.go @@ -405,7 +405,7 @@ func partyRoundFooter(ct *combatTurn, seat, acting int) string { } b.WriteString(fmt.Sprintf("%s: **%d/%d**\n\n", ct.enemy.Name, ct.sess.EnemyHP, ct.sess.EnemyHPMax)) if seat == acting { - b.WriteString(fmt.Sprintf("**Round %d.** Your move — `!attack`, `!cast `, `!consume `.", ct.sess.Round)) + b.WriteString(partyMovePrompt(ct.sess.Round)) } else { b.WriteString(fmt.Sprintf("**Round %d.** Waiting on **%s**.", ct.sess.Round, ct.players[acting].Name)) } diff --git a/internal/plugin/combat_party_turn_test.go b/internal/plugin/combat_party_turn_test.go index 74ba34c..5a527dd 100644 --- a/internal/plugin/combat_party_turn_test.go +++ b/internal/plugin/combat_party_turn_test.go @@ -350,10 +350,28 @@ func TestNotYourTurnMsg_NamesWhoWeAreWaitingOn(t *testing.T) { // ── Starting a fight ───────────────────────────────────────────────────────── +// enemyWithHP is the monster the seats below face. Only its HP pool and its +// Speed matter here: the pool sizes the fight, the speed feeds initiative. +func enemyWithHP(hp int) *Combatant { + e := baseEnemy() + e.Stats.MaxHP = hp + return &e +} + +// partySeats pairs each combatant with a seat setup, as buildFightSeats does. +func partySeats(players []*Combatant, hp int) []CombatSeatSetup { + seats := make([]CombatSeatSetup, len(players)) + for i, c := range players { + seats[i] = CombatSeatSetup{UserID: id.UserID(memberID(i)), HP: hp, HPMax: hp, C: c} + } + seats[0].UserID = "@leader:x" + return seats +} + func TestStartPartyCombatSession_SoloWritesNoParticipantRows(t *testing.T) { setupEmptyTestDB(t) - sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", 60, + sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemyWithHP(60), []CombatSeatSetup{{UserID: "@solo:x", HP: 40, HPMax: 40}}) if err != nil { t.Fatal(err) @@ -375,9 +393,11 @@ func TestStartPartyCombatSession_SoloWritesNoParticipantRows(t *testing.T) { func TestStartPartyCombatSession_SeedsEachSeatsOwnOneShots(t *testing.T) { setupEmptyTestDB(t) - sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", 60, []CombatSeatSetup{ - {UserID: "@leader:x", HP: 40, HPMax: 40}, - {UserID: "@abjurer:x", HP: 30, HPMax: 30, Mods: CombatModifiers{ArcaneWardHP: 12, WardCharges: 2}}, + leader, abjurer := basePlayer(), basePlayer() + sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemyWithHP(60), []CombatSeatSetup{ + {UserID: "@leader:x", HP: 40, HPMax: 40, C: &leader}, + {UserID: "@abjurer:x", HP: 30, HPMax: 30, C: &abjurer, + Mods: CombatModifiers{ArcaneWardHP: 12, WardCharges: 2}}, }) if err != nil { t.Fatal(err) @@ -402,6 +422,57 @@ func TestStartPartyCombatSession_SeedsEachSeatsOwnOneShots(t *testing.T) { } } +// A monster that wins initiative swings first. startCombatSession parks every +// fight on a player_turn, which is right for the hardcoded solo order and wrong +// for a party: the resume path would snap the cursor to the first player slot +// and the enemy would silently forfeit round 1. +func TestStartPartyCombatSession_EnemyThatWinsInitiativeOpensTheRound(t *testing.T) { + setupEmptyTestDB(t) + players, enemy := biasedParty() + // Invert the bias biasedParty stacks: the monster now outruns everyone. + for _, c := range players { + c.Mods.InitiativeBias = -1000 + } + enemy.Stats.Speed = 500 + + sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemy, + partySeats(players, 100)) + if err != nil { + t.Fatal(err) + } + if sess.Phase != CombatPhaseEnemyTurn { + t.Fatalf("round 1 opened on phase %q; the enemy won initiative and must act first", sess.Phase) + } + if order := turnOrder(sess, 1, players, enemy); order[0] != enemySeat { + t.Fatalf("turn order = %v, want the enemy at the head", order) + } + + // And the phase survives the round trip, since a resume reads it back. + back, err := getCombatSession(sess.SessionID) + if err != nil { + t.Fatal(err) + } + if back.Phase != CombatPhaseEnemyTurn { + t.Errorf("reloaded phase = %q, want the enemy still on the clock", back.Phase) + } +} + +// The other half of the same rule: when the party wins, nothing moved. +func TestStartPartyCombatSession_PartyThatWinsInitiativeStillOpensOnAPlayer(t *testing.T) { + setupEmptyTestDB(t) + players, enemy := biasedParty() + + sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemy, + partySeats(players, 100)) + if err != nil { + t.Fatal(err) + } + if sess.Phase != CombatPhasePlayerTurn || sess.Statuses.TurnIdx != 0 { + t.Fatalf("round 1 opened on %q/%d, want player_turn at the head of the order", + sess.Phase, sess.Statuses.TurnIdx) + } +} + // ── The round driver ───────────────────────────────────────────────────────── // The old loop rested on any player_turn. A party's downed seat still holds a @@ -411,17 +482,16 @@ func TestSettleCombatSession_StepsPastADownedSeat(t *testing.T) { setupEmptyTestDB(t) players, enemy := biasedParty() - sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", 100, []CombatSeatSetup{ - {UserID: "@leader:x", HP: 100, HPMax: 100}, - {UserID: "@bram:x", HP: 100, HPMax: 100}, - {UserID: "@cass:x", HP: 100, HPMax: 100}, - }) + sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemy, + partySeats(players, 100)) if err != nil { t.Fatal(err) } - // Bram is down, and it is his turn. + // Bram is down, and it is his turn. biasedParty stacks initiative so the + // party leads the round; force the phase back to it in case the roll disagrees. sess.Participants[0].HP = 0 + sess.Phase = CombatPhasePlayerTurn sess.Statuses.TurnIdx = 1 if _, err := settleCombatSession(sess, players, enemy); err != nil { @@ -446,7 +516,7 @@ func TestSettleCombatSession_IsANoOpOnASoloPlayerTurn(t *testing.T) { setupEmptyTestDB(t) p, e := basePlayer(), baseEnemy() - sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", 60, + sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemyWithHP(60), []CombatSeatSetup{{UserID: "@solo:x", HP: 40, HPMax: 40}}) if err != nil { t.Fatal(err) diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go index 5e841d4..75784e5 100644 --- a/internal/plugin/combat_session.go +++ b/internal/plugin/combat_session.go @@ -462,11 +462,12 @@ func startCombatSession(userID id.UserID, runID, encounterID, enemyID string, pl // roster_size bump, and the single unwrapped INSERT the solo path has always // issued. That is the invariant the whole balance corpus rests on. func (p *AdventurePlugin) startPartyCombatSession( - runID, encounterID, enemyID string, enemyHP int, seats []CombatSeatSetup, + runID, encounterID, enemyID string, enemy *Combatant, seats []CombatSeatSetup, ) (*CombatSession, error) { if len(seats) == 0 { return nil, fmt.Errorf("start combat session: empty roster") } + enemyHP := enemy.Stats.MaxHP owner := seats[0] sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID, owner.HP, owner.HPMax, enemyHP, enemyHP) @@ -492,6 +493,17 @@ func (p *AdventurePlugin) startPartyCombatSession( } sess.Participants = ps sess.rosterSize = len(seats) + + // startCombatSession parks every fight on a player_turn, because a solo + // order is hardcoded [player, enemy] and the player always leads. A party + // rolls for initiative and the monster can win it, so round 1's phase has + // to come from the order rather than from that assumption — otherwise the + // cursor snaps forward to the first player slot on resume and the enemy + // silently forfeits its opening turn. Round 2+ already derives this in + // stepRoundEnd. + order := turnOrder(sess, sess.Round, seatCombatants(seats), enemy) + sess.Phase = phaseForSeat(order[0]) + sess.Statuses.TurnIdx = 0 dirty = true } @@ -504,12 +516,15 @@ func (p *AdventurePlugin) startPartyCombatSession( } // CombatSeatSetup is one character's entry into a fight: who they are, the HP -// pool they bring, and the modifiers their fight-start one-shots are read off. +// pool they bring, the modifiers their fight-start one-shots are read off, and +// the built combatant itself — which the initiative roll needs, and which the +// caller threads on to the opening block rather than rebuilding the roster. type CombatSeatSetup struct { UserID id.UserID HP int HPMax int Mods CombatModifiers + C *Combatant } // getActiveCombatSession returns the player's in-flight fight, or (nil, nil). diff --git a/internal/plugin/dnd_cast.go b/internal/plugin/dnd_cast.go index a5e9aea..7dd4547 100644 --- a/internal/plugin/dnd_cast.go +++ b/internal/plugin/dnd_cast.go @@ -63,8 +63,12 @@ func decodePendingCast(s string) (PendingCast, bool) { func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) error { // In a turn-based Elite/Boss fight, !cast resolves as the player's turn // for the round rather than queuing for "next combat". handleCombatCastCmd - // takes the per-user lock itself and re-checks the session under it. - if sess, _ := getActiveCombatSession(ctx.Sender); sess != nil { + // takes the fight's locks itself and re-checks the session under them. + // + // activeCombatSessionFor, not getActiveCombatSession: a seated party member + // owns no session row, and would otherwise fall through to the out-of-combat + // path and queue a PendingCast in the middle of their own boss fight. + if sess, _ := activeCombatSessionFor(ctx.Sender); sess != nil { return p.handleCombatCastCmd(ctx, args) } diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index 56b0ea7..e4cec93 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -96,7 +96,9 @@ func (p *AdventurePlugin) nightRolloverBurn(e *Expedition) (float32, error) { var newSupplies ExpeditionSupplies var burn float32 if burnOverride.Multiplier > 0 { - burn = e.Supplies.DailyBurn * burnOverride.Multiplier * float32(phase5BDailyBurnRatePct) / 100 + // 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 { @@ -105,7 +107,7 @@ func (p *AdventurePlugin) nightRolloverBurn(e *Expedition) (float32, error) { newSupplies.ForagedToday = false } else { harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e) - newSupplies, burn = applyDailyBurn(e.Supplies, harsh, e.SiegeMode) + newSupplies, burn = applyExpeditionDailyBurn(e, harsh, e.SiegeMode) } if err := updateSupplies(e.ID, newSupplies); err != nil { return 0, err diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go index 3978180..bb8b250 100644 --- a/internal/plugin/dnd_expedition_extract.go +++ b/internal/plugin/dnd_expedition_extract.go @@ -49,7 +49,7 @@ func voluntaryExtractExpedition(userID id.UserID) (*Expedition, error) { return nil, ErrNoActiveExpedition } harsh := e.ThreatLevel > 60 - newSupplies, _ := applyDailyBurn(e.Supplies, harsh, e.SiegeMode) + newSupplies, _ := applyExpeditionDailyBurn(e, harsh, e.SiegeMode) e.Supplies = newSupplies supJSON, _ := json.Marshal(newSupplies) if _, err := db.Get().Exec(` @@ -239,13 +239,18 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error { userMu.Lock() defer userMu.Unlock() - exp, err := getActiveExpedition(ctx.Sender) + exp, isLeader, err := activeExpeditionFor(ctx.Sender) if err != nil { return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) } if exp == nil { return p.SendDM(ctx.Sender, "No active expedition to extract from.") } + if !isLeader { + // Extraction ends the expedition for the whole roster, so it is the + // leader's call — the same reasoning that makes `!flee` leader-only. + return p.SendDM(ctx.Sender, "Only your party leader can call the extraction. Ask them to `!extract`, or `!expedition leave` to walk out alone.") + } zone, _ := getZone(exp.ZoneID) updated, err := voluntaryExtractExpedition(ctx.Sender) if err != nil { @@ -263,14 +268,24 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error { b.WriteString(line) b.WriteString("\n\n") } - b.WriteString(fmt.Sprintf("Loot, XP, and coins are kept. The dungeon stays where you left it — `!resume` within 7 days to come back. After %s the expedition expires.", - (time.Now().UTC().Add(extractResumeWindow)).Format("2006-01-02 15:04 MST"))) - if err := p.SendDM(ctx.Sender, b.String()); err != nil { - return err - } + + // The extraction ends the day for everyone standing in the dungeon, and the + // roster outlives a voluntary extract — `extracting` is a resumable limbo, so + // members are still seated and must hear about it. Only the leader can call + // `!resume`, so the closing line differs per reader. + expires := (time.Now().UTC().Add(extractResumeWindow)).Format("2006-01-02 15:04 MST") + p.fanOutExpeditionDM(updated, b.String(), func(uid id.UserID, body string) string { + if uid == id.UserID(updated.UserID) { + return body + fmt.Sprintf("Loot, XP, and coins are kept. The dungeon stays where you left it — `!resume` within 7 days to come back. After %s the expedition expires.", expires) + } + return body + fmt.Sprintf("Loot, XP, and coins are kept. Your leader can `!resume` within 7 days, and you'll walk back in with them. After %s the expedition expires.", expires) + }) + // Emergence seam: surfacing from a run is when an animal may have moved - // into the empty house. - p.maybeRollPetArrivalOnEmerge(ctx.Sender) + // into the empty house. Every member surfaced, so every member rolls. + for _, uid := range expeditionAudience(updated) { + p.maybeRollPetArrivalOnEmerge(uid) + } return nil } diff --git a/internal/plugin/dnd_expedition_region_cmd.go b/internal/plugin/dnd_expedition_region_cmd.go index 417a626..456b063 100644 --- a/internal/plugin/dnd_expedition_region_cmd.go +++ b/internal/plugin/dnd_expedition_region_cmd.go @@ -140,7 +140,7 @@ func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition, // Burn one day of supplies (transit day). siege := exp.SiegeMode harsh := exp.ThreatLevel > 60 || zoneTemporalHarsh(exp) - newSupplies, burned := applyDailyBurn(exp.Supplies, harsh, siege) + newSupplies, burned := applyExpeditionDailyBurn(exp, harsh, siege) exp.Supplies = newSupplies if err := updateSupplies(exp.ID, exp.Supplies); err != nil { return "", fmt.Errorf("apply transit supply burn: %w", err) diff --git a/internal/plugin/dnd_expedition_supplies.go b/internal/plugin/dnd_expedition_supplies.go index 1357dd4..3993543 100644 --- a/internal/plugin/dnd_expedition_supplies.go +++ b/internal/plugin/dnd_expedition_supplies.go @@ -2,6 +2,7 @@ package plugin import ( "fmt" + "log/slog" "math/rand/v2" "strings" ) @@ -335,6 +336,45 @@ func applyDailyBurn(s ExpeditionSupplies, harshActive, siege bool) (ExpeditionSu return applyDailyBurnP(s, harshActive, siege, phase5BDailyBurnRatePct) } +// partyBurnEfficiency is the per-head discount a party gets on rations: three +// people eat 2.4 days' worth per day, not 3. Sharing a fire, a pot and a watch +// rota is worth something, and it is the one place C1 asked for a party to be +// mechanically better off than three solo runs. +// +// It is an exact ratio rather than the literal 0.8 because the rate is truncated +// to an int: float32(50*3) * 0.8 evaluates a hair under 120, and int() would +// round it to 119 — a silent, permanent tax on every party of three. +const ( + partyBurnEfficiencyNum = 4 + partyBurnEfficiencyDen = 5 +) + +// applyExpeditionDailyBurn is applyDailyBurn with the roster folded in: a party +// of N burns N × 0.8 days of supplies per day, against a pool that P6b's +// addSupplyPurchase has already grown by everyone's packs. +// +// A solo expedition — every expedition that has ever run, and everything the +// balance corpus measured — resolves to phase5BDailyBurnRatePct exactly, so this +// is a no-op for it down to the float. On a roster read error it burns the solo +// rate: undercharging a party is a bug, starving them on a SQLite hiccup is a +// lost expedition. +func applyExpeditionDailyBurn(e *Expedition, harshActive, siege bool) (ExpeditionSupplies, float32) { + return applyDailyBurnP(e.Supplies, harshActive, siege, expeditionBurnRatePct(e.ID)) +} + +func expeditionBurnRatePct(expeditionID string) int { + n, err := partySize(expeditionID) + if err != nil { + slog.Warn("expedition: party size read failed, burning at the solo rate", + "expedition", expeditionID, "err", err) + return phase5BDailyBurnRatePct + } + if n <= 1 { + return phase5BDailyBurnRatePct + } + return phase5BDailyBurnRatePct * n * partyBurnEfficiencyNum / partyBurnEfficiencyDen +} + // applyDailyBurnP is the rate-parameterized form used by the Phase 3-B // sim harness lever sweep. burnRatePct == 0 means "use live" (100%); // any positive value scales the final per-day burn by that percent diff --git a/internal/plugin/dnd_rest.go b/internal/plugin/dnd_rest.go index 6146f13..cd42694 100644 --- a/internal/plugin/dnd_rest.go +++ b/internal/plugin/dnd_rest.go @@ -51,14 +51,22 @@ func restingLockoutRemaining(c *DnDCharacter) time.Duration { // cannot rest right now because they're mid-fight or mid-expedition. An // empty string means rest is allowed. Both !rest short and !rest long // honor this — the dungeon shouldn't be a campfire. +// +// Both lookups resolve through the party: a seated member owns neither the +// session row nor the expedition row, so the owner-keyed reads would wave them +// through to a full heal mid boss fight. func restBlockedReason(uid id.UserID) string { - if hasActiveCombatSession(uid) { + if sess, _ := activeCombatSessionFor(uid); sess != nil { return "You're mid-fight. Finish it (or `!flee`) before resting." } - if exp, _ := getActiveExpedition(uid); exp != nil { - return "You can't rest while on an expedition. Use `!expedition extract` first." + exp, isLeader, _ := activeExpeditionFor(uid) + switch { + case exp == nil: + return "" + case !isLeader: + return "You can't rest while on an expedition. Ask your leader to `!extract`, or `!expedition leave` to walk out alone." } - return "" + return "You can't rest while on an expedition. Use `!expedition extract` first." } func (p *AdventurePlugin) handleDnDRestCmd(ctx MessageContext, args string) error { diff --git a/internal/plugin/dnd_zone_cmd_graph.go b/internal/plugin/dnd_zone_cmd_graph.go index 48eb8c2..dc97414 100644 --- a/internal/plugin/dnd_zone_cmd_graph.go +++ b/internal/plugin/dnd_zone_cmd_graph.go @@ -154,14 +154,18 @@ func nodeKindToRoomType(k ZoneNodeKind) RoomType { // arrival teaser. The player still has to !zone advance to resolve // the new room — same cadence as auto-advance. func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error { - run, err := getActiveZoneRun(ctx.Sender) + run, isLeader, err := activeZoneRunFor(ctx.Sender) if err != nil { return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error()) } if run == nil { return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter `.") } - if cs, _ := getActiveCombatSession(ctx.Sender); cs != nil { + if !isLeader { + // The fork is one choice for one party, and the run is the leader's row. + return p.SendDM(ctx.Sender, "Your party leader picks the path. `!map` to see where you're standing.") + } + if cs, _ := activeCombatSessionFor(ctx.Sender); cs != nil { return p.SendDM(ctx.Sender, "⚔️ Finish your fight first — `!attack` or `!flee`.") } pf, derr := decodePendingFork(run.NodeChoices) diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index 9cec6aa..e43c9b4 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -795,7 +795,7 @@ func (p *AdventurePlugin) autoDriveCombat(ctx MessageContext) (bool, error) { if err := p.handleFightCmd(ctx); err != nil { return false, fmt.Errorf("fight: %w", err) } - sess, err := getActiveCombatSession(ctx.Sender) + sess, err := activeCombatSessionFor(ctx.Sender) if err != nil { return false, fmt.Errorf("getActiveCombatSession: %w", err) } @@ -803,8 +803,12 @@ func (p *AdventurePlugin) autoDriveCombat(ctx MessageContext) (bool, error) { // No session opened (bestiary miss, doorway already cleared). return false, nil } + // The cap counts dispatches, and a party spends one per seat per round. Scale + // it by the roster so a three-player boss fight gets the same 200 rounds of + // headroom a solo one does; solo multiplies by 1 and is unchanged. sessionID := sess.SessionID - for i := 0; i < autoCombatRoundCap; i++ { + dispatchCap := autoCombatRoundCap * sess.RosterSize() + for i := 0; i < dispatchCap; i++ { // Re-fetch by id so we can read Won/Lost/Fled — the "active" // filter on getActiveCombatSession returns nil once status flips. cur, err := getCombatSession(sessionID) @@ -820,21 +824,55 @@ func (p *AdventurePlugin) autoDriveCombat(ctx MessageContext) (bool, error) { case CombatStatusLost, CombatStatusFled: return false, nil } - kind, arg := p.pickAutoCombatAction(ctx.Sender, cur) + + // Every combat command is refused to anyone but the seat on the clock, so + // a party fight has to be driven as whoever that is. Solo skips the lookup + // entirely: seat 0 is the only seat, and the sender is already sitting in + // it — the bit-identical path the balance corpus rests on. + turn := ctx + seat := 0 + if cur.IsParty() { + seat, err = p.actingSeatForAutopilot(cur) + if err != nil { + return false, fmt.Errorf("iter %d: %w", i, err) + } + turn.Sender = id.UserID(cur.seatUserID(seat)) + } + + kind, arg := p.pickAutoCombatActionForSeat(turn.Sender, cur, seat) var dispatchErr error switch kind { case "consume": - dispatchErr = p.handleConsumeCmd(ctx, arg) + dispatchErr = p.handleConsumeCmd(turn, arg) case "cast": - dispatchErr = p.handleCombatCastCmd(ctx, arg) + dispatchErr = p.handleCombatCastCmd(turn, arg) default: - dispatchErr = p.handleAttackCmd(ctx) + dispatchErr = p.handleAttackCmd(turn) } if dispatchErr != nil { return false, fmt.Errorf("%s iter %d: %w", kind, i, dispatchErr) } } - return false, fmt.Errorf("combat exceeded %d rounds", autoCombatRoundCap) + return false, fmt.Errorf("combat exceeded %d dispatches (%d seats × %d rounds)", + dispatchCap, sess.RosterSize(), autoCombatRoundCap) +} + +// actingSeatForAutopilot names the seat autoDriveCombat must act as next. The +// session it reads has already been settled by the command that parked it here, +// so it is sitting on a live player's turn; a session that is not is a bug in +// the engine, not a state the autopilot should paper over by spinning to the +// round cap. +func (p *AdventurePlugin) actingSeatForAutopilot(sess *CombatSession) (int, error) { + players, enemy, err := p.partyCombatantsForSession(sess) + if err != nil { + return 0, fmt.Errorf("rebuild party fight: %w", err) + } + seat, waiting := actingSeat(sess, players, enemy) + if !waiting { + return 0, fmt.Errorf("combat session %s: active but waiting on nobody (phase %q)", + sess.SessionID, sess.Phase) + } + return seat, nil } // simShortRestHPPct is the HP-percentage threshold below which the sim