mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Both scaling levers counted seats. partyEnemyHPScale gave +15% boss HP for any roster >= 2, and partyActionExpectation lifted the enemy from 1 to 2.4 attacks a round. A seat COUNT charges the same for an under-levelled friend, a hired NPC, and a true peer — so a below-median body cost a full seat's worth of boss and did not give a full seat's worth back. Measured, once the companion's free full-heal was taken away and he became honest: hiring him was WORSE than going alone (66.1% against solo's 69.0%). That is this bug, and it has been live for every under-levelled friend anyone has ever invited. Seats now carry a SeatWeight, and both levers scale on the summed weight of the LIVING seats rather than on a head count. The weight is level-based, priced against the leader, times a discount for a hireling (no subclass, no magic items, gear that is never Masterwork — the layers a player accrues and a hireling never will). Level, and deliberately not a power score: an HP-x-damage proxy would rank a cleric below a fighter and quietly make every mixed HUMAN party easier, which is a difficulty regression smuggled in under a bug fix. The safety argument is one property: **a peer weighs exactly 1.0**. So the curves interpolate between the integer knots the P8 sweep tuned — (1, 1.0), (2, 2.4), 2n-1 from 3 up — and every integer input returns exactly what it always returned. Solo is byte-identical, a party of same-level humans is byte-identical, both goldens hold unmoved, and only an UNEQUAL roster lands between the knots. That is the entire point of the change. It also finishes §2(b): a seat that is down now buys the enemy nothing. §2(b) fixed the head-count half; a corpse still carried its full weight until this. Measured, 640 runs/arm, same grid: solo 69.0% (unchanged — corpus intact) + Pete 76.8% (+7.8pp) + a human cleric peer 77.6% (+8.6pp) band solo +Pete lift trailing (<40%) 10.0% 31.0% +21.0pp middle 58.9% 76.8% +17.9pp leading (>=70%) 93.5% 99.2% +5.7pp Help, never a carry: he rescues the players who were drowning and barely moves the ones who were already fine — and he stays below a real human of the leader's level, which is the invariant a hireling must never break. Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
262 lines
10 KiB
Go
262 lines
10 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
|
|
}
|
|
}
|
|
// Parallel to `seats`, so a skipped member leaves no gap: what a seat costs the
|
|
// enemy is priced from these once the roster is final.
|
|
var levels []int
|
|
var companions []bool
|
|
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,
|
|
})
|
|
levels, companions = append(levels, level), append(companions, 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, dc, 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,
|
|
})
|
|
lvl := 0
|
|
if dc != nil {
|
|
lvl = dc.Level
|
|
}
|
|
levels, companions = append(levels, lvl), append(companions, false)
|
|
}
|
|
|
|
// Price each seat against the leader. It runs here, over the seats that were
|
|
// actually seated — a member who was skipped (downed, busy elsewhere) never
|
|
// joined the fight and must not be charged to the enemy.
|
|
applySeatWeights(seatCombatants(seats), levels, companions)
|
|
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
|
|
}
|