mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Three defects, all the same mistake, all found by sweep and not by tests: the companion has no database row for a thing to persist onto, so the thing "arrives fresh next time" — which for a resource means infinite. 1. His spell slots refilled every fight. The ledger went on his combat SEAT, and a seat is per-session. A human rations one pool across a 30-room run and gets it back at camp; rationing it IS the caster's game. Now on expedition_party.companion_slots_used, refreshed at camp. (Worth ~0pp alone — a run holds only ~2 real fights, so the pool never binds. I predicted this was the whole answer. It was not.) 2. His BODY refilled every fight. buildFightSeats seated him at Stats.MaxHP and the close-out skipped him — "he arrives fresh next time", said the comment. That is an infinite body: he soaked a share of every fight's incoming and then reset, while the humans beside him bled all the way to camp. THIS was the carry. Now expedition_party.companion_hp; healed at camp; a dropped companion returns on 1 HP rather than as a corpse, because there is no companion-death rule and inventing one inside a bug fix would be a second feature. 3. No autopiloted caster had ever healed ITSELF. simPickAllyHeal skipped `i == seat` and bailed on !IsParty(), so a solo cleric carried cure_wounds for a whole run and never once cast it. Now simPickHeal: heal whoever is worst off, which is sometimes you. Measured, 640 runs/arm, like-for-like (the leaders whose role-fill gives Pete a Cleric, against a human Cleric follower of the leader's own level): solo 69.0% + a human cleric 77.6% (+8.6pp) + Pete 66.1% (-2.9pp) The reference arm is the point. Against SOLO even a mace-only Pete looked like a carry — but parties are designed to be safer, so solo is the wrong yardstick. Against a human peer the real bug appeared: a gearless, level-penalized hireling was out-clearing a fully-geared human cleric of the leader's own level by 15pp, because he was the only combatant in the game who healed to full between fights. With the free lunches gone he is honest, and honestly a net negative — which is exactly the plan's §2 diagnosis, unmasked: a below-median seat cannot pay for its own enemy scaling (+15% boss HP and 2.4 enemy actions a round instead of 1). §2(a) is next, and the sweep now argues FOR it; before this commit it would have made things worse. Self-heal moved solo 66.1% -> 66.2%, so the balance corpus is undisturbed and no re-baseline is owed. It is also NOT the answer to §6 — casters reach for a healing consumable first and the sim stocks them, so a human rarely falls through to the spell. Pete carries no consumables, so it is his only heal. Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
247 lines
9.8 KiB
Go
247 lines
9.8 KiB
Go
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}
|
|
}
|
|
// Seats, not audience: the hired companion fights even though he never
|
|
// receives a DM about it.
|
|
return expeditionSeats(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
|
|
|
|
// The hired companion. He must be handled before everything below:
|
|
// dndHPSnapshot returns (0,0) for a user with no sheet, so the very next
|
|
// check would quietly sit him out of every fight he was paid for, and
|
|
// buildZoneCombatants would then fail on him anyway.
|
|
//
|
|
// He is latched onto autopilot at seat time rather than after the away-player
|
|
// deadline — nobody is going to type for him, and waiting three minutes to
|
|
// discover that would stall the fight and then announce him to the party as
|
|
// an absent player.
|
|
if isCompanionSeat(uid) {
|
|
expID := companionExpeditionFor(roster[0])
|
|
class, level := companionLoadout(expID)
|
|
player, _, _ := p.companionCombatant(class, level, monster, tier, dmMood)
|
|
// He carries his wounds between fights, like everyone else. Seating him at
|
|
// full max HP — which is what this did — hands the party an infinite body:
|
|
// he soaks a share of every fight's incoming and then resets, while the
|
|
// humans beside him bleed all the way to camp.
|
|
seats = append(seats, CombatSeatSetup{
|
|
UserID: uid,
|
|
HP: companionSeatHP(expID, player.Stats.MaxHP),
|
|
HPMax: player.Stats.MaxHP,
|
|
Mods: player.Mods, C: &player, EngineDriven: true,
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Both refusals below are cheap and neither needs the build, so they run
|
|
// before it: consuming a seat's armed ability and *then* sitting them out
|
|
// would spend their rage on a fight they never joined.
|
|
hp, hpMax := dndHPSnapshot(uid)
|
|
if hp <= 0 {
|
|
if leader {
|
|
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
|
|
}
|
|
|
|
// Consumed exactly once for the fight, here. Every later rebuild
|
|
// re-applies this id off the seat's statuses rather than re-arming.
|
|
armed := ""
|
|
if c, cerr := LoadDnDCharacter(uid); cerr == nil && c != nil {
|
|
trySimAutoArm(c)
|
|
armed = consumeArmedAbility(c)
|
|
}
|
|
player, e, _, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed)
|
|
if err != nil {
|
|
if leader {
|
|
return nil, nil, "", "Couldn't set up the fight: " + err.Error()
|
|
}
|
|
slog.Warn("combat: party member left out of fight", "user", uid, "err", err)
|
|
skip(uid, "Couldn't bring you into the fight: "+err.Error())
|
|
continue
|
|
}
|
|
if leader {
|
|
enemy = &e
|
|
}
|
|
if armed != "" {
|
|
slog.Info("combat: armed ability fired (turn-based start)", "user", uid, "ability", armed)
|
|
}
|
|
|
|
seats = append(seats, CombatSeatSetup{
|
|
UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player, ArmedAbility: armed,
|
|
})
|
|
}
|
|
return seats, enemy, senderSkip, ""
|
|
}
|
|
|
|
// 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() {
|
|
// Seat-keyed fan-out, so it bypasses expeditionAudience's filter — the
|
|
// companion sits down but is never written to. (He also has no magic items
|
|
// to line up, and activeMagicItemsLine would go looking for them.)
|
|
if isCompanionUser(uid) {
|
|
continue
|
|
}
|
|
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
|
|
}
|