mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
N3/P6c: a fight the whole party sits down for
`!fight` seats the expedition's roster instead of the one player who typed it. Seat 0 is the leader, always: the session row is theirs, the lock is theirs, and `!flee`, the fork, and `!extract` stay their call. A monster that wins initiative now swings before anyone speaks. The session layer used to park every new fight on a player_turn, which is true of the hardcoded solo order and a lie about a party's -- the enemy would forfeit round 1 and nobody would notice. `startPartyCombatSession` rolls the order and sets the phase from it; `handleFightCmd` settles the round before it announces, so the opening block narrates the hit rather than quietly showing its damage. Members were invisible to two commands that had no business ignoring them: `!cast` queued a spell for "next combat" while its caster was standing in one, and `!rest` healed a seated member to full mid boss fight. Both now resolve through the party. Nobody leaves without an answer. A downed member's `!fight` opens the party's fight and tells them why they are not in it. The leader's `!extract` reaches everyone it drags out of the dungeon, and everyone rolls for what moved into their house while they were gone. Supplies burn at 50% x N x 4/5 -- a party eats more than one and less than N. The ratio is exact: 0.8 as a float truncates a party of three to 119%, a permanent tax nobody would have found. Solo is untouched, byte for byte. One seat means one build, one INSERT, no participant rows, the same RNG draws in the same order -- the combat characterization golden does not move, and neither does the balance corpus.
This commit is contained in:
@@ -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
|
||||
|
||||
196
internal/plugin/combat_party_start.go
Normal file
196
internal/plugin/combat_party_start.go
Normal file
@@ -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 <spell>`, `!consume <item>`.", 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
|
||||
}
|
||||
308
internal/plugin/combat_party_start_test.go
Normal file
308
internal/plugin/combat_party_start_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 <spell>`, `!consume <item>`.", 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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <id>`.")
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user