mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
N3/P5: a fight that knows whose turn it is
A solo fight is a conversation: the player types, the engine answers, and
nothing happens in between. A party fight is a queue, and three things follow.
Turn ownership. Only the seat on the clock may act, so beginCombatTurn resolves
the sender's seat and refuses the rest before anyone spends a slot or burns an
item. A fight lock, because three members typing !attack at once took three
different user locks and the check would have passed for all three: it takes the
fight's lock (keyed on seat 0) then the member's own, always in that order, and
a solo fight -- whose owner is the sender, and sync.Mutex is not reentrant --
takes exactly the one lock it always took. And a turn deadline, because one
member who wanders off must not freeze the other two for the hour it takes the
session reaper to wake up.
The deadline is three minutes, not the plan's sixty seconds. The sweep rides the
existing one-minute ticker, so any deadline really fires in [d, d+1m); and
expeditions here run for days, so the asymmetry favours patience over robbing
someone of their boss turn while they read the room on their phone. A lapse
latches that seat onto the auto-picker for the rest of the fight, so an absent
member costs the party one wait rather than one per round. Typing anything hands
the wheel back. Solo is never swept.
Three seat-0 leaks fixed on the way past, all of which would have surfaced as
the leader quietly doing everyone's business:
- mid-fight buffs folded into the session's embedded ActorStatuses, so a
member casting Shield on themselves would have armoured the leader;
- pickAutoCombatAction read sess.PlayerHP and Statuses.ConcentrationDmg, so
playing an away member's turn would have healed the wrong person and
re-armed the wrong aura;
- runCombatRound rested on any player_turn, and a downed seat still holds one
-- the round would have come to rest on a corpse and waited for a dead
member to type !attack. settleCombatSession drains it. beginCombatTurn
settles before reading the clock, which also fixes a latent solo bug: a
fight interrupted mid enemy_turn resumed parked there and silently ate the
player's next !attack.
The narration turned out to be written in the second person -- "You score 9
damage", "A hit gets through your guard" -- so swapping a name per seat would
have told three people they each landed the same blow. A round is rendered once
per reader instead: your own events go through the untouched flavor pool, your
allies' through a terse third-person summary. CombatEvent carries the seat to
make that possible, stamped once per phase step rather than at the twenty-odd
append sites in the primitives, which emit against the cursor and know nothing
of seats.
Closing out fans along the seam the data model already cut. Threat, the
zone-kill record, the boss-defeat drop and the run teardown all resolve through
getActiveExpedition or getActiveZoneRun, and a member owns neither row -- so
they fire once, for the owner. Fanning them out would have tripled the threat a
single kill costs. HP, XP, loot and death are the character's, and every seat
gets their own. A member can be dead in a fight the party won, so death is read
per seat off HP, not off the session's status.
The reaper stays attack-only. Finishing an abandoned fight should not quietly
burn the player's spell slots and potions; the deadline latch does use the
picker, because that member is mid-fight with a party waiting on them.
startPartyCombatSession has no production caller yet -- handleFightCmd still
opens a solo session. P6 seats the party.
TestCombatCharacterization is byte-identical: solo balance did not move.
This commit is contained in:
@@ -98,6 +98,11 @@ func (p *AdventurePlugin) eventTicker() {
|
||||
|
||||
// Auto-play any combat sessions past their 1h timeout.
|
||||
p.reapExpiredCombatSessions()
|
||||
|
||||
// Latch away party members onto autopilot so one absent player
|
||||
// can't hold a co-op fight hostage (N3/P5). Solo fights are never
|
||||
// listed — they answer to the reaper above.
|
||||
p.nudgeStalledPartyTurns()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,29 +149,43 @@ func (p *AdventurePlugin) handleFleeCmd(ctx MessageContext) error {
|
||||
return p.handleCombatActionCmd(ctx, PlayerAction{Kind: ActionFlee})
|
||||
}
|
||||
|
||||
const noFightMsg = "You're not in a fight. `!fight` at an Elite or Boss room to start one."
|
||||
|
||||
func (p *AdventurePlugin) handleCombatActionCmd(ctx MessageContext, action PlayerAction) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
sess, err := getActiveCombatSession(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
|
||||
ct, release, msg := p.beginCombatTurn(ctx.Sender, noFightMsg)
|
||||
if ct == nil {
|
||||
return p.replyDM(ctx, msg)
|
||||
}
|
||||
if sess == nil {
|
||||
return p.replyDM(ctx, "You're not in a fight. `!fight` at an Elite or Boss room to start one.")
|
||||
defer release()
|
||||
|
||||
// Fleeing ends the run for the whole party, so it is the leader's call —
|
||||
// the same reasoning that makes `!extract` leader-only.
|
||||
if action.Kind == ActionFlee && ct.isParty() && ct.seat != 0 {
|
||||
return p.replyDM(ctx, "Only your party leader can break off a fight.")
|
||||
}
|
||||
|
||||
player, enemy, err := p.combatantsForSession(sess)
|
||||
if err != nil {
|
||||
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
|
||||
}
|
||||
|
||||
events, err := runCombatRound(sess, &player, &enemy, action)
|
||||
events, err := p.driveCombatRound(ct, action)
|
||||
if err != nil {
|
||||
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
|
||||
}
|
||||
return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
|
||||
return p.replyCombatRound(ctx, ct, events)
|
||||
}
|
||||
|
||||
// replyCombatRound narrates a resolved round. A solo fight answers the one
|
||||
// player who typed, exactly as it always has. A party fight fans out: every
|
||||
// member gets the play-by-play with the right names on it, and their own footer
|
||||
// or close-out. Terminal side effects run either way — a silent autopilot round
|
||||
// still owes the party its XP and loot.
|
||||
func (p *AdventurePlugin) replyCombatRound(ctx MessageContext, ct *combatTurn, events []CombatEvent) error {
|
||||
if !ct.isParty() {
|
||||
return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, ct.sess, events, ct.players[0].Name, *ct.enemy))
|
||||
}
|
||||
outcomes := p.closePartyRound(ct)
|
||||
if ctx.Silent {
|
||||
return nil
|
||||
}
|
||||
p.announcePartyRound(ct, events, "", outcomes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderRoundResult turns a resolved round into the player-facing block: the
|
||||
@@ -192,24 +206,64 @@ func (p *AdventurePlugin) renderRoundResult(userID id.UserID, sess *CombatSessio
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// runCombatRound resolves one full round: the player's chosen action, then the
|
||||
// enemy turn and the round-end status tick, advancing the session until it is
|
||||
// back at a player_turn or has reached a terminal status. Returns every event
|
||||
// the round produced. Each advanceCombatSession call persists the session, so
|
||||
// runCombatRound resolves one full round of a solo fight: the player's chosen
|
||||
// action, then the enemy turn and the round-end status tick, advancing the
|
||||
// session until it is back at a player_turn or has reached a terminal status.
|
||||
// Returns every event the round produced. Each advance persists the session, so
|
||||
// a crash mid-round resumes cleanly from the last phase.
|
||||
func runCombatRound(sess *CombatSession, player, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
|
||||
events, err := advanceCombatSession(sess, player, enemy, action)
|
||||
return runPartyCombatRound(sess, []*Combatant{player}, enemy, action)
|
||||
}
|
||||
|
||||
// partyRoundStepCap bounds the drain loop below. A round is at most one step per
|
||||
// seat plus the enemy turn and the round-end tick, so a 3-player party settles
|
||||
// in 5; the cap only turns a hypothetical non-advancing phase into a loud error
|
||||
// instead of a hung goroutine holding the fight's lock.
|
||||
const partyRoundStepCap = 64
|
||||
|
||||
// runPartyCombatRound resolves the acting seat's action and then drains every
|
||||
// phase after it that needs no human: the enemy turn, the round-end status tick,
|
||||
// and any seat that is down (which forfeits its turn silently). It comes to rest
|
||||
// on a standing player's turn, or on a terminal status.
|
||||
//
|
||||
// A latched-onto-autopilot seat is *not* drained here — resolving its turn means
|
||||
// running the picker, which needs the plugin to reach the character's spells and
|
||||
// inventory. driveCombatRound layers that on top.
|
||||
//
|
||||
// For a solo roster this is exactly the old loop: a solo player_turn always
|
||||
// belongs to a standing player, since a downed one has already ended the fight.
|
||||
func runPartyCombatRound(sess *CombatSession, players []*Combatant, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
|
||||
events, err := advancePartyCombatSession(sess, players, enemy, action)
|
||||
if err != nil {
|
||||
return events, err
|
||||
}
|
||||
for sess.IsActive() && sess.Phase != CombatPhasePlayerTurn {
|
||||
more, merr := advanceCombatSession(sess, player, enemy, PlayerAction{})
|
||||
more, err := settleCombatSession(sess, players, enemy)
|
||||
return append(events, more...), err
|
||||
}
|
||||
|
||||
// settleCombatSession drains every phase the engine can resolve without a human:
|
||||
// the enemy turn, the round-end status tick, and any seat that is down. It comes
|
||||
// to rest on a standing player's turn, or on a terminal status.
|
||||
//
|
||||
// It is a no-op on a session already parked on a standing player's turn, which
|
||||
// is where every solo fight sits between commands.
|
||||
func settleCombatSession(sess *CombatSession, players []*Combatant, enemy *Combatant) ([]CombatEvent, error) {
|
||||
var events []CombatEvent
|
||||
for i := 0; i < partyRoundStepCap; i++ {
|
||||
if !sess.IsActive() {
|
||||
return events, nil
|
||||
}
|
||||
if seat, waiting := actingSeat(sess, players, enemy); waiting && sess.seatAlive(seat) {
|
||||
return events, nil
|
||||
}
|
||||
more, merr := advancePartyCombatSession(sess, players, enemy, PlayerAction{})
|
||||
if merr != nil {
|
||||
return events, merr
|
||||
}
|
||||
events = append(events, more...)
|
||||
}
|
||||
return events, nil
|
||||
return events, fmt.Errorf("combat session %s: round did not settle within %d steps",
|
||||
sess.SessionID, partyRoundStepCap)
|
||||
}
|
||||
|
||||
// combatTurnPrompt is the "your move" footer shown after every non-terminal
|
||||
@@ -405,65 +459,87 @@ func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefin
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
// "not in a fight anymore" — this handler is only routed to when the caller
|
||||
// already saw an active session, so a miss here means it closed under them.
|
||||
ct, release, msg := p.beginCombatTurn(ctx.Sender, "You're not in a fight anymore.")
|
||||
if ct == nil {
|
||||
return p.replyDM(ctx, msg)
|
||||
}
|
||||
defer release()
|
||||
|
||||
sess, err := getActiveCombatSession(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
|
||||
}
|
||||
if sess == nil {
|
||||
// Race: the fight closed between the route check and the lock.
|
||||
return p.replyDM(ctx, "You're not in a fight anymore.")
|
||||
}
|
||||
|
||||
advChar, _ := loadAdvCharacter(ctx.Sender)
|
||||
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
|
||||
if err != nil || c == nil {
|
||||
return p.replyDM(ctx, "Couldn't load your Adv 2.0 sheet.")
|
||||
}
|
||||
if !isSpellcaster(c) {
|
||||
return p.replyDM(ctx, fmt.Sprintf(
|
||||
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class)))
|
||||
}
|
||||
|
||||
spell, slotLevel, errMsg := parseCombatCast(ctx.Sender, c, strings.TrimSpace(args))
|
||||
action, settle, errMsg := p.castActionForSeat(ct, ct.seat, args)
|
||||
if errMsg != "" {
|
||||
return p.replyDM(ctx, errMsg)
|
||||
}
|
||||
if spell.Effect == EffectReaction {
|
||||
return p.replyDM(ctx, fmt.Sprintf(
|
||||
"%s is a reaction spell — those still aren't usable. Pick a damage, healing, or control spell.", spell.Name))
|
||||
events, err := p.driveCombatRound(ct, action)
|
||||
settle(err == nil)
|
||||
if err != nil {
|
||||
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
|
||||
}
|
||||
return p.replyCombatRound(ctx, ct, events)
|
||||
}
|
||||
|
||||
// castActionForSeat resolves a `!cast` for one seat into a PlayerAction, having
|
||||
// already spent the slot and any material component. It is shared by the command
|
||||
// handler and by the autopilot that plays an absent member's turn, so both spend
|
||||
// resources through exactly one code path.
|
||||
//
|
||||
// The returned settle(ok) must be called once the round has been attempted: on
|
||||
// failure it refunds the slot. On refusal (non-empty msg) nothing was spent.
|
||||
//
|
||||
// A buff spell folds its delta into *this seat's* persisted statuses and rebuilds
|
||||
// the roster so the buff is live for the round's enemy turn. Before P5 that
|
||||
// delta went to the session's embedded copy — seat 0 — so a party member
|
||||
// buffing themselves would have buffed the leader.
|
||||
func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args string) (PlayerAction, func(bool), string) {
|
||||
noop := func(bool) {}
|
||||
uid := id.UserID(ct.sess.seatUserID(seat))
|
||||
|
||||
advChar, _ := loadAdvCharacter(uid)
|
||||
c, err := p.ensureCharForDnDCmd(uid, advChar)
|
||||
if err != nil || c == nil {
|
||||
return PlayerAction{}, noop, "Couldn't load your Adv 2.0 sheet."
|
||||
}
|
||||
if !isSpellcaster(c) {
|
||||
return PlayerAction{}, noop, fmt.Sprintf(
|
||||
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class))
|
||||
}
|
||||
|
||||
player, enemy, err := p.combatantsForSession(sess)
|
||||
if err != nil {
|
||||
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
|
||||
spell, slotLevel, errMsg := parseCombatCast(uid, c, strings.TrimSpace(args))
|
||||
if errMsg != "" {
|
||||
return PlayerAction{}, noop, errMsg
|
||||
}
|
||||
if spell.Effect == EffectReaction {
|
||||
return PlayerAction{}, noop, fmt.Sprintf(
|
||||
"%s is a reaction spell — those still aren't usable. Pick a damage, healing, or control spell.", spell.Name)
|
||||
}
|
||||
|
||||
refund := func(ok bool) {
|
||||
if !ok && spell.Level > 0 {
|
||||
_ = refundSpellSlot(uid, slotLevel)
|
||||
}
|
||||
}
|
||||
|
||||
var eff *turnActionEffect
|
||||
if spell.Effect == EffectBuffSelf || spell.Effect == EffectBuffAlly {
|
||||
// Buff path — resolve the buff against a throwaway combatant, fold the
|
||||
// marginal effect into the session's persisted state, then rebuild the
|
||||
// pair so the buff is live for this round's enemy turn.
|
||||
// marginal effect into that seat's persisted state, then rebuild the
|
||||
// roster so the buff is live for this round's enemy turn.
|
||||
player := ct.players[seat]
|
||||
as, am := player.Stats, player.Mods
|
||||
applySpellBuff(spell, c, &as, &am, slotLevel)
|
||||
d := diffTurnBuff(player.Stats, as, player.Mods, am)
|
||||
if !d.any() {
|
||||
return p.replyDM(ctx, fmt.Sprintf(
|
||||
"%s has no effect the turn-based engine can apply yet.", spell.Name))
|
||||
return PlayerAction{}, noop, fmt.Sprintf(
|
||||
"%s has no effect the turn-based engine can apply yet.", spell.Name)
|
||||
}
|
||||
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
|
||||
return p.replyDM(ctx, msg)
|
||||
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
|
||||
return PlayerAction{}, noop, msg
|
||||
}
|
||||
sess.Statuses.applyBuffDelta(d)
|
||||
player, enemy, err = p.combatantsForSession(sess)
|
||||
if err != nil {
|
||||
if spell.Level > 0 {
|
||||
_ = refundSpellSlot(ctx.Sender, slotLevel)
|
||||
}
|
||||
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
|
||||
ct.sess.actorStatusesPtr(seat).applyBuffDelta(d)
|
||||
if rerr := p.rebuildRoster(ct); rerr != nil {
|
||||
refund(false)
|
||||
return PlayerAction{}, noop, "Couldn't rebuild the fight: " + rerr.Error()
|
||||
}
|
||||
label := spell.Name + " — active"
|
||||
if d.heal > 0 {
|
||||
@@ -474,13 +550,13 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
|
||||
PlayerHeal: d.heal, EnemySkip: d.enemySkip,
|
||||
}
|
||||
} else {
|
||||
out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats)
|
||||
out, supported := resolveTurnSpell(c, spell, slotLevel, &ct.enemy.Stats)
|
||||
if !supported {
|
||||
return p.replyDM(ctx, fmt.Sprintf(
|
||||
"%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name))
|
||||
return PlayerAction{}, noop, fmt.Sprintf(
|
||||
"%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name)
|
||||
}
|
||||
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
|
||||
return p.replyDM(ctx, msg)
|
||||
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
|
||||
return PlayerAction{}, noop, msg
|
||||
}
|
||||
eff = &turnActionEffect{
|
||||
Label: out.Desc,
|
||||
@@ -498,15 +574,18 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
|
||||
eff.ConcentrationDmg = out.EnemyDamage
|
||||
}
|
||||
}
|
||||
return PlayerAction{Kind: ActionCast, Effect: eff}, refund, ""
|
||||
}
|
||||
|
||||
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff})
|
||||
// rebuildRoster re-derives the seated combatants after a mid-fight buff changed
|
||||
// a seat's persisted statuses, so the buff is live for the rest of the round.
|
||||
func (p *AdventurePlugin) rebuildRoster(ct *combatTurn) error {
|
||||
players, enemy, err := p.partyCombatantsForSession(ct.sess)
|
||||
if err != nil {
|
||||
if spell.Level > 0 {
|
||||
_ = refundSpellSlot(ctx.Sender, slotLevel)
|
||||
}
|
||||
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
|
||||
return err
|
||||
}
|
||||
return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
|
||||
ct.players, ct.enemy = players, enemy
|
||||
return nil
|
||||
}
|
||||
|
||||
// chargeSpellCost debits a spell's material component and leveled slot for a
|
||||
@@ -571,21 +650,21 @@ func matchConsumable(inv []ConsumableItem, query string) (*ConsumableItem, strin
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
const notFighting = "`!consume <item>` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically."
|
||||
|
||||
sess, err := getActiveCombatSession(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
|
||||
}
|
||||
if sess == nil {
|
||||
return p.replyDM(ctx, "`!consume <item>` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically.")
|
||||
}
|
||||
|
||||
inv := p.loadConsumableInventory(ctx.Sender)
|
||||
args = strings.TrimSpace(args)
|
||||
if args == "" {
|
||||
// Listing the pack reads no turn state, so it neither takes the fight's
|
||||
// lock nor settles a phase — a player peeking at their options between
|
||||
// rounds must not advance the fight.
|
||||
probe, err := activeCombatSessionFor(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
|
||||
}
|
||||
if probe == nil {
|
||||
return p.replyDM(ctx, notFighting)
|
||||
}
|
||||
inv := p.loadConsumableInventory(ctx.Sender)
|
||||
if len(inv) == 0 {
|
||||
return p.replyDM(ctx, "You have no combat consumables. `!attack` / `!cast` / `!flee`.")
|
||||
}
|
||||
@@ -596,20 +675,45 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
|
||||
return p.replyDM(ctx, "Usage: `!consume <item>`. You're carrying: "+strings.Join(names, ", ")+".")
|
||||
}
|
||||
|
||||
ct, release, msg := p.beginCombatTurn(ctx.Sender, notFighting)
|
||||
if ct == nil {
|
||||
return p.replyDM(ctx, msg)
|
||||
}
|
||||
defer release()
|
||||
|
||||
action, settle, errMsg := p.consumeActionForSeat(ct, ct.seat, args)
|
||||
if errMsg != "" {
|
||||
return p.replyDM(ctx, errMsg)
|
||||
}
|
||||
events, err := p.driveCombatRound(ct, action)
|
||||
settle(err == nil)
|
||||
if err != nil {
|
||||
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
|
||||
}
|
||||
return p.replyCombatRound(ctx, ct, events)
|
||||
}
|
||||
|
||||
// consumeActionForSeat resolves a `!consume` for one seat into a PlayerAction.
|
||||
// Shared by the command handler and by the autopilot that plays an absent
|
||||
// member's turn.
|
||||
//
|
||||
// The returned settle(ok) burns the item only once the round has resolved: a
|
||||
// removal failure leaves the player a free use (logged, rare), which beats
|
||||
// charging them for a round that errored out.
|
||||
func (p *AdventurePlugin) consumeActionForSeat(ct *combatTurn, seat int, args string) (PlayerAction, func(bool), string) {
|
||||
noop := func(bool) {}
|
||||
uid := id.UserID(ct.sess.seatUserID(seat))
|
||||
|
||||
inv := p.loadConsumableInventory(uid)
|
||||
item, ambig := matchConsumable(inv, args)
|
||||
if ambig != "" {
|
||||
return p.replyDM(ctx, ambig)
|
||||
return PlayerAction{}, noop, ambig
|
||||
}
|
||||
if item == nil {
|
||||
return p.replyDM(ctx, fmt.Sprintf("No consumable matching %q in your inventory.", args))
|
||||
return PlayerAction{}, noop, fmt.Sprintf("No consumable matching %q in your inventory.", args)
|
||||
}
|
||||
|
||||
def := item.Def
|
||||
player, enemy, err := p.combatantsForSession(sess)
|
||||
if err != nil {
|
||||
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
|
||||
}
|
||||
|
||||
eff := &turnActionEffect{Action: "use_consumable"}
|
||||
switch def.Effect {
|
||||
case EffectHeal:
|
||||
@@ -620,34 +724,32 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
|
||||
eff.Label = fmt.Sprintf("%s — %d damage", def.Name, eff.EnemyDamage)
|
||||
default:
|
||||
// Buff-type consumable — resolve the marginal effect against a
|
||||
// throwaway combatant, fold it into the session's persisted state, and
|
||||
// rebuild the pair so the buff is live for this round's enemy turn.
|
||||
// throwaway combatant, fold it into that seat's persisted state, and
|
||||
// rebuild the roster so the buff is live for this round's enemy turn.
|
||||
player := ct.players[seat]
|
||||
as, am := player.Stats, player.Mods
|
||||
ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}})
|
||||
d := diffTurnBuff(player.Stats, as, player.Mods, am)
|
||||
if !d.any() {
|
||||
return p.replyDM(ctx, fmt.Sprintf(
|
||||
"**%s** has no effect the turn-based engine can apply yet.", def.Name))
|
||||
return PlayerAction{}, noop, fmt.Sprintf(
|
||||
"**%s** has no effect the turn-based engine can apply yet.", def.Name)
|
||||
}
|
||||
sess.Statuses.applyBuffDelta(d)
|
||||
player, enemy, err = p.combatantsForSession(sess)
|
||||
if err != nil {
|
||||
return p.replyDM(ctx, "Couldn't rebuild the fight: "+err.Error())
|
||||
ct.sess.actorStatusesPtr(seat).applyBuffDelta(d)
|
||||
if rerr := p.rebuildRoster(ct); rerr != nil {
|
||||
return PlayerAction{}, noop, "Couldn't rebuild the fight: " + rerr.Error()
|
||||
}
|
||||
eff.Label = def.Name + " — active"
|
||||
eff.PlayerHeal = d.heal
|
||||
eff.EnemySkip = d.enemySkip
|
||||
}
|
||||
|
||||
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionConsume, Effect: eff})
|
||||
if err != nil {
|
||||
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
|
||||
burn := func(ok bool) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if rerr := removeAdvInventoryItem(item.InventoryID); rerr != nil {
|
||||
slog.Error("combat: consume remove inventory item failed", "user", uid, "item", def.Name, "err", rerr)
|
||||
}
|
||||
}
|
||||
// Round resolved and persisted — now burn the item. A removal failure here
|
||||
// leaves the player a free use (logged, rare); better than charging them
|
||||
// for a round that errored out.
|
||||
if rerr := removeAdvInventoryItem(item.InventoryID); rerr != nil {
|
||||
slog.Error("combat: consume remove inventory item failed", "user", ctx.Sender, "item", def.Name, "err", rerr)
|
||||
}
|
||||
return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
|
||||
return PlayerAction{Kind: ActionConsume, Effect: eff}, burn, ""
|
||||
}
|
||||
|
||||
@@ -211,6 +211,15 @@ type CombatEvent struct {
|
||||
// Roll is the raw d20 (1..20); RollAgainst is the target AC.
|
||||
Roll int
|
||||
RollAgainst int
|
||||
// Seat is the roster index of the character this event is about: the one
|
||||
// who swung, whose pet struck, who the enemy hit, whose poison ticked. The
|
||||
// turn engine stamps it (turnEngine.stampSeat); the auto-resolve engine
|
||||
// leaves it 0, which is correct for its one and only combatant.
|
||||
//
|
||||
// It exists so a party's play-by-play can name the right person. Solo events
|
||||
// are all seat 0, and the omitempty tag keeps the field out of every solo
|
||||
// turn_log_json — rows written before N3/P5 decode unchanged.
|
||||
Seat int `json:"Seat,omitempty"`
|
||||
}
|
||||
|
||||
type CombatResult struct {
|
||||
@@ -361,6 +370,10 @@ type actor struct {
|
||||
type combatState struct {
|
||||
*actor // cursor into actors; promotes the per-actor fields
|
||||
actors []*actor // the player roster, in seating order. len == 1 for solo.
|
||||
// seatIdx is the roster index the cursor points at. Kept in step with the
|
||||
// embedded *actor by seat(); read only by the turn engine, to attribute the
|
||||
// events a phase emitted to the character they happened to.
|
||||
seatIdx int
|
||||
|
||||
enemyHP int
|
||||
|
||||
@@ -442,7 +455,9 @@ func newActor(c *Combatant) *actor {
|
||||
// seat points the cursor at roster index i. Every per-actor read in the
|
||||
// resolution primitives (st.playerHP, st.wardCharges, …) follows the cursor.
|
||||
// Solo combat seats index 0 once and never moves it.
|
||||
func (st *combatState) seat(i int) { st.actor = st.actors[i] }
|
||||
// seat moves the cursor to a roster index. seatIdx trails it so the turn engine
|
||||
// can tag the events a phase emits with the character they are about.
|
||||
func (st *combatState) seat(i int) { st.actor, st.seatIdx = st.actors[i], i }
|
||||
|
||||
// anyAlive reports whether at least one seated character is still standing.
|
||||
// Solo fights read this as "the player is alive".
|
||||
|
||||
@@ -874,16 +874,46 @@ var narrativeCloseLoss = []string{
|
||||
// handful of events, and across a long boss fight the picker resetting each
|
||||
// round reads as variety rather than staleness.
|
||||
func RenderTurnRound(events []CombatEvent, playerName, enemyName string) string {
|
||||
return RenderPartyTurnRound(events, []string{playerName}, enemyName, 0)
|
||||
}
|
||||
|
||||
// RenderPartyTurnRound renders a round for one member of a party — the reader at
|
||||
// viewerSeat.
|
||||
//
|
||||
// The turn narration pool is written in the second person: "You score 9 damage",
|
||||
// "A hit gets through your guard". That is exactly right for the reader's own
|
||||
// events and exactly wrong for everybody else's, so this splits on the event's
|
||||
// Seat. The reader's own events go through the same flavor pool a solo fight
|
||||
// uses, untouched; an ally's are summarised third-person by renderAllySeatEvent.
|
||||
//
|
||||
// A solo fight has one seat, every event belongs to it, and the reader is it —
|
||||
// so it renders the same bytes it always has.
|
||||
func RenderPartyTurnRound(events []CombatEvent, seatNames []string, enemyName string, viewerSeat int) string {
|
||||
if len(seatNames) == 0 {
|
||||
seatNames = []string{"You"}
|
||||
}
|
||||
seatName := func(seat int) string {
|
||||
if seat > 0 && seat < len(seatNames) {
|
||||
return seatNames[seat]
|
||||
}
|
||||
return seatNames[0]
|
||||
}
|
||||
|
||||
picker := newActionPicker()
|
||||
var lines []string
|
||||
for _, e := range events {
|
||||
line := renderTurnEvent(e, playerName, enemyName, picker)
|
||||
var line string
|
||||
if e.Seat == viewerSeat {
|
||||
line = renderTurnEvent(e, seatName(e.Seat), enemyName, picker)
|
||||
if roll := rollAnnotation(e); line != "" && roll != "" {
|
||||
line += " " + roll
|
||||
}
|
||||
} else {
|
||||
line = renderAllySeatEvent(e, seatName(e.Seat), enemyName)
|
||||
}
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if roll := rollAnnotation(e); roll != "" {
|
||||
line += " " + roll
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
@@ -892,6 +922,80 @@ func RenderTurnRound(events []CombatEvent, playerName, enemyName string) string
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderAllySeatEvent summarises one event belonging to somebody else's
|
||||
// character, for a party member's DM. Terse on purpose: the reader wants the
|
||||
// shape of the round, not three sentences of flavor about their friend's pet.
|
||||
// Anything not worth a line in someone else's DM renders as "".
|
||||
func renderAllySeatEvent(e CombatEvent, name, enemyName string) string {
|
||||
who := "**" + name + "**"
|
||||
down := func(line string) string {
|
||||
if e.PlayerHP <= 0 {
|
||||
return line + " " + who + " is down."
|
||||
}
|
||||
return line
|
||||
}
|
||||
switch e.Action {
|
||||
case "hit":
|
||||
if e.Actor == "player" {
|
||||
return fmt.Sprintf("%s hits %s for %d.", who, enemyName, e.Damage)
|
||||
}
|
||||
return down(fmt.Sprintf("%s hits %s for %d.", enemyName, who, e.Damage))
|
||||
case "crit":
|
||||
if e.Actor == "player" {
|
||||
return fmt.Sprintf("%s crits %s for %d!", who, enemyName, e.Damage)
|
||||
}
|
||||
return down(fmt.Sprintf("%s crits %s for %d!", enemyName, who, e.Damage))
|
||||
case "miss":
|
||||
if e.Actor == "player" {
|
||||
return fmt.Sprintf("%s misses.", who)
|
||||
}
|
||||
return fmt.Sprintf("%s misses %s.", enemyName, who)
|
||||
case "block":
|
||||
// renderEvent's convention: Actor "player" means the *enemy* blocked the
|
||||
// player's swing, and vice versa.
|
||||
if e.Actor == "player" {
|
||||
return fmt.Sprintf("%s blocks %s (%d).", enemyName, who, e.Damage)
|
||||
}
|
||||
return fmt.Sprintf("%s blocks (%d).", who, e.Damage)
|
||||
case "spell_cast":
|
||||
label := e.Desc
|
||||
if label == "" {
|
||||
label = "a spell"
|
||||
}
|
||||
return fmt.Sprintf("%s casts %s.", who, label)
|
||||
case "use_consumable":
|
||||
label := e.Desc
|
||||
if label == "" {
|
||||
label = "an item"
|
||||
}
|
||||
return fmt.Sprintf("%s uses %s.", who, label)
|
||||
case "pet_attack":
|
||||
return fmt.Sprintf("🐾 %s's pet strikes for %d.", who, e.Damage)
|
||||
case "spirit_weapon_strike":
|
||||
return fmt.Sprintf("%s's spirit weapon strikes for %d.", who, e.Damage)
|
||||
case "concentration_tick":
|
||||
return fmt.Sprintf("%s's aura burns %s for %d.", who, enemyName, e.Damage)
|
||||
case "poison_tick":
|
||||
return down(fmt.Sprintf("☠️ %s takes %d from poison.", who, e.Damage))
|
||||
case "environmental":
|
||||
return down(fmt.Sprintf("%s takes %d from the room.", who, e.Damage))
|
||||
case "flat_damage":
|
||||
return fmt.Sprintf("%s deals %d.", who, e.Damage)
|
||||
case "heal_item", "misty_heal":
|
||||
return fmt.Sprintf("%s recovers %d.", who, e.Damage)
|
||||
case "death_save":
|
||||
return fmt.Sprintf("%s clings on.", who)
|
||||
case "stun", "stunned":
|
||||
return fmt.Sprintf("%s is stunned.", who)
|
||||
case "flee":
|
||||
return fmt.Sprintf("%s breaks off.", who)
|
||||
default:
|
||||
// Ward absorbs, spore misses, reflects, enrage cues: real, but noise in
|
||||
// somebody else's DM. The owner sees them in full in their own.
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func renderTurnEvent(e CombatEvent, playerName, enemyName string, picker *actionPicker) string {
|
||||
switch e.Action {
|
||||
case "flee":
|
||||
|
||||
183
internal/plugin/combat_party_finish.go
Normal file
183
internal/plugin/combat_party_finish.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// N3/P5 — closing out a party fight.
|
||||
//
|
||||
// finishCombatSession (combat_cmd.go) is the solo close-out and stays exactly
|
||||
// what it was. This is its sibling for a seated roster, and the split it makes
|
||||
// is the one the data model already forced:
|
||||
//
|
||||
// - Run- and expedition-scoped effects fire ONCE, for the owner. Threat, the
|
||||
// zone-kill record, the boss-defeat threat drop, the run teardown on a loss
|
||||
// — every one of them resolves through getActiveExpedition(userID) or
|
||||
// getActiveZoneRun(userID), and a party member owns neither row. Fanning
|
||||
// them out would be a no-op for members and would triple the threat the
|
||||
// owner pays for a single kill.
|
||||
// - Character-scoped effects fan out. HP, XP, loot, and death belong to the
|
||||
// person, not the expedition, and every seat gets their own.
|
||||
//
|
||||
// A member can be dead in a fight the party won: they dropped, the others
|
||||
// finished the job. So death is decided per seat off HP, not off the session's
|
||||
// terminal status.
|
||||
|
||||
// finishPartyCombatSession runs the terminal side effects for a seated roster
|
||||
// and returns each seat's own player-facing close-out, indexed by seat. A solo
|
||||
// session delegates to finishCombatSession untouched.
|
||||
func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
|
||||
sess, enemy := ct.sess, *ct.enemy
|
||||
owner := id.UserID(sess.UserID)
|
||||
if !ct.isParty() {
|
||||
return []string{p.finishCombatSession(owner, sess, enemy)}
|
||||
}
|
||||
|
||||
run, _ := getZoneRun(sess.RunID)
|
||||
var zone ZoneDefinition
|
||||
elite := true
|
||||
cadence := sess.Round
|
||||
if run != nil {
|
||||
zone = zoneOrFallback(run.ZoneID)
|
||||
elite = run.CurrentRoomType() != RoomBoss
|
||||
cadence = narrationCadence(run)
|
||||
}
|
||||
monster := dndBestiary[sess.EnemyID]
|
||||
|
||||
// nat20/nat1 mood deltas from the whole fight's event log — the run's, so once.
|
||||
scanMoodEventsFromEvents(sess.RunID, sess.TurnLog)
|
||||
|
||||
// Every seat's HP lands on their sheet regardless of how the fight ended.
|
||||
for seat := range sess.RosterSize() {
|
||||
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
|
||||
}
|
||||
|
||||
switch sess.Status {
|
||||
case CombatStatusWon:
|
||||
return p.finishPartyWin(ct, run, zone, monster, elite, cadence)
|
||||
case CombatStatusLost:
|
||||
return p.finishPartyLoss(ct, zone, cadence)
|
||||
case CombatStatusFled:
|
||||
_ = abandonZoneRun(owner)
|
||||
forceExtractExpeditionForRunLoss(owner, "combat flee")
|
||||
return p.eachSeat(ct, fmt.Sprintf(
|
||||
"🏃 The party broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name))
|
||||
default:
|
||||
return p.eachSeat(ct, "The fight is over.")
|
||||
}
|
||||
}
|
||||
|
||||
// finishPartyWin pays the party out. The kill is the expedition's, so threat and
|
||||
// the zone-kill record resolve once through the owner; XP and loot are the
|
||||
// character's, so every member rolls their own — loot independently, per the C1
|
||||
// no-split rule, and XP in full, per accessibility over crunch.
|
||||
func (p *AdventurePlugin) finishPartyWin(
|
||||
ct *combatTurn, run *DungeonRun, zone ZoneDefinition, monster DnDMonsterTemplate, elite bool, cadence int,
|
||||
) []string {
|
||||
sess := ct.sess
|
||||
owner := id.UserID(sess.UserID)
|
||||
|
||||
recordZoneKillForUser(owner, sess.EnemyID)
|
||||
applyRoomCombatThreatForUser(owner, elite)
|
||||
|
||||
bossOnExpedition := false
|
||||
if !elite {
|
||||
if exp, eerr := getActiveExpedition(owner); eerr == nil && exp != nil {
|
||||
_ = applyBossDefeatThreat(exp.ID)
|
||||
bossOnExpedition = true
|
||||
}
|
||||
}
|
||||
|
||||
tier := 1
|
||||
if run != nil {
|
||||
tier = int(zone.Tier)
|
||||
}
|
||||
shared := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence)
|
||||
emoji := "✅"
|
||||
if !elite {
|
||||
emoji = "🏆"
|
||||
}
|
||||
|
||||
out := make([]string, sess.RosterSize())
|
||||
for seat := range sess.RosterSize() {
|
||||
uid := id.UserID(sess.seatUserID(seat))
|
||||
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
|
||||
|
||||
// A member who went down before the killing blow still earns the kill —
|
||||
// but at a fifth of their pool or less, the XP path calls it near-death.
|
||||
nearDeath := hpMax > 0 && hp*5 < hpMax
|
||||
if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 {
|
||||
if _, err := p.grantDnDXP(uid, xp); err != nil {
|
||||
slog.Error("combat: party grantDnDXP", "user", uid, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
if shared != "" && seat == 0 {
|
||||
b.WriteString(shared + "\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s **%s** down. The party stands.\n", emoji, ct.enemy.Name))
|
||||
if hp <= 0 {
|
||||
// They won it from the floor. Down is down: the hospital takes them.
|
||||
markAdventureDead(uid, "zone", zone.Display)
|
||||
b.WriteString("💀 You didn't see it fall — you were already down.\n")
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("You finished at **%d/%d HP**.\n", hp, hpMax))
|
||||
if drop := p.dropZoneLoot(uid, zone.ID, monster, !elite, elite); drop != "" {
|
||||
b.WriteString(drop + "\n")
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case bossOnExpedition && seat == 0:
|
||||
b.WriteString("🎉 **Zone cleared — the expedition is won.** `!expedition run` to march out and claim your spoils.")
|
||||
case bossOnExpedition:
|
||||
b.WriteString("🎉 **Zone cleared — the expedition is won.** Your leader marches the party out.")
|
||||
case seat == 0:
|
||||
b.WriteString(continueHint(owner))
|
||||
default:
|
||||
b.WriteString("Waiting on your leader to move the party on.")
|
||||
}
|
||||
out[seat] = b.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// finishPartyLoss ends the run for everyone. The whole roster is down — the turn
|
||||
// engine only reports Lost once anyAlive() goes false — so every member takes
|
||||
// the death, and the owner's run and expedition are torn down once.
|
||||
func (p *AdventurePlugin) finishPartyLoss(ct *combatTurn, zone ZoneDefinition, cadence int) []string {
|
||||
sess := ct.sess
|
||||
owner := id.UserID(sess.UserID)
|
||||
|
||||
if run, _ := getZoneRun(sess.RunID); run != nil {
|
||||
_, _ = applyMoodEvent(sess.RunID, MoodEventPlayerDeath)
|
||||
}
|
||||
_ = abandonZoneRun(owner)
|
||||
forceExtractExpeditionForRunLoss(owner, "combat death")
|
||||
|
||||
line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence)
|
||||
out := make([]string, sess.RosterSize())
|
||||
for seat := range sess.RosterSize() {
|
||||
markAdventureDead(id.UserID(sess.seatUserID(seat)), "zone", zone.Display)
|
||||
var b strings.Builder
|
||||
if line != "" && seat == 0 {
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("💀 The party fell to **%s**. Run ended.", ct.enemy.Name))
|
||||
out[seat] = b.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// eachSeat gives every seat the same close-out block.
|
||||
func (p *AdventurePlugin) eachSeat(ct *combatTurn, block string) []string {
|
||||
out := make([]string, ct.sess.RosterSize())
|
||||
for i := range out {
|
||||
out[i] = block
|
||||
}
|
||||
return out
|
||||
}
|
||||
413
internal/plugin/combat_party_turn.go
Normal file
413
internal/plugin/combat_party_turn.go
Normal file
@@ -0,0 +1,413 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// N3/P5 — the party turn layer.
|
||||
//
|
||||
// A solo fight is a conversation between one player and the engine: they type,
|
||||
// it answers, and nothing happens in between. A party fight is a queue. Three
|
||||
// things follow from that, and this file is all three:
|
||||
//
|
||||
// 1. Turn ownership. Only the seat on the clock may act, so every combat
|
||||
// command has to ask "is it mine?" before it spends a slot or burns an item.
|
||||
// 2. A fight-scoped lock. Three members typing `!attack` at once take three
|
||||
// *different* user locks, and the check above would pass for all three.
|
||||
// 3. A turn deadline. One member who wanders off must not freeze the other two
|
||||
// until the 1h session reaper wakes up.
|
||||
//
|
||||
// Solo pays for none of it: the fight lock collapses to the user lock it always
|
||||
// took, the turn check is trivially true, and nothing latches a solo seat onto
|
||||
// autopilot — a lone player who walks away is the session reaper's problem, as
|
||||
// they have always been.
|
||||
|
||||
// partyTurnDeadline is how long a party fight waits on one member before
|
||||
// resolving their turn for them. Swept by the existing one-minute eventTicker
|
||||
// (D4: no net-new tickers), so a lapse actually fires somewhere in
|
||||
// [deadline, deadline+1m) — 3–4 minutes here.
|
||||
//
|
||||
// The number is a compromise between two failure modes. Too short and a player
|
||||
// reading the room on their phone loses their boss turn; too long and two people
|
||||
// sit staring at a prompt. Expeditions run for days, so the asymmetry favours
|
||||
// patience.
|
||||
const partyTurnDeadline = 3 * time.Minute
|
||||
|
||||
// combatTurn is a fight, opened for one member, with that member verified to be
|
||||
// the seat on the clock. Held only for the duration of one command, under the
|
||||
// locks release() drops.
|
||||
type combatTurn struct {
|
||||
sess *CombatSession
|
||||
players []*Combatant
|
||||
enemy *Combatant
|
||||
seat int
|
||||
// uid is the member acting, i.e. the player at seat.
|
||||
uid id.UserID
|
||||
}
|
||||
|
||||
// isParty reports whether more than one character is seated.
|
||||
func (ct *combatTurn) isParty() bool { return ct.sess.IsParty() }
|
||||
|
||||
// seatNames is the roster's display names in seating order, for narration.
|
||||
func (ct *combatTurn) seatNames() []string {
|
||||
names := make([]string, len(ct.players))
|
||||
for i, c := range ct.players {
|
||||
names[i] = c.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// activeCombatSessionFor finds the in-flight fight a player is in, whether they
|
||||
// are its owner (seat 0) or a seated member. getActiveCombatSession alone keys
|
||||
// on combat_session.user_id and so tells a party member they are not fighting.
|
||||
func activeCombatSessionFor(userID id.UserID) (*CombatSession, error) {
|
||||
if s, err := getActiveCombatSession(userID); err != nil || s != nil {
|
||||
return s, err
|
||||
}
|
||||
return getActiveCombatSessionForMember(userID)
|
||||
}
|
||||
|
||||
// lockCombatFight takes the locks a combat command needs, in an order that
|
||||
// cannot deadlock: the fight's lock first — keyed on seat 0, the session's owner
|
||||
// — and then the acting member's own lock. Every other command in the codebase
|
||||
// takes at most one user lock, so there is no cycle to close.
|
||||
//
|
||||
// A solo fight's owner *is* the sender, and sync.Mutex is not reentrant, so that
|
||||
// case takes exactly one lock: the same one handleAttackCmd has always taken.
|
||||
func (p *AdventurePlugin) lockCombatFight(owner, sender id.UserID) func() {
|
||||
fight := p.advUserLock(owner)
|
||||
fight.Lock()
|
||||
if owner == sender {
|
||||
return fight.Unlock
|
||||
}
|
||||
self := p.advUserLock(sender)
|
||||
self.Lock()
|
||||
return func() {
|
||||
self.Unlock()
|
||||
fight.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// beginCombatTurn opens the sender's fight for an action: it locates the
|
||||
// session, takes its locks, settles any phase the engine still owes (an enemy
|
||||
// turn left half-resolved by a crash), rebuilds the roster, and verifies the
|
||||
// sender is the seat on the clock.
|
||||
//
|
||||
// On any refusal it releases what it took and returns a player-facing message
|
||||
// with a nil turn; noFightMsg is the caller's own copy for "you are not in a
|
||||
// fight", which differs per command. On success the caller must call release().
|
||||
//
|
||||
// Acting also unlatches the member from autopilot: typing anything is proof they
|
||||
// are back at the keyboard.
|
||||
func (p *AdventurePlugin) beginCombatTurn(sender id.UserID, noFightMsg string) (*combatTurn, func(), string) {
|
||||
probe, err := activeCombatSessionFor(sender)
|
||||
if err != nil {
|
||||
return nil, nil, "Couldn't read combat state: " + err.Error()
|
||||
}
|
||||
if probe == nil {
|
||||
return nil, nil, noFightMsg
|
||||
}
|
||||
|
||||
release := p.lockCombatFight(id.UserID(probe.UserID), sender)
|
||||
fail := func(msg string) (*combatTurn, func(), string) {
|
||||
release()
|
||||
return nil, nil, msg
|
||||
}
|
||||
|
||||
// Re-read under the lock. The probe was unlocked, so the fight may have
|
||||
// ended, or been replaced by a fresh one, in the window since.
|
||||
sess, err := getCombatSession(probe.SessionID)
|
||||
if err != nil {
|
||||
return fail("Couldn't read combat state: " + err.Error())
|
||||
}
|
||||
if sess == nil || !sess.IsActive() {
|
||||
return fail(noFightMsg)
|
||||
}
|
||||
seat, seated := sess.seatOf(sender)
|
||||
if !seated {
|
||||
return fail(noFightMsg)
|
||||
}
|
||||
|
||||
players, enemy, err := p.partyCombatantsForSession(sess)
|
||||
if err != nil {
|
||||
return fail("Couldn't rebuild the fight: " + err.Error())
|
||||
}
|
||||
|
||||
// Settle any phase the engine still owes before reading whose turn it is. A
|
||||
// fight interrupted mid enemy-turn resumes parked there; without this the
|
||||
// player's !attack would be refused as "not your turn" and the fight would
|
||||
// never advance past it again.
|
||||
if _, serr := settleCombatSession(sess, players, enemy); serr != nil {
|
||||
return fail("Couldn't resolve the round: " + serr.Error())
|
||||
}
|
||||
if !sess.IsActive() {
|
||||
return fail(noFightMsg)
|
||||
}
|
||||
|
||||
acting, waiting := actingSeat(sess, players, enemy)
|
||||
if !waiting || acting != seat {
|
||||
return fail(notYourTurnMsg(players, acting, waiting))
|
||||
}
|
||||
|
||||
// They typed, so they are here. Hand back the wheel.
|
||||
sess.actorStatusesPtr(seat).Autopilot = false
|
||||
|
||||
return &combatTurn{sess: sess, players: players, enemy: enemy, seat: seat, uid: sender}, release, ""
|
||||
}
|
||||
|
||||
// notYourTurnMsg tells a member who is holding the round up.
|
||||
func notYourTurnMsg(players []*Combatant, acting int, waiting bool) string {
|
||||
if !waiting || acting < 0 || acting >= len(players) {
|
||||
return "The round is still resolving — try again in a moment."
|
||||
}
|
||||
return fmt.Sprintf("It's **%s**'s turn. Hang tight — I'll act for them if they're away.", players[acting].Name)
|
||||
}
|
||||
|
||||
// ── driving a round ──────────────────────────────────────────────────────────
|
||||
|
||||
// driveCombatRound resolves the acting member's action, then keeps the fight
|
||||
// moving until it comes to rest on a player who can actually answer: the enemy
|
||||
// turn and round-end tick resolve, downed seats forfeit, and seats latched onto
|
||||
// autopilot are played by the picker.
|
||||
//
|
||||
// Solo never enters the autopilot loop — nothing latches a solo seat.
|
||||
func (p *AdventurePlugin) driveCombatRound(ct *combatTurn, action PlayerAction) ([]CombatEvent, error) {
|
||||
events, err := runPartyCombatRound(ct.sess, ct.players, ct.enemy, action)
|
||||
if err != nil {
|
||||
return events, err
|
||||
}
|
||||
more, err := p.driveAutopilotedSeats(ct)
|
||||
return append(events, more...), err
|
||||
}
|
||||
|
||||
// driveAutopilotedSeats plays out every latched seat standing between the fight
|
||||
// and its next live human turn.
|
||||
func (p *AdventurePlugin) driveAutopilotedSeats(ct *combatTurn) ([]CombatEvent, error) {
|
||||
var events []CombatEvent
|
||||
for i := 0; i < partyRoundStepCap; i++ {
|
||||
seat, waiting := actingSeat(ct.sess, ct.players, ct.enemy)
|
||||
if !waiting || !ct.sess.seatIsAutopiloted(seat) {
|
||||
return events, nil
|
||||
}
|
||||
more, err := p.runAutoSeatTurn(ct, seat)
|
||||
if err != nil {
|
||||
return events, err
|
||||
}
|
||||
events = append(events, more...)
|
||||
}
|
||||
return events, fmt.Errorf("combat session %s: autopilot did not settle within %d turns",
|
||||
ct.sess.SessionID, partyRoundStepCap)
|
||||
}
|
||||
|
||||
// runAutoSeatTurn resolves one seat's turn with nobody at the keyboard, through
|
||||
// the same decision tree the headless sim and the expedition autopilot use. A
|
||||
// cast or consume the picker chose but the resolver then refuses (the slot went
|
||||
// missing, the item was sold from another room) degrades to a weapon attack
|
||||
// rather than stalling the round.
|
||||
func (p *AdventurePlugin) runAutoSeatTurn(ct *combatTurn, seat int) ([]CombatEvent, error) {
|
||||
uid := id.UserID(ct.sess.seatUserID(seat))
|
||||
kind, arg := p.pickAutoCombatActionForSeat(uid, ct.sess, seat)
|
||||
|
||||
action := PlayerAction{Kind: ActionAttack}
|
||||
settle := func(bool) {}
|
||||
switch kind {
|
||||
case "cast":
|
||||
if a, s, msg := p.castActionForSeat(ct, seat, arg); msg == "" {
|
||||
action, settle = a, s
|
||||
} else {
|
||||
slog.Debug("combat: autopilot cast refused, swinging instead",
|
||||
"session", ct.sess.SessionID, "seat", seat, "spell", arg, "why", msg)
|
||||
}
|
||||
case "consume":
|
||||
if a, s, msg := p.consumeActionForSeat(ct, seat, arg); msg == "" {
|
||||
action, settle = a, s
|
||||
} else {
|
||||
slog.Debug("combat: autopilot consume refused, swinging instead",
|
||||
"session", ct.sess.SessionID, "seat", seat, "item", arg, "why", msg)
|
||||
}
|
||||
}
|
||||
|
||||
events, err := runPartyCombatRound(ct.sess, ct.players, ct.enemy, action)
|
||||
settle(err == nil)
|
||||
return events, err
|
||||
}
|
||||
|
||||
// ── the turn deadline ────────────────────────────────────────────────────────
|
||||
|
||||
// nudgeStalledPartyTurns latches every party seat whose turn deadline has lapsed
|
||||
// onto autopilot and plays the fight forward. Driven off eventTicker, beside the
|
||||
// session reaper, so it costs no new ticker.
|
||||
//
|
||||
// Solo sessions are never listed: a lone player who walks away owns their own
|
||||
// fight, and the 1h reaper already finishes it for them.
|
||||
func (p *AdventurePlugin) nudgeStalledPartyTurns() {
|
||||
stalled, err := listStalledPartyCombatSessions()
|
||||
if err != nil {
|
||||
slog.Error("combat: failed to list stalled party turns", "err", err)
|
||||
return
|
||||
}
|
||||
for _, sess := range stalled {
|
||||
p.nudgeStalledPartyTurn(sess.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// nudgeStalledPartyTurn resolves one stalled fight under its lock. It re-reads
|
||||
// the session after locking: the member may have acted in the window between the
|
||||
// sweep's query and the lock, in which case there is nothing to do.
|
||||
func (p *AdventurePlugin) nudgeStalledPartyTurn(sessionID string) {
|
||||
probe, err := getCombatSession(sessionID)
|
||||
if err != nil || probe == nil {
|
||||
return
|
||||
}
|
||||
owner := id.UserID(probe.UserID)
|
||||
release := p.lockCombatFight(owner, owner)
|
||||
defer release()
|
||||
|
||||
sess, err := getCombatSession(sessionID)
|
||||
if err != nil {
|
||||
slog.Error("combat: stalled-turn reload failed", "session", sessionID, "err", err)
|
||||
return
|
||||
}
|
||||
if sess == nil || !sess.IsActive() || !sess.IsParty() || !turnDeadlineLapsed(sess) {
|
||||
return
|
||||
}
|
||||
|
||||
players, enemy, err := p.partyCombatantsForSession(sess)
|
||||
if err != nil {
|
||||
slog.Warn("combat: cannot rebuild stalled party fight", "session", sessionID, "err", err)
|
||||
return
|
||||
}
|
||||
seat, waiting := actingSeat(sess, players, enemy)
|
||||
if !waiting {
|
||||
return
|
||||
}
|
||||
|
||||
// Latch the absent member. From here their turns resolve the moment the
|
||||
// round reaches them, until they type something.
|
||||
sess.actorStatusesPtr(seat).Autopilot = true
|
||||
away := id.UserID(sess.seatUserID(seat))
|
||||
|
||||
ct := &combatTurn{sess: sess, players: players, enemy: enemy, seat: seat, uid: away}
|
||||
events, err := p.driveAutopilotedSeats(ct)
|
||||
if err != nil {
|
||||
slog.Error("combat: stalled-turn autopilot failed", "session", sessionID, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
preamble := fmt.Sprintf("⏳ **%s** was away — I'm taking their turns.\n\n", players[seat].Name)
|
||||
p.announcePartyRound(ct, events, preamble, p.closePartyRound(ct))
|
||||
}
|
||||
|
||||
// turnDeadlineLapsed reports whether the fight has sat on one member's turn past
|
||||
// partyTurnDeadline. LastActionAt is stamped by every saveCombatSession, and the
|
||||
// save that parked the fight on this seat's player_turn was the last one — so it
|
||||
// is exactly when the member's clock started.
|
||||
func turnDeadlineLapsed(sess *CombatSession) bool {
|
||||
return sess.Phase == CombatPhasePlayerTurn &&
|
||||
time.Since(sess.LastActionAt) >= partyTurnDeadline
|
||||
}
|
||||
|
||||
// listStalledPartyCombatSessions returns every active *party* fight parked on a
|
||||
// player's turn past the deadline. The roster_size filter keeps solo fights —
|
||||
// which is every fight that has ever run — out of the sweep entirely.
|
||||
func listStalledPartyCombatSessions() ([]*CombatSession, error) {
|
||||
cutoff := time.Now().UTC().Add(-partyTurnDeadline)
|
||||
rows, err := db.Get().Query(`SELECT `+combatSessionCols+`
|
||||
FROM combat_session
|
||||
WHERE status = 'active'
|
||||
AND roster_size > 1
|
||||
AND phase = 'player_turn'
|
||||
AND last_action_at <= ?
|
||||
ORDER BY last_action_at ASC`, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*CombatSession
|
||||
for rows.Next() {
|
||||
s, err := scanCombatSession(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Hydrate only after the cursor is closed: a nested query while iterating
|
||||
// can stall on a single-connection pool.
|
||||
rows.Close()
|
||||
for _, s := range out {
|
||||
if err := hydrateCombatParticipants(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── telling the party what happened ──────────────────────────────────────────
|
||||
|
||||
// closePartyRound runs the terminal side effects exactly once and returns each
|
||||
// seat's own close-out block. It returns nil while the fight is still in flight.
|
||||
//
|
||||
// Callers must invoke it whether or not they intend to narrate: a silent
|
||||
// autopilot round still owes the party its XP, loot, and death bookkeeping.
|
||||
func (p *AdventurePlugin) closePartyRound(ct *combatTurn) []string {
|
||||
if ct.sess.IsActive() {
|
||||
return nil
|
||||
}
|
||||
return p.finishPartyCombatSession(ct)
|
||||
}
|
||||
|
||||
// announcePartyRound DMs every seated member the round that just resolved. It is
|
||||
// the fan-out the single-recipient SendDM seam does not have: one call per seat,
|
||||
// with each member's own footer — or their own close-out, if outcomes is set.
|
||||
//
|
||||
// Solo callers do not use it — handleCombatActionCmd replies to the one player
|
||||
// directly, as it always has.
|
||||
func (p *AdventurePlugin) announcePartyRound(ct *combatTurn, events []CombatEvent, preamble string, outcomes []string) {
|
||||
names := ct.seatNames()
|
||||
acting, waiting := actingSeat(ct.sess, ct.players, ct.enemy)
|
||||
for seat, uid := range ct.sess.SeatUserIDs() {
|
||||
// Rendered once per reader: the flavor pool speaks in the second person,
|
||||
// so each member's own events must be theirs and nobody else's.
|
||||
body := RenderPartyTurnRound(events, names, ct.enemy.Name, seat)
|
||||
tail := ""
|
||||
switch {
|
||||
case seat < len(outcomes):
|
||||
tail = outcomes[seat]
|
||||
case waiting:
|
||||
tail = partyRoundFooter(ct, seat, acting)
|
||||
}
|
||||
if err := p.SendDM(id.UserID(uid), preamble+body+"\n\n"+tail); err != nil {
|
||||
slog.Error("combat: party round DM failed", "user", uid, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// partyRoundFooter is the per-member close of a round: the roster's HP, then
|
||||
// either "your move" or who everyone is waiting on.
|
||||
func partyRoundFooter(ct *combatTurn, seat, acting int) string {
|
||||
var b strings.Builder
|
||||
for i, c := range ct.players {
|
||||
down := ""
|
||||
if !ct.sess.seatAlive(i) {
|
||||
down = " _(down)_"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s: **%d/%d**%s\n", c.Name, ct.sess.seatHP(i), ct.sess.seatHPMax(i), down))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s: **%d/%d**\n\n", ct.enemy.Name, ct.sess.EnemyHP, ct.sess.EnemyHPMax))
|
||||
if seat == acting {
|
||||
b.WriteString(fmt.Sprintf("**Round %d.** Your move — `!attack`, `!cast <spell>`, `!consume <item>`.", ct.sess.Round))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("**Round %d.** Waiting on **%s**.", ct.sess.Round, ct.players[acting].Name))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
467
internal/plugin/combat_party_turn_test.go
Normal file
467
internal/plugin/combat_party_turn_test.go
Normal file
@@ -0,0 +1,467 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// N3/P5 — the session layer.
|
||||
//
|
||||
// Everything here exercises a seated party, which production cannot yet build:
|
||||
// P6 supplies the invite. The point of testing it now is that P5's mistakes are
|
||||
// silent ones — a buff landing on the wrong sheet, a latch that resets on every
|
||||
// step, a solo row that grew a key — and none of them announce themselves.
|
||||
|
||||
// ── Seat addressing ──────────────────────────────────────────────────────────
|
||||
|
||||
// partySession builds a 3-seat roster in memory, with each seat's HP and
|
||||
// statuses distinguishable so a mix-up cannot pass.
|
||||
func partySession(phase string, seatHP ...int) *CombatSession {
|
||||
s := turnSession(phase, seatHP[0], 100)
|
||||
s.UserID = "@leader:x"
|
||||
for i, hp := range seatHP[1:] {
|
||||
s.Participants = append(s.Participants, CombatParticipant{
|
||||
Seat: i + 1, UserID: memberID(i + 1), HP: hp, HPMax: hp + 10,
|
||||
})
|
||||
}
|
||||
s.rosterSize = len(seatHP)
|
||||
return s
|
||||
}
|
||||
|
||||
func memberID(seat int) string {
|
||||
return string(rune('a'+seat-1)) + "member:x"
|
||||
}
|
||||
|
||||
func TestSeatAccessors_AddressTheRightRow(t *testing.T) {
|
||||
s := partySession(CombatPhasePlayerTurn, 40, 55, 70)
|
||||
s.PlayerHPMax = 90
|
||||
|
||||
for seat, want := range []int{40, 55, 70} {
|
||||
if got := s.seatHP(seat); got != want {
|
||||
t.Errorf("seatHP(%d) = %d, want %d", seat, got, want)
|
||||
}
|
||||
}
|
||||
if got := s.seatHPMax(0); got != 90 {
|
||||
t.Errorf("seatHPMax(0) = %d, want the session row's 90", got)
|
||||
}
|
||||
if got := s.seatHPMax(2); got != 80 {
|
||||
t.Errorf("seatHPMax(2) = %d, want the participant row's 80", got)
|
||||
}
|
||||
if got := s.seatUserID(0); got != "@leader:x" {
|
||||
t.Errorf("seatUserID(0) = %q, want the session owner", got)
|
||||
}
|
||||
if got := s.seatUserID(1); got != memberID(1) {
|
||||
t.Errorf("seatUserID(1) = %q, want %q", got, memberID(1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeatOf_FindsMembersAndRejectsStrangers(t *testing.T) {
|
||||
s := partySession(CombatPhasePlayerTurn, 40, 55, 70)
|
||||
|
||||
if seat, ok := s.seatOf("@leader:x"); !ok || seat != 0 {
|
||||
t.Errorf("seatOf(leader) = (%d,%v), want (0,true)", seat, ok)
|
||||
}
|
||||
if seat, ok := s.seatOf(id.UserID(memberID(2))); !ok || seat != 2 {
|
||||
t.Errorf("seatOf(member 2) = (%d,%v), want (2,true)", seat, ok)
|
||||
}
|
||||
if _, ok := s.seatOf("@nobody:x"); ok {
|
||||
t.Error("seatOf(stranger) reported a seat — an outsider could act in the fight")
|
||||
}
|
||||
}
|
||||
|
||||
// A member's mid-fight buff must land on their own sheet. Before P5 the cast
|
||||
// path folded every delta into the session's embedded ActorStatuses — seat 0 —
|
||||
// so a party member casting Shield on themselves armoured the leader instead.
|
||||
func TestActorStatusesPtr_WritesToTheCastingSeat(t *testing.T) {
|
||||
s := partySession(CombatPhasePlayerTurn, 40, 55, 70)
|
||||
|
||||
s.actorStatusesPtr(2).applyBuffDelta(turnBuffDelta{dAC: 5})
|
||||
|
||||
if got := s.Statuses.BuffACBonus; got != 0 {
|
||||
t.Errorf("seat 0 picked up seat 2's buff: BuffACBonus = %d, want 0", got)
|
||||
}
|
||||
if got := s.Participants[1].Statuses.BuffACBonus; got != 5 {
|
||||
t.Errorf("seat 2's BuffACBonus = %d, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── The autopilot latch ──────────────────────────────────────────────────────
|
||||
|
||||
// The latch is per-seat session state with no combatState counterpart, so it
|
||||
// rides through the engine on snapshotActor's carry-over of the prior snapshot —
|
||||
// the same road the Buff* deltas take. If it did not, an away member would be
|
||||
// handed the wheel back on every single step and the party would stall anew each
|
||||
// round.
|
||||
func TestSnapshotActor_CarriesTheAutopilotLatchAcrossAStep(t *testing.T) {
|
||||
c := basePlayer()
|
||||
prior := ActorStatuses{Autopilot: true, BuffACBonus: 3}
|
||||
|
||||
got := snapshotActor(newActor(&c), prior)
|
||||
|
||||
if !got.Autopilot {
|
||||
t.Error("snapshotActor dropped the autopilot latch — an away member unlatches every step")
|
||||
}
|
||||
if got.BuffACBonus != 3 {
|
||||
t.Errorf("snapshotActor dropped a carried-over buff: BuffACBonus = %d, want 3", got.BuffACBonus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeatNeedsNoHuman(t *testing.T) {
|
||||
s := partySession(CombatPhasePlayerTurn, 40, 0, 70)
|
||||
s.Participants[1].Statuses.Autopilot = true
|
||||
|
||||
if s.seatNeedsNoHuman(0) {
|
||||
t.Error("a standing, unlatched seat needs its human")
|
||||
}
|
||||
if !s.seatNeedsNoHuman(1) {
|
||||
t.Error("a downed seat must not block the round")
|
||||
}
|
||||
if !s.seatNeedsNoHuman(2) {
|
||||
t.Error("a latched seat must resolve without waiting")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wire compatibility ───────────────────────────────────────────────────────
|
||||
|
||||
// Two fields landed on persisted structs in P5. Both must vanish from a solo
|
||||
// row, which is every row prod holds today: statuses_json and turn_log_json are
|
||||
// decoded by readers that predate them, and a fight in flight across the deploy
|
||||
// must resume byte-identically.
|
||||
func TestP5Fields_StayOffSoloRows(t *testing.T) {
|
||||
raw, err := json.Marshal(CombatStatuses{ActorStatuses: ActorStatuses{PoisonTicks: 1}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "autopilot") {
|
||||
t.Errorf("a solo statuses_json carries the autopilot key: %s", raw)
|
||||
}
|
||||
|
||||
raw, err = json.Marshal(CombatEvent{Actor: "player", Action: "hit", Damage: 4})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "Seat") {
|
||||
t.Errorf("a solo turn_log event carries the Seat key: %s", raw)
|
||||
}
|
||||
|
||||
// …and both must survive the round trip when they are set.
|
||||
raw, _ = json.Marshal(ActorStatuses{Autopilot: true})
|
||||
var back ActorStatuses
|
||||
if err := json.Unmarshal(raw, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !back.Autopilot {
|
||||
t.Errorf("autopilot did not round-trip: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event attribution ────────────────────────────────────────────────────────
|
||||
|
||||
// biasedParty builds a roster whose initiative order is fixed at [0,1,2,enemy],
|
||||
// so a test can park the cursor on a known seat.
|
||||
func biasedParty() ([]*Combatant, *Combatant) {
|
||||
a, b, c := basePlayer(), basePlayer(), basePlayer()
|
||||
a.Name, b.Name, c.Name = "Ada", "Bram", "Cass"
|
||||
a.Mods.InitiativeBias, b.Mods.InitiativeBias, c.Mods.InitiativeBias = 300, 200, 100
|
||||
e := baseEnemy()
|
||||
e.Stats.Speed = 1
|
||||
return []*Combatant{&a, &b, &c}, &e
|
||||
}
|
||||
|
||||
func TestTurnEngine_StampsEventsWithTheSeatThatActed(t *testing.T) {
|
||||
players, enemy := biasedParty()
|
||||
sess := partySession(CombatPhasePlayerTurn, 100, 100, 100)
|
||||
sess.Statuses.TurnIdx = 1 // Bram's turn
|
||||
|
||||
events, err := partyStep(sess, players, enemy, PlayerAction{Kind: ActionAttack})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) == 0 {
|
||||
t.Fatal("Bram's turn produced no events")
|
||||
}
|
||||
for _, e := range events {
|
||||
if e.Seat != 1 {
|
||||
t.Errorf("event %+v stamped seat %d, want 1 (Bram swung)", e, e.Seat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Solo events must all be seat 0 — that is what keeps the field omitted from
|
||||
// every solo turn_log_json.
|
||||
func TestTurnEngine_SoloEventsAreAllSeatZero(t *testing.T) {
|
||||
sess := turnSession(CombatPhasePlayerTurn, 100, 100)
|
||||
p, e := basePlayer(), baseEnemy()
|
||||
|
||||
events, err := partyStep(sess, []*Combatant{&p}, &e, PlayerAction{Kind: ActionAttack})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, ev := range events {
|
||||
if ev.Seat != 0 {
|
||||
t.Errorf("solo event %+v stamped seat %d, want 0", ev, ev.Seat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The flavor pool speaks in the second person. A round must therefore read
|
||||
// differently to each member: your own swing is "You score 9 damage", your
|
||||
// ally's is "**Cass** hits Rat for 9". Getting this backwards tells three people
|
||||
// they each personally landed the same blow.
|
||||
func TestRenderPartyTurnRound_SecondPersonForTheReaderThirdForAllies(t *testing.T) {
|
||||
events := []CombatEvent{
|
||||
{Actor: "player", Action: "hit", Damage: 7, Seat: 0},
|
||||
{Actor: "player", Action: "hit", Damage: 9, Seat: 2},
|
||||
}
|
||||
names := []string{"Ada", "Bram", "Cass"}
|
||||
|
||||
// The reader's own line comes from the flavor pool, whose phrasings vary
|
||||
// ("You score 9 damage", "A measured strike. 9 damage."). What is invariant
|
||||
// is that it never names them in the third person, and that the ally's line
|
||||
// always does.
|
||||
ada := RenderPartyTurnRound(events, names, "Rat", 0)
|
||||
if strings.Contains(ada, "**Ada**") {
|
||||
t.Errorf("Ada's own swing was rendered in the third person:\n%s", ada)
|
||||
}
|
||||
if !strings.Contains(ada, "**Cass** hits Rat for 9.") {
|
||||
t.Errorf("Ada should read Cass's swing in the third person:\n%s", ada)
|
||||
}
|
||||
if strings.Contains(ada, "Bram") {
|
||||
t.Errorf("Bram did nothing this round but was named:\n%s", ada)
|
||||
}
|
||||
|
||||
cass := RenderPartyTurnRound(events, names, "Rat", 2)
|
||||
if strings.Contains(cass, "**Cass**") {
|
||||
t.Errorf("Cass's own swing was rendered in the third person:\n%s", cass)
|
||||
}
|
||||
if !strings.Contains(cass, "**Ada** hits Rat for 7.") {
|
||||
t.Errorf("Cass should read Ada's swing in the third person:\n%s", cass)
|
||||
}
|
||||
}
|
||||
|
||||
// A downed ally is the one thing a member must not miss in their own DM.
|
||||
func TestRenderAllySeatEvent_FlagsAnAllyGoingDown(t *testing.T) {
|
||||
got := renderAllySeatEvent(
|
||||
CombatEvent{Actor: "enemy", Action: "hit", Damage: 12, PlayerHP: 0, Seat: 1}, "Bram", "Rat")
|
||||
if !strings.Contains(got, "is down") {
|
||||
t.Errorf("an ally dropping to 0 HP should be called out, got %q", got)
|
||||
}
|
||||
|
||||
standing := renderAllySeatEvent(
|
||||
CombatEvent{Actor: "enemy", Action: "hit", Damage: 3, PlayerHP: 20, Seat: 1}, "Bram", "Rat")
|
||||
if strings.Contains(standing, "is down") {
|
||||
t.Errorf("a standing ally was reported down: %q", standing)
|
||||
}
|
||||
}
|
||||
|
||||
// An event whose seat has fallen off the roster falls back to seat 0 rather
|
||||
// than panicking on a replayed turn_log.
|
||||
func TestRenderPartyTurnRound_OutOfRangeSeatFallsBack(t *testing.T) {
|
||||
got := RenderPartyTurnRound(
|
||||
[]CombatEvent{{Actor: "player", Action: "hit", Damage: 3, Seat: 9}}, []string{"Ada"}, "Rat", 0)
|
||||
if got == "" || strings.Contains(got, "without a clean blow") {
|
||||
t.Errorf("out-of-range seat should still render a line, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── The turn deadline ────────────────────────────────────────────────────────
|
||||
|
||||
func TestTurnDeadlineLapsed(t *testing.T) {
|
||||
fresh := time.Now().UTC()
|
||||
stale := fresh.Add(-partyTurnDeadline - time.Second)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
phase string
|
||||
at time.Time
|
||||
want bool
|
||||
}{
|
||||
{"fresh player turn", CombatPhasePlayerTurn, fresh, false},
|
||||
{"stale player turn", CombatPhasePlayerTurn, stale, true},
|
||||
// The enemy's turn belongs to the engine, not to a player who might be
|
||||
// away — a fight parked there is mid-resolution, not stalled on anyone.
|
||||
{"stale enemy turn", CombatPhaseEnemyTurn, stale, false},
|
||||
{"stale round end", CombatPhaseRoundEnd, stale, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
s := partySession(tc.phase, 40, 40)
|
||||
s.LastActionAt = tc.at
|
||||
if got := turnDeadlineLapsed(s); got != tc.want {
|
||||
t.Errorf("%s: turnDeadlineLapsed = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Locking ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// A solo fight's owner is the sender. sync.Mutex is not reentrant, so taking the
|
||||
// fight lock and then the user lock would deadlock the very command every solo
|
||||
// player types. This test hangs rather than fails if that regresses.
|
||||
func TestLockCombatFight_SoloTakesExactlyOneLock(t *testing.T) {
|
||||
p := &AdventurePlugin{}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
release := p.lockCombatFight("@solo:x", "@solo:x")
|
||||
release()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("lockCombatFight self-deadlocked on a solo fight")
|
||||
}
|
||||
}
|
||||
|
||||
// A party member's command holds both the fight's lock and their own, so no
|
||||
// other command of theirs races the round they are resolving.
|
||||
func TestLockCombatFight_PartyHoldsBothLocks(t *testing.T) {
|
||||
p := &AdventurePlugin{}
|
||||
release := p.lockCombatFight("@leader:x", "@member:x")
|
||||
|
||||
if p.advUserLock("@leader:x").TryLock() {
|
||||
t.Error("the fight's lock (seat 0) was not held")
|
||||
}
|
||||
if p.advUserLock("@member:x").TryLock() {
|
||||
t.Error("the acting member's own lock was not held")
|
||||
}
|
||||
release()
|
||||
|
||||
if !p.advUserLock("@leader:x").TryLock() {
|
||||
t.Error("release() left the fight's lock held")
|
||||
}
|
||||
if !p.advUserLock("@member:x").TryLock() {
|
||||
t.Error("release() left the member's lock held")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotYourTurnMsg_NamesWhoWeAreWaitingOn(t *testing.T) {
|
||||
players, _ := biasedParty()
|
||||
if got := notYourTurnMsg(players, 1, true); !strings.Contains(got, "Bram") {
|
||||
t.Errorf("message should name the acting player, got %q", got)
|
||||
}
|
||||
if got := notYourTurnMsg(players, 0, false); strings.Contains(got, "Ada") {
|
||||
t.Errorf("nobody is on the clock mid-resolution, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Starting a fight ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestStartPartyCombatSession_SoloWritesNoParticipantRows(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
|
||||
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", 60,
|
||||
[]CombatSeatSetup{{UserID: "@solo:x", HP: 40, HPMax: 40}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.IsParty() || sess.RosterSize() != 1 {
|
||||
t.Fatalf("solo session reports a roster of %d", sess.RosterSize())
|
||||
}
|
||||
ps, err := loadCombatParticipants(sess.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ps) != 0 {
|
||||
t.Errorf("solo fight wrote %d participant rows, want 0", len(ps))
|
||||
}
|
||||
}
|
||||
|
||||
// Each seat's fight-start one-shots are read off their own combatant. A party
|
||||
// Abjurer brings their own Arcane Ward; the leader does not inherit it.
|
||||
func TestStartPartyCombatSession_SeedsEachSeatsOwnOneShots(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
|
||||
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", 60, []CombatSeatSetup{
|
||||
{UserID: "@leader:x", HP: 40, HPMax: 40},
|
||||
{UserID: "@abjurer:x", HP: 30, HPMax: 30, Mods: CombatModifiers{ArcaneWardHP: 12, WardCharges: 2}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sess.IsParty() || sess.RosterSize() != 2 {
|
||||
t.Fatalf("roster size = %d, want 2", sess.RosterSize())
|
||||
}
|
||||
if sess.Statuses.ArcaneWardHP != 0 {
|
||||
t.Errorf("the leader inherited the abjurer's ward: %d", sess.Statuses.ArcaneWardHP)
|
||||
}
|
||||
|
||||
// …and it must be there after a round trip through the DB.
|
||||
back, err := getCombatSession(sess.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := back.actorStatusesForSeat(1); got.ArcaneWardHP != 12 || got.WardCharges != 2 {
|
||||
t.Errorf("seat 1 statuses after reload = %+v, want ArcaneWardHP 12 / WardCharges 2", got)
|
||||
}
|
||||
if back.seatHP(1) != 30 || back.seatUserID(1) != "@abjurer:x" {
|
||||
t.Errorf("seat 1 = %d HP / %s", back.seatHP(1), back.seatUserID(1))
|
||||
}
|
||||
}
|
||||
|
||||
// ── The round driver ─────────────────────────────────────────────────────────
|
||||
|
||||
// The old loop rested on any player_turn. A party's downed seat still holds a
|
||||
// player_turn, so the round would come to rest on a corpse and the fight would
|
||||
// wait forever for a dead member to type `!attack`.
|
||||
func TestSettleCombatSession_StepsPastADownedSeat(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
players, enemy := biasedParty()
|
||||
|
||||
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", 100, []CombatSeatSetup{
|
||||
{UserID: "@leader:x", HP: 100, HPMax: 100},
|
||||
{UserID: "@bram:x", HP: 100, HPMax: 100},
|
||||
{UserID: "@cass:x", HP: 100, HPMax: 100},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Bram is down, and it is his turn.
|
||||
sess.Participants[0].HP = 0
|
||||
sess.Statuses.TurnIdx = 1
|
||||
|
||||
if _, err := settleCombatSession(sess, players, enemy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sess.IsActive() {
|
||||
t.Fatalf("fight ended; two members were still standing (status %q)", sess.Status)
|
||||
}
|
||||
seat, waiting := actingSeat(sess, players, enemy)
|
||||
if !waiting {
|
||||
t.Fatal("settle came to rest on no player's turn")
|
||||
}
|
||||
if seat != 2 {
|
||||
t.Errorf("settled on seat %d, want seat 2 — seat 1 is down and forfeits", seat)
|
||||
}
|
||||
}
|
||||
|
||||
// Settling a session already parked on a standing player's turn must do nothing
|
||||
// at all. Every solo fight sits exactly there between commands, and a stray step
|
||||
// would resolve a round the player never asked for.
|
||||
func TestSettleCombatSession_IsANoOpOnASoloPlayerTurn(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
p, e := basePlayer(), baseEnemy()
|
||||
|
||||
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", 60,
|
||||
[]CombatSeatSetup{{UserID: "@solo:x", HP: 40, HPMax: 40}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := *sess
|
||||
|
||||
events, err := settleCombatSession(sess, []*Combatant{&p}, &e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Errorf("settle emitted %d events on an idle solo turn", len(events))
|
||||
}
|
||||
if sess.Round != before.Round || sess.Phase != before.Phase || sess.PlayerHP != before.PlayerHP {
|
||||
t.Errorf("settle advanced an idle solo fight: %d/%s/%d -> %d/%s/%d",
|
||||
before.Round, before.Phase, before.PlayerHP, sess.Round, sess.Phase, sess.PlayerHP)
|
||||
}
|
||||
}
|
||||
@@ -44,9 +44,10 @@ func (p *AdventurePlugin) reapExpiredCombatSessions() {
|
||||
// nothing to do.
|
||||
func (p *AdventurePlugin) reapCombatSession(userIDStr, sessionID string) {
|
||||
userID := id.UserID(userIDStr)
|
||||
userMu := p.advUserLock(userID)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
// The session's owner is seat 0, which is the user the sweep listed — so the
|
||||
// fight lock and the user lock are the same mutex here, taken once.
|
||||
release := p.lockCombatFight(userID, userID)
|
||||
defer release()
|
||||
|
||||
sess, err := getActiveCombatSession(userID)
|
||||
if err != nil {
|
||||
@@ -58,41 +59,51 @@ func (p *AdventurePlugin) reapCombatSession(userIDStr, sessionID string) {
|
||||
return
|
||||
}
|
||||
|
||||
player, enemy, err := p.combatantsForSession(sess)
|
||||
if err != nil {
|
||||
// Can't reconstruct the fight — park it terminal so it isn't retried
|
||||
// every minute forever.
|
||||
slog.Warn("combat: reaper cannot rebuild session, marking expired",
|
||||
"user", userID, "session", sess.SessionID, "err", err)
|
||||
expire := func(why string, args ...any) {
|
||||
slog.Warn("combat: reaper "+why, append([]any{"user", userID, "session", sess.SessionID}, args...)...)
|
||||
if merr := markCombatSessionExpired(sess.SessionID); merr != nil {
|
||||
slog.Error("combat: reaper failed to mark session expired", "session", sess.SessionID, "err", merr)
|
||||
}
|
||||
}
|
||||
|
||||
players, enemy, err := p.partyCombatantsForSession(sess)
|
||||
if err != nil {
|
||||
// Can't reconstruct the fight — park it terminal so it isn't retried
|
||||
// every minute forever.
|
||||
expire("cannot rebuild session, marking expired", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Every seat swings until the fight lands. Deliberately *not* the auto-picker
|
||||
// the turn deadline uses: this is an abandoned fight, and finishing it should
|
||||
// not quietly burn the player's spell slots and potions on their behalf. The
|
||||
// turn-deadline latch is different — that member is mid-fight and their party
|
||||
// is waiting, so playing their character properly is the whole point.
|
||||
//
|
||||
// Each pass resolves one seat's turn and drains the enemy turn, the round-end
|
||||
// tick, and any downed seat behind it, so a solo fight walks exactly the loop
|
||||
// it always did.
|
||||
ct := &combatTurn{sess: sess, players: players, enemy: enemy, seat: 0, uid: userID}
|
||||
rounds := 0
|
||||
for sess.IsActive() {
|
||||
if rounds >= reaperRoundCap {
|
||||
slog.Warn("combat: reaper hit round cap, marking expired",
|
||||
"user", userID, "session", sess.SessionID)
|
||||
if merr := markCombatSessionExpired(sess.SessionID); merr != nil {
|
||||
slog.Error("combat: reaper failed to mark session expired", "session", sess.SessionID, "err", merr)
|
||||
}
|
||||
expire("hit round cap, marking expired")
|
||||
return
|
||||
}
|
||||
if _, rerr := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionAttack}); rerr != nil {
|
||||
slog.Error("combat: reaper round failed", "user", userID, "session", sess.SessionID, "err", rerr)
|
||||
if merr := markCombatSessionExpired(sess.SessionID); merr != nil {
|
||||
slog.Error("combat: reaper failed to mark session expired", "session", sess.SessionID, "err", merr)
|
||||
}
|
||||
if _, rerr := runPartyCombatRound(sess, players, enemy, PlayerAction{Kind: ActionAttack}); rerr != nil {
|
||||
expire("round failed", "err", rerr)
|
||||
return
|
||||
}
|
||||
rounds++
|
||||
}
|
||||
|
||||
outcome := p.finishCombatSession(userID, sess, enemy)
|
||||
outcomes := p.closePartyRound(ct)
|
||||
preamble := fmt.Sprintf("⏳ Your fight with **%s** timed out — I finished it for you.\n\n", enemy.Name)
|
||||
if err := p.SendDM(userID, preamble+outcome); err != nil {
|
||||
slog.Error("combat: reaper failed to DM outcome", "user", userID, "err", err)
|
||||
if !ct.isParty() {
|
||||
if err := p.SendDM(userID, preamble+outcomes[0]); err != nil {
|
||||
slog.Error("combat: reaper failed to DM outcome", "user", userID, "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
p.announcePartyRound(ct, nil, preamble, outcomes)
|
||||
}
|
||||
|
||||
@@ -99,6 +99,18 @@ type ActorStatuses struct {
|
||||
AssassinateReroll bool `json:"assassinate_reroll_used,omitempty"`
|
||||
AssassinateBonus bool `json:"assassinate_bonus_used,omitempty"`
|
||||
|
||||
// Autopilot latches this seat onto the auto-picker for the rest of the
|
||||
// fight, set when its turn deadline lapses once (see partyTurnDeadline).
|
||||
// Without the latch an away member taxes the party the full deadline every
|
||||
// single round; with it, they cost one wait and then resolve instantly. Any
|
||||
// combat command from that member clears it.
|
||||
//
|
||||
// Like the Buff* deltas it is session-layer state with no combatState
|
||||
// counterpart, so snapshotActor carries it over from the prior snapshot
|
||||
// rather than re-deriving it. omitempty keeps it off every solo row — a solo
|
||||
// fight has no turn deadline, only the 1h session reaper.
|
||||
Autopilot bool `json:"autopilot,omitempty"`
|
||||
|
||||
// Debuffs the enemy has stacked onto this character specifically.
|
||||
PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
|
||||
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
|
||||
@@ -210,7 +222,14 @@ func (s *ActorStatuses) applyBuffDelta(d turnBuffDelta) {
|
||||
// turn-based build deliberately omits pre-combat consumables and queued casts —
|
||||
// but the full set is seeded for robustness. Returns true if anything was set.
|
||||
func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) bool {
|
||||
st := &s.Statuses
|
||||
return seedActorOneShots(&s.Statuses.ActorStatuses, playerMods)
|
||||
}
|
||||
|
||||
// seedActorOneShots copies one character's fight-start one-shot resources onto
|
||||
// their persisted statuses. Seat 0's live on the session row; a party member's
|
||||
// live on their participant row, and each seat reads its own combatant's mods —
|
||||
// a party Abjurer brings their own Arcane Ward, not the leader's.
|
||||
func seedActorOneShots(st *ActorStatuses, playerMods CombatModifiers) bool {
|
||||
st.WardCharges = playerMods.WardCharges
|
||||
st.SporeRounds = playerMods.SporeCloud
|
||||
st.ReflectFrac = playerMods.ReflectNext
|
||||
@@ -293,6 +312,74 @@ func (s *CombatSession) actorStatusesForSeat(seat int) ActorStatuses {
|
||||
return s.Participants[seat-1].Statuses
|
||||
}
|
||||
|
||||
// actorStatusesPtr is actorStatusesForSeat for writers. The mid-fight buff path
|
||||
// (!cast / !consume) folds its delta into the *casting* seat's statuses; before
|
||||
// P5 it wrote unconditionally to the session's embedded copy, which is seat 0 —
|
||||
// so a party member's buff would have landed on the leader.
|
||||
func (s *CombatSession) actorStatusesPtr(seat int) *ActorStatuses {
|
||||
if seat == 0 {
|
||||
return &s.Statuses.ActorStatuses
|
||||
}
|
||||
return &s.Participants[seat-1].Statuses
|
||||
}
|
||||
|
||||
// seatHP / seatHPMax read one seat's HP pool. Seat 0's lives on the session row
|
||||
// (PlayerHP / PlayerHPMax); seats 1+ carry their own on their participant row.
|
||||
func (s *CombatSession) seatHP(seat int) int {
|
||||
if seat == 0 {
|
||||
return s.PlayerHP
|
||||
}
|
||||
return s.Participants[seat-1].HP
|
||||
}
|
||||
|
||||
func (s *CombatSession) seatHPMax(seat int) int {
|
||||
if seat == 0 {
|
||||
return s.PlayerHPMax
|
||||
}
|
||||
return s.Participants[seat-1].HPMax
|
||||
}
|
||||
|
||||
// seatUserID names the player sitting at a seat.
|
||||
func (s *CombatSession) seatUserID(seat int) string {
|
||||
if seat == 0 {
|
||||
return s.UserID
|
||||
}
|
||||
return s.Participants[seat-1].UserID
|
||||
}
|
||||
|
||||
// seatOf locates a player on the roster. The false return is the "you are not in
|
||||
// this fight" answer every party-aware command needs before it touches a turn.
|
||||
func (s *CombatSession) seatOf(userID id.UserID) (int, bool) {
|
||||
u := string(userID)
|
||||
if s.UserID == u {
|
||||
return 0, true
|
||||
}
|
||||
for i, p := range s.Participants {
|
||||
if p.UserID == u {
|
||||
return i + 1, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// seatAlive reports whether a seat is still standing. A downed seat forfeits its
|
||||
// turn silently rather than blocking the round on a corpse.
|
||||
func (s *CombatSession) seatAlive(seat int) bool { return s.seatHP(seat) > 0 }
|
||||
|
||||
// seatIsAutopiloted reports whether a seat has been latched onto the auto-picker
|
||||
// by a lapsed turn deadline.
|
||||
func (s *CombatSession) seatIsAutopiloted(seat int) bool {
|
||||
return s.actorStatusesForSeat(seat).Autopilot
|
||||
}
|
||||
|
||||
// seatNeedsNoHuman reports whether the engine can resolve a seat's turn without
|
||||
// waiting on its player: it is down (forfeits silently) or latched onto
|
||||
// autopilot. driveCombatRound keeps stepping while this holds, so a round only
|
||||
// comes to rest on a live human's turn.
|
||||
func (s *CombatSession) seatNeedsNoHuman(seat int) bool {
|
||||
return !s.seatAlive(seat) || s.seatIsAutopiloted(seat)
|
||||
}
|
||||
|
||||
// Errors returned by the combat session layer.
|
||||
var (
|
||||
ErrCombatSessionAlreadyActive = errors.New("combat session already active for player")
|
||||
@@ -366,6 +453,65 @@ func startCombatSession(userID id.UserID, runID, encounterID, enemyID string, pl
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// startPartyCombatSession opens a fight for a seated roster. seats[0] owns the
|
||||
// session row (and is the expedition's leader); seats 1..N-1 get their own
|
||||
// combat_participant rows. Each seat carries the HP pool and the fight-start
|
||||
// one-shot resources (Abjuration's Arcane Ward, …) of its own character.
|
||||
//
|
||||
// A one-seat roster is exactly startCombatSession: no participant rows, no
|
||||
// roster_size bump, and the single unwrapped INSERT the solo path has always
|
||||
// issued. That is the invariant the whole balance corpus rests on.
|
||||
func (p *AdventurePlugin) startPartyCombatSession(
|
||||
runID, encounterID, enemyID string, enemyHP int, seats []CombatSeatSetup,
|
||||
) (*CombatSession, error) {
|
||||
if len(seats) == 0 {
|
||||
return nil, fmt.Errorf("start combat session: empty roster")
|
||||
}
|
||||
owner := seats[0]
|
||||
sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID,
|
||||
owner.HP, owner.HPMax, enemyHP, enemyHP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Seat 0's one-shots live on the session row; seeding them is a mutation of
|
||||
// sess.Statuses that the save below flushes along with the participants.
|
||||
dirty := seedCombatSessionOneShots(sess, owner.Mods)
|
||||
|
||||
if len(seats) > 1 {
|
||||
ps := make([]CombatParticipant, 0, len(seats)-1)
|
||||
for i, s := range seats[1:] {
|
||||
var st ActorStatuses
|
||||
seedActorOneShots(&st, s.Mods)
|
||||
ps = append(ps, CombatParticipant{
|
||||
Seat: i + 1, UserID: string(s.UserID), HP: s.HP, HPMax: s.HPMax, Statuses: st,
|
||||
})
|
||||
}
|
||||
if err := insertCombatParticipants(sess.SessionID, ps); err != nil {
|
||||
return nil, fmt.Errorf("seat party: %w", err)
|
||||
}
|
||||
sess.Participants = ps
|
||||
sess.rosterSize = len(seats)
|
||||
dirty = true
|
||||
}
|
||||
|
||||
if dirty {
|
||||
if err := saveCombatSession(sess); err != nil {
|
||||
return nil, fmt.Errorf("seed combat session: %w", err)
|
||||
}
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// CombatSeatSetup is one character's entry into a fight: who they are, the HP
|
||||
// pool they bring, and the modifiers their fight-start one-shots are read off.
|
||||
type CombatSeatSetup struct {
|
||||
UserID id.UserID
|
||||
HP int
|
||||
HPMax int
|
||||
Mods CombatModifiers
|
||||
}
|
||||
|
||||
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil).
|
||||
func getActiveCombatSession(userID id.UserID) (*CombatSession, error) {
|
||||
row := db.Get().QueryRow(`SELECT `+combatSessionCols+`
|
||||
|
||||
@@ -107,33 +107,70 @@ func (p *AdventurePlugin) buildZoneCombatants(
|
||||
return player, enemy, dndChar, nil
|
||||
}
|
||||
|
||||
// combatantsForSession reconstructs the Combatant pair for an in-flight
|
||||
// CombatSession. It reloads the zone run for the DM mood + tier and looks the
|
||||
// enemy up in the bestiary by the session's EnemyID. The pair carries no HP —
|
||||
// the turn engine reads that from the session row.
|
||||
// combatantsForSession reconstructs the Combatant pair for an in-flight solo
|
||||
// CombatSession — the shape every caller wanted before parties existed. It is
|
||||
// partyCombatantsForSession reduced to seat 0, and it errors rather than
|
||||
// silently dropping the rest of a party's roster on the floor.
|
||||
func (p *AdventurePlugin) combatantsForSession(sess *CombatSession) (player Combatant, enemy Combatant, err error) {
|
||||
if sess.IsParty() {
|
||||
return Combatant{}, Combatant{}, fmt.Errorf(
|
||||
"combat session %s seats %d players; use partyCombatantsForSession", sess.SessionID, sess.RosterSize())
|
||||
}
|
||||
players, e, err := p.partyCombatantsForSession(sess)
|
||||
if err != nil {
|
||||
return Combatant{}, Combatant{}, err
|
||||
}
|
||||
return *players[0], *e, nil
|
||||
}
|
||||
|
||||
// partyCombatantsForSession reconstructs the seated roster and the enemy for an
|
||||
// in-flight CombatSession. It reloads the zone run for the DM mood + tier and
|
||||
// looks the enemy up in the bestiary by the session's EnemyID. The combatants
|
||||
// carry no HP — the turn engine reads that from the session row and the
|
||||
// participant rows.
|
||||
//
|
||||
// Every seat is rebuilt from *its own* player: their sheet, their equipment,
|
||||
// their passives, their magic items. That is N times the per-round DB chatter
|
||||
// combatantsForSession already had (project_combat_session_cache_deferred), and
|
||||
// a party of 3 pays it three times over. Solo — every fight that has ever run,
|
||||
// and the whole balance corpus — still issues exactly one build.
|
||||
func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Combatant, *Combatant, error) {
|
||||
run, rerr := getZoneRun(sess.RunID)
|
||||
if rerr != nil {
|
||||
return Combatant{}, Combatant{}, fmt.Errorf("load zone run: %w", rerr)
|
||||
return nil, nil, fmt.Errorf("load zone run: %w", rerr)
|
||||
}
|
||||
if run == nil {
|
||||
return Combatant{}, Combatant{}, fmt.Errorf("combat session %s: zone run %s not found", sess.SessionID, sess.RunID)
|
||||
return nil, nil, fmt.Errorf("combat session %s: zone run %s not found", sess.SessionID, sess.RunID)
|
||||
}
|
||||
monster, ok := dndBestiary[sess.EnemyID]
|
||||
if !ok {
|
||||
return Combatant{}, Combatant{}, fmt.Errorf("combat session %s: enemy %q not in bestiary", sess.SessionID, sess.EnemyID)
|
||||
return nil, nil, fmt.Errorf("combat session %s: enemy %q not in bestiary", sess.SessionID, sess.EnemyID)
|
||||
}
|
||||
zone := zoneOrFallback(run.ZoneID)
|
||||
player, enemy, _, err = p.buildZoneCombatants(id.UserID(sess.UserID), monster, int(zone.Tier), run.DMMood)
|
||||
if err != nil {
|
||||
return player, enemy, err
|
||||
|
||||
seats := sess.SeatUserIDs()
|
||||
players := make([]*Combatant, len(seats))
|
||||
var enemy Combatant
|
||||
for seat, uid := range seats {
|
||||
player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
|
||||
}
|
||||
// Fold any fight-scoped buffs this seat's mid-fight !cast / !consume
|
||||
// layered on back onto their freshly-rebuilt combatant. The depleting
|
||||
// one-shots (ward/spore/…) live on their persisted statuses and flow
|
||||
// through the turn engine's resume/commit cycle, so only the persistent
|
||||
// stat deltas are applied here — and only that seat's own.
|
||||
applySessionBuffs(&player, sess.actorStatusesForSeat(seat))
|
||||
players[seat] = &player
|
||||
if seat == 0 {
|
||||
// The enemy build reads only (monster, tier, dmMood): every seat
|
||||
// rebuilds the identical stat block, so seat 0's copy is the fight's.
|
||||
// Only the *player* half of the build varies by seat.
|
||||
enemy = e
|
||||
}
|
||||
}
|
||||
// Fold any fight-scoped buffs a mid-fight !cast / !consume layered on back
|
||||
// onto the freshly-rebuilt player. The depleting one-shots (ward/spore/…)
|
||||
// live on the session's Statuses and flow through the turn engine's
|
||||
// resume/commit cycle, so only the persistent stat deltas are applied here.
|
||||
applySessionBuffs(&player, sess.Statuses.ActorStatuses)
|
||||
return player, enemy, err
|
||||
return players, &enemy, nil
|
||||
}
|
||||
|
||||
// applySessionBuffs re-derives the persistent stat effect of every mid-fight
|
||||
|
||||
@@ -195,6 +195,31 @@ func phaseForSeat(seat int) string {
|
||||
return CombatPhasePlayerTurn
|
||||
}
|
||||
|
||||
// stepSeat is the seat that resolves the session's current phase: the acting
|
||||
// player on a player_turn, the enemy sentinel on anything else.
|
||||
//
|
||||
// It is the *single* derivation of "whose step is this". advancePartyCombatSession
|
||||
// needs it before the engine is resumed, because the seat seeds that step's RNG;
|
||||
// the command layer needs it to tell a member it is not their turn. Both read
|
||||
// only (sessionID, round, roster), so they agree by construction.
|
||||
func stepSeat(sess *CombatSession, players []*Combatant, enemy *Combatant) int {
|
||||
if sess.Phase != CombatPhasePlayerTurn {
|
||||
return enemySeat
|
||||
}
|
||||
order := turnOrder(sess, sess.Round, players, enemy)
|
||||
return order[turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)]
|
||||
}
|
||||
|
||||
// actingSeat names the player seat currently on the clock. The false return
|
||||
// means the fight is not waiting on anybody: it is mid enemy-turn, mid
|
||||
// round-end, or over.
|
||||
func actingSeat(sess *CombatSession, players []*Combatant, enemy *Combatant) (int, bool) {
|
||||
if !sess.IsActive() || sess.Phase != CombatPhasePlayerTurn {
|
||||
return 0, false
|
||||
}
|
||||
return stepSeat(sess, players, enemy), true
|
||||
}
|
||||
|
||||
// turnIdxForPhase reconciles a persisted cursor against the persisted phase.
|
||||
// They can disagree for exactly one reason: a fight that was in flight when
|
||||
// TurnIdx was introduced decodes as 0 regardless of whose turn it is. Phase is
|
||||
@@ -362,6 +387,18 @@ func (te *turnEngine) advance() {
|
||||
// events generated this step are returned (also accumulated by commit into
|
||||
// sess.TurnLog). It does not persist — call commit then saveCombatSession, or
|
||||
// use advanceCombatSession which does both.
|
||||
// stampSeat tags every event appended since mark with the roster seat it is
|
||||
// about, so a party's play-by-play can name the right character. The primitives
|
||||
// in combat_primitives.go emit against the cursor and know nothing of seats, so
|
||||
// the tag is applied here, once per phase step, rather than at ~20 append sites.
|
||||
//
|
||||
// Solo stamps seat 0 onto seat-0 events — a no-op that omitempty erases.
|
||||
func (te *turnEngine) stampSeat(mark, seat int) {
|
||||
for i := mark; i < len(te.st.events); i++ {
|
||||
te.st.events[i].Seat = seat
|
||||
}
|
||||
}
|
||||
|
||||
func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) {
|
||||
if !te.sess.IsActive() {
|
||||
return nil, errCombatSessionOver
|
||||
@@ -369,9 +406,18 @@ func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) {
|
||||
te.st.events = nil
|
||||
switch te.sess.Phase {
|
||||
case CombatPhasePlayerTurn:
|
||||
// The cursor was seated on the acting player by resumeTurnEngine, and
|
||||
// stepPlayerTurn never moves it, so every event this phase emits — the
|
||||
// swings, the pet, the spirit weapon, the retaliate that kills them —
|
||||
// belongs to that seat.
|
||||
acting := te.st.seatIdx
|
||||
te.stepPlayerTurn(action)
|
||||
te.stampSeat(0, acting)
|
||||
case CombatPhaseEnemyTurn:
|
||||
te.stepEnemyTurn()
|
||||
// stepEnemyTurn seats its target before anything resolves, and the whole
|
||||
// phase lands on that one character.
|
||||
te.stampSeat(0, te.st.seatIdx)
|
||||
case CombatPhaseRoundEnd:
|
||||
te.stepRoundEnd()
|
||||
case CombatPhaseOver:
|
||||
@@ -707,6 +753,7 @@ func (te *turnEngine) stepRoundEnd() {
|
||||
if st.poisonTicks <= 0 {
|
||||
continue
|
||||
}
|
||||
mark := len(st.events)
|
||||
st.playerHP = max(0, st.playerHP-st.poisonDmg)
|
||||
st.poisonTicks--
|
||||
st.events = append(st.events, CombatEvent{
|
||||
@@ -714,9 +761,11 @@ func (te *turnEngine) stepRoundEnd() {
|
||||
Damage: st.poisonDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.playerHP <= 0 && !trySave(st, st.c, CombatPhaseRoundEnd) && !st.anyAlive() {
|
||||
te.stampSeat(mark, i)
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
}
|
||||
te.stampSeat(mark, i)
|
||||
}
|
||||
// Concentration aura (Spirit Guardians et al.): the lingering spell bites
|
||||
// the enemy each round it stays up. Concentration is per-caster, so every
|
||||
@@ -730,7 +779,7 @@ func (te *turnEngine) stepRoundEnd() {
|
||||
st.enemyHP = max(0, st.enemyHP-st.concentrationDmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "player", Action: "concentration_tick",
|
||||
Damage: st.concentrationDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Damage: st.concentrationDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
|
||||
})
|
||||
if st.enemyHP <= 0 {
|
||||
te.finish(CombatStatusWon)
|
||||
@@ -830,12 +879,7 @@ func advancePartyCombatSession(sess *CombatSession, players []*Combatant, enemy
|
||||
// step's RNG — so the order is derived once here and once inside
|
||||
// resumeTurnEngine. Both derivations read only (sessionID, round, roster),
|
||||
// so they agree by construction.
|
||||
seat := enemySeat
|
||||
if sess.Phase == CombatPhasePlayerTurn {
|
||||
order := turnOrder(sess, sess.Round, players, enemy)
|
||||
seat = order[turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)]
|
||||
}
|
||||
rng := combatSessionStepRNG(sess, seat)
|
||||
rng := combatSessionStepRNG(sess, stepSeat(sess, players, enemy))
|
||||
te := resumeTurnEngine(sess, players, enemy, rng)
|
||||
events, err := te.step(action)
|
||||
if err != nil {
|
||||
|
||||
@@ -896,11 +896,22 @@ const simHealHPThresholdPct = 40
|
||||
// Pre-J2a the sim looped !attack only, which underweighted every caster
|
||||
// class — see sim_results/j2_findings.md for the trace evidence.
|
||||
func (p *AdventurePlugin) pickAutoCombatAction(uid id.UserID, sess *CombatSession) (kind, arg string) {
|
||||
return p.pickAutoCombatActionForSeat(uid, sess, 0)
|
||||
}
|
||||
|
||||
// pickAutoCombatActionForSeat is pickAutoCombatAction for an arbitrary seat.
|
||||
// The decision tree reads HP and the running concentration aura, and both are
|
||||
// per-character — before N3/P5 they were read straight off the session row,
|
||||
// which is seat 0. Driving a party member's turn off the leader's HP would heal
|
||||
// the wrong person and re-arm the wrong aura.
|
||||
func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *CombatSession, seat int) (kind, arg string) {
|
||||
c, _ := LoadDnDCharacter(uid)
|
||||
if c == nil || sess == nil {
|
||||
return "attack", ""
|
||||
}
|
||||
lowHP := sess.PlayerHPMax > 0 && sess.PlayerHP*100 < sess.PlayerHPMax*simHealHPThresholdPct
|
||||
st := sess.actorStatusesForSeat(seat)
|
||||
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
|
||||
lowHP := hpMax > 0 && hp*100 < hpMax*simHealHPThresholdPct
|
||||
if lowHP {
|
||||
inv := p.loadConsumableInventory(uid)
|
||||
for _, it := range inv {
|
||||
@@ -916,14 +927,14 @@ func (p *AdventurePlugin) pickAutoCombatAction(uid id.UserID, sess *CombatSessio
|
||||
// otherwise never spends an L2 slot on it. Force the pick once
|
||||
// per fight (BuffSpiritProc==0) so the picker doesn't pretend
|
||||
// it's not a damage option.
|
||||
if id := simPickSpiritualWeapon(c, uid, sess); id != "" {
|
||||
if id := simPickSpiritualWeapon(c, uid, st); id != "" {
|
||||
return "cast", id
|
||||
}
|
||||
// Once a concentration aura is up, a competent caster maintains it and
|
||||
// attacks (or casts a non-concentration spell) rather than burning a
|
||||
// slot to re-arm the same aura — so the picker excludes concentration
|
||||
// spells while one is active.
|
||||
auraActive := sess.Statuses.ConcentrationDmg > 0
|
||||
auraActive := st.ConcentrationDmg > 0
|
||||
if id := simPickSpell(c, uid, auraActive); id != "" {
|
||||
return "cast", id
|
||||
}
|
||||
@@ -959,11 +970,11 @@ func simMartialFirstClass(class DnDClass) bool {
|
||||
// above 2nd, so spending a precious L5 to add a single d8 to the proc is
|
||||
// not worth burning the bigger slot's damage potential elsewhere; sim
|
||||
// behaves like a competent player and saves the high slot.
|
||||
func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, sess *CombatSession) string {
|
||||
if c == nil || c.Class != ClassCleric || sess == nil {
|
||||
func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) string {
|
||||
if c == nil || c.Class != ClassCleric {
|
||||
return ""
|
||||
}
|
||||
if sess.Statuses.BuffSpiritProc > 0 {
|
||||
if st.BuffSpiritProc > 0 {
|
||||
return ""
|
||||
}
|
||||
known, err := listKnownSpells(uid)
|
||||
|
||||
Reference in New Issue
Block a user