mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
N3 widened the combat *roster* from 1 to N but never widened the action model,
the scaling model, or the test net to match. Building the hireable companion
walked into all three. Only one of the four defects found was the companion's;
the rest have been live in prod for every human party since N3.
The party golden did not exist (§5)
Solo combat is pinned exhaustively (7468 lines); the entire N-body layer had
nothing. That is why a healer class that cannot heal shipped without a test
going red. Adds party_characterization.golden (9 scenarios x 5 seeds, incl.
weak and dying seats) and TestPartyCharacterization_OneSeatIsStillSolo, so the
N-body path can never quietly stop being a superset of the balance corpus.
Regenerate only on purpose: -update-party.
No action could target another seat (§1)
Every heal in the engine was self-scoped. A party cleric could not put one hit
point on a friend. Adds turnActionEffect.AllyHeal/AllySeat, `!cast <spell>
@user` and `--target @user` -- the latter has been advertised in !help and
silently swallowed by parseCombatCast since SP2 ("reserved for SP3"). The auto
picker uses it too (simPickAllyHeal), so away-players and engine-driven healers
behave like competent ones. It will not raise the dead.
Corpses kept buffing the boss (§2b)
enemyActionsThisRound counted len(st.actors), dead included -- so a party that
lost a member kept paying for them, and the survivor faced a boss still swinging
at two-player cadence, alone. A death spiral with the arrow pointing the wrong
way. Now counts livingActors(). Party golden moved deliberately for this.
An engine-driven seat was a bool any command could clear (§3)
autoDriveCombat drives a party by dispatching each seat's turn AS that seat, so
a companion's own auto-played move arrived at beginCombatTurn looking like a
player returning to the keyboard and cleared the latch that was moving him. He
then stood in the fight doing nothing while the boss he had inflated killed
everyone. ActorStatuses.EngineDriven is now a persisted seat property that no
command clears, and the driver calls driveEngineSeat instead of impersonating.
"The party" could be empty (§4)
A solo expedition has no expedition_party rows, so asking the roster who was in
the party answered "nobody" -- and every caller fell back to something plausible.
That is how the companion got hired at level 1 for exactly the player the feature
exists for. expeditionParty()/partyHumans() always include the owner.
The companion himself (!expedition hire [class] / !expedition dismiss)
Day 1, leader only, costs coins, role-fills the gap, globally exclusive. He is an
NPC seat and must never become a player: no player_meta, no dnd_character, no
inventory, no DM room -- mint him a player_meta row and
ensureDnDCharacterForCombat will auto-build the news bot a real character, and
he starts appearing in the graveyard and filing death notices about himself.
Mail and seats are different sets: he fights, he does not get written to.
Measured on millenia, n=750/arm. Before these fixes he was -28pp -- worse than no
companion at all. After: solo 48.5% -> 63.9% clear (+15.3pp), with +28.0pp for
trailing players and +2.0pp for leaders. Help, never a carry.
The solo golden is byte-identical throughout: solo combat provably did not move,
and the balance corpus is intact.
Known gap: the companion cannot cast (castActionForSeat loads a sheet from the DB
and he has none by design), so a hired Cleric is still just a bad fighter.
Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
240 lines
9.5 KiB
Go
240 lines
9.5 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) {
|
|
class, level := companionLoadout(companionExpeditionFor(roster[0]))
|
|
player, _, _ := p.companionCombatant(class, level, monster, tier, dmMood)
|
|
seats = append(seats, CombatSeatSetup{
|
|
UserID: uid, HP: 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
|
|
}
|