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
318 lines
13 KiB
Go
318 lines
13 KiB
Go
package plugin
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// N3/P6b — the player-facing party surface.
|
|
//
|
|
// !expedition invite @user (leader, Day 1)
|
|
// !expedition accept [packs] (invitee — buys their own loadout)
|
|
// !expedition decline
|
|
// !expedition party (roster + pool)
|
|
// !expedition leave (member; the leader ends it with !extract)
|
|
//
|
|
// The invitee pays for their own supplies, which is what makes a party fair
|
|
// rather than a free ride: the pool is the sum of what everyone carried in.
|
|
|
|
// zoneOpenToLevel reports whether a player at this level may enter the zone. The
|
|
// leader's expedition is no way around the tier gate — a party is not a taxi
|
|
// into content two tiers above your head.
|
|
func zoneOpenToLevel(zoneID ZoneID, level int) bool {
|
|
for _, z := range zonesForLevel(level) {
|
|
if z.ID == zoneID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// expeditionCmdInvite asks a player to join the sender's expedition.
|
|
func (p *AdventurePlugin) expeditionCmdInvite(ctx MessageContext, rest string) error {
|
|
target := strings.TrimSpace(rest)
|
|
if target == "" {
|
|
return p.SendDM(ctx.Sender, "`!expedition invite @user` — bring someone along. Day 1 only.")
|
|
}
|
|
|
|
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. `!expedition start <zone>` first.")
|
|
}
|
|
if !isLeader {
|
|
return p.SendDM(ctx.Sender, "Only the party leader can invite. You're along for the ride.")
|
|
}
|
|
if !inviteWindowOpen(exp) {
|
|
return p.SendDM(ctx.Sender,
|
|
"This expedition has already set off — companions join on Day 1 only.")
|
|
}
|
|
|
|
invitee, ok := p.ResolveUser(target, ctx.RoomID)
|
|
if !ok {
|
|
return p.SendDM(ctx.Sender, "Couldn't find that player.")
|
|
}
|
|
if invitee == ctx.Sender {
|
|
return p.SendDM(ctx.Sender, "You're already here.")
|
|
}
|
|
if c, cerr := LoadDnDCharacter(invitee); cerr != nil || c == nil || c.PendingSetup {
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"**%s** hasn't made a character yet — they need `!setup` first.", p.DisplayName(invitee)))
|
|
}
|
|
|
|
zone := zoneOrFallback(exp.ZoneID)
|
|
if err := inviteToParty(exp.ID, invitee, ctx.Sender); err != nil {
|
|
return p.SendDM(ctx.Sender, inviteRefusalText(p.DisplayName(invitee), err))
|
|
}
|
|
|
|
if derr := p.SendDM(invitee, fmt.Sprintf(
|
|
"🤝 **%s** wants you on an expedition into **%s**.\n\n"+
|
|
"You'll buy your own supplies and they go into the party's pool. Loot and XP are yours alone — nothing is split.\n\n"+
|
|
"`!expedition accept` to pick a supply pack, or `!expedition decline`.\n"+
|
|
"_The offer stands for %s._",
|
|
p.DisplayName(ctx.Sender), zone.Display, formatRespecDuration(expeditionInviteTTL))); derr != nil {
|
|
// The invite row is committed; the DM is not. Tell the leader rather
|
|
// than rolling back — the invitee can still `!expedition accept`.
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"Invited **%s**, but couldn't DM them (%v). They can still `!expedition accept`.",
|
|
p.DisplayName(invitee), derr))
|
|
}
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"🤝 Asked **%s** to join you in **%s**. I'll hold the expedition here until they answer.",
|
|
p.DisplayName(invitee), zone.Display))
|
|
}
|
|
|
|
// inviteRefusalText turns the invite layer's sentinel errors into player-facing
|
|
// copy. Anything unrecognised surfaces raw — a silent "couldn't invite" would
|
|
// hide a real bug.
|
|
func inviteRefusalText(name string, err error) string {
|
|
switch {
|
|
case errors.Is(err, ErrPartyFull):
|
|
return fmt.Sprintf("Your party is full (%d, counting invites you haven't heard back on).", expeditionPartyMax)
|
|
case errors.Is(err, ErrAlreadyInParty):
|
|
return fmt.Sprintf("**%s** is already with you.", name)
|
|
case errors.Is(err, ErrAlreadyInvited):
|
|
return fmt.Sprintf("You've already asked **%s** — still waiting on them.", name)
|
|
case errors.Is(err, ErrPlayerBusyElsewhere):
|
|
return fmt.Sprintf("**%s** is already on an expedition of their own.", name)
|
|
default:
|
|
return "Couldn't send the invite: " + err.Error()
|
|
}
|
|
}
|
|
|
|
// expeditionCmdAccept seats the sender on the expedition they were most recently
|
|
// asked to join, once they have bought their own supplies.
|
|
func (p *AdventurePlugin) expeditionCmdAccept(ctx MessageContext, c *DnDCharacter, rest string) error {
|
|
if remaining := restingLockoutRemaining(c); remaining > 0 {
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"🛌 You're still resting — %s remaining. Pack up after.", formatRespecDuration(remaining)))
|
|
}
|
|
inv, err := latestInviteFor(ctx.Sender)
|
|
if errors.Is(err, ErrInviteNotFound) {
|
|
return p.SendDM(ctx.Sender, "Nobody has invited you anywhere.")
|
|
}
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't read your invites: "+err.Error())
|
|
}
|
|
|
|
exp, err := getExpedition(inv.ExpeditionID)
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't read that expedition: "+err.Error())
|
|
}
|
|
if exp == nil || !inviteWindowOpen(exp) {
|
|
_ = clearInvite(inv.ExpeditionID, ctx.Sender)
|
|
return p.SendDM(ctx.Sender, "That expedition has already set off without you.")
|
|
}
|
|
// A standalone zone run isn't caught by assertNotAdventuring — it guards
|
|
// expeditions and rosters, not bare runs. startExpedition rejects one; so
|
|
// must this.
|
|
if run, _ := getActiveZoneRun(ctx.Sender); run != nil {
|
|
return p.SendDM(ctx.Sender,
|
|
"You have an active zone run. Finish or `!zone abandon` before joining a party.")
|
|
}
|
|
|
|
zone := zoneOrFallback(exp.ZoneID)
|
|
if !zoneOpenToLevel(exp.ZoneID, c.Level) {
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"**%s** is beyond you for now — come back at a higher level.", zone.Display))
|
|
}
|
|
|
|
packTok := strings.TrimSpace(rest)
|
|
if packTok == "" {
|
|
return p.SendDM(ctx.Sender, renderLoadoutPrompt(zone, "expedition accept"))
|
|
}
|
|
purchase, err := resolveLoadoutOrParse(packTok, zone.Tier)
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error())
|
|
}
|
|
if err := purchase.Validate(zone.Tier); err != nil {
|
|
return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
|
|
}
|
|
if p.euro == nil {
|
|
return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
|
|
}
|
|
cost := float64(purchase.Cost())
|
|
if balance := p.euro.GetBalance(ctx.Sender); balance < cost {
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"Not enough coins. Outfitting costs **%d** but you have **%.0f**.", int(cost), balance))
|
|
}
|
|
|
|
if !p.euro.Debit(ctx.Sender, cost, "expedition outfitting: "+string(exp.ZoneID)) {
|
|
return p.SendDM(ctx.Sender, "Couldn't debit outfitting cost (try again).")
|
|
}
|
|
if err := joinParty(exp.ID, ctx.Sender); err != nil {
|
|
p.euro.Credit(ctx.Sender, cost, "expedition outfitting refund")
|
|
return p.SendDM(ctx.Sender, inviteRefusalText(p.DisplayName(ctx.Sender), err))
|
|
}
|
|
_ = clearInvite(exp.ID, ctx.Sender)
|
|
|
|
// Fold their packs into the pool. Do this after the seat is committed: a
|
|
// member whose supplies landed but whose seat did not would have paid to
|
|
// feed a party they aren't in.
|
|
suppliesPurchase := purchase
|
|
if isHol, _ := isHolidayToday(); isHol {
|
|
suppliesPurchase.StandardPacks++
|
|
}
|
|
// `exp` was fetched before the coin debit, and a second invitee accepting —
|
|
// or the leader's day-burn tick — may have rewritten the row since. Folding
|
|
// onto that stale snapshot would silently drop their packs or resurrect
|
|
// spent SU, so the fold happens against a fresh read under the pool's lock.
|
|
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
|
|
return addSupplyPurchase(fresh.Supplies, suppliesPurchase), nil
|
|
})
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool: "+err.Error())
|
|
}
|
|
|
|
leader := id.UserID(exp.UserID)
|
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
|
|
fmt.Sprintf("%s joined the party", p.DisplayName(ctx.Sender)), "")
|
|
for _, uid := range expeditionAudience(exp) {
|
|
body := fmt.Sprintf("🤝 **%s** joins the expedition into **%s**. Supplies pooled: **%.1f SU**.",
|
|
p.DisplayName(ctx.Sender), zone.Display, pooled.Current)
|
|
if uid == ctx.Sender {
|
|
body = fmt.Sprintf("🤝 You join **%s** in **%s**. Party supplies: **%.1f SU**.\n\n"+
|
|
"`!expedition party` for the roster. When a fight starts, you'll act on your own turn.",
|
|
p.DisplayName(leader), zone.Display, pooled.Current)
|
|
}
|
|
if derr := p.SendDM(uid, body); derr != nil {
|
|
return derr
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// expeditionCmdDecline drops the sender's most recent invite.
|
|
func (p *AdventurePlugin) expeditionCmdDecline(ctx MessageContext) error {
|
|
inv, err := latestInviteFor(ctx.Sender)
|
|
if errors.Is(err, ErrInviteNotFound) {
|
|
return p.SendDM(ctx.Sender, "Nobody has invited you anywhere.")
|
|
}
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't read your invites: "+err.Error())
|
|
}
|
|
if err := clearInvite(inv.ExpeditionID, ctx.Sender); err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't decline: "+err.Error())
|
|
}
|
|
leader := id.UserID(inv.InvitedBy)
|
|
_ = p.SendDM(leader, fmt.Sprintf("**%s** won't be joining you. The expedition is yours to walk.",
|
|
p.DisplayName(ctx.Sender)))
|
|
return p.SendDM(ctx.Sender, "Declined. Maybe next time.")
|
|
}
|
|
|
|
// expeditionCmdParty renders the roster, whoever asks for it.
|
|
func (p *AdventurePlugin) expeditionCmdParty(ctx MessageContext) error {
|
|
exp, _, 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.")
|
|
}
|
|
members, err := partyMembers(exp.ID)
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't read the roster: "+err.Error())
|
|
}
|
|
zone := zoneOrFallback(exp.ZoneID)
|
|
|
|
var b strings.Builder
|
|
b.WriteString(fmt.Sprintf("🎒 **%s — Day %d**\n\n", zone.Display, exp.CurrentDay))
|
|
if len(members) == 0 {
|
|
b.WriteString(fmt.Sprintf("**%s** — alone.\n", p.DisplayName(id.UserID(exp.UserID))))
|
|
}
|
|
for _, m := range members {
|
|
if isCompanionUser(m.UserID) {
|
|
b.WriteString(companionRosterLine(exp.ID))
|
|
continue
|
|
}
|
|
role := "member"
|
|
if m.IsLeader() {
|
|
role = "leader"
|
|
}
|
|
b.WriteString(fmt.Sprintf("**%s** _(%s)_\n", p.DisplayName(id.UserID(m.UserID)), role))
|
|
}
|
|
if invites, ierr := pendingInvites(exp.ID); ierr == nil {
|
|
for _, inv := range invites {
|
|
b.WriteString(fmt.Sprintf("**%s** _(asked, no answer yet)_\n", p.DisplayName(id.UserID(inv.UserID))))
|
|
}
|
|
}
|
|
b.WriteString(fmt.Sprintf("\nSupplies: **%.1f / %.1f SU**", exp.Supplies.Current, exp.Supplies.Max))
|
|
if inviteWindowOpen(exp) {
|
|
b.WriteString("\n\n_`!expedition invite @user` while it's still Day 1._")
|
|
if !companionSeated(exp.ID) {
|
|
b.WriteString("\n_Short a body? `!expedition hire` brings Pete along._")
|
|
}
|
|
}
|
|
return p.SendDM(ctx.Sender, b.String())
|
|
}
|
|
|
|
// expeditionCmdLeave walks a member out. The leader cannot leave — their row is
|
|
// the expedition — so they are pointed at `!extract`, which ends it for all.
|
|
func (p *AdventurePlugin) expeditionCmdLeave(ctx MessageContext) error {
|
|
// Resolve the seat the way the guards that trap them do. seatedExpeditionFor
|
|
// spans `extracting`, which activeExpeditionFor does not: a leader who
|
|
// extracts and never resumes would otherwise leave their members seated —
|
|
// refused a new adventure by the guard, and told "no active expedition" by
|
|
// the very command the guard points them at. The exit has to see every state
|
|
// the gate sees. It already excludes leaders, so they fall through below.
|
|
seated, err := seatedExpeditionFor(ctx.Sender)
|
|
if err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
|
}
|
|
if seated != nil {
|
|
return p.leaveSeatedParty(ctx, seated)
|
|
}
|
|
|
|
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.")
|
|
}
|
|
if isLeader {
|
|
return p.SendDM(ctx.Sender,
|
|
"You're leading this one — `!extract` ends it for everyone, or `!expedition abandon` to walk away from it.")
|
|
}
|
|
return p.leaveSeatedParty(ctx, exp)
|
|
}
|
|
|
|
// leaveSeatedParty unseats a member and tells both ends. Shared by the two ways
|
|
// a member's seat resolves: the `extracting` limbo and the plain active party.
|
|
func (p *AdventurePlugin) leaveSeatedParty(ctx MessageContext, exp *Expedition) error {
|
|
if err := leaveParty(exp.ID, ctx.Sender); err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't leave: "+err.Error())
|
|
}
|
|
// Supplies stay in the pool. They were spent on the expedition, not lent to
|
|
// it, and clawing them back would let a member starve the party on their way
|
|
// out of the door.
|
|
_ = p.SendDM(id.UserID(exp.UserID), fmt.Sprintf(
|
|
"**%s** turned back. Their supplies stay with the party.", p.DisplayName(ctx.Sender)))
|
|
return p.SendDM(ctx.Sender, "You turn back for town. Your supplies stay with the party.")
|
|
}
|