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:
prosolis
2026-07-09 23:23:17 -07:00
parent 1928f75c19
commit b333d05443
14 changed files with 806 additions and 68 deletions

View File

@@ -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
}