mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
`!fight` seats the expedition's roster instead of the one player who typed it. Seat 0 is the leader, always: the session row is theirs, the lock is theirs, and `!flee`, the fork, and `!extract` stay their call. A monster that wins initiative now swings before anyone speaks. The session layer used to park every new fight on a player_turn, which is true of the hardcoded solo order and a lie about a party's -- the enemy would forfeit round 1 and nobody would notice. `startPartyCombatSession` rolls the order and sets the phase from it; `handleFightCmd` settles the round before it announces, so the opening block narrates the hit rather than quietly showing its damage. Members were invisible to two commands that had no business ignoring them: `!cast` queued a spell for "next combat" while its caster was standing in one, and `!rest` healed a seated member to full mid boss fight. Both now resolve through the party. Nobody leaves without an answer. A downed member's `!fight` opens the party's fight and tells them why they are not in it. The leader's `!extract` reaches everyone it drags out of the dungeon, and everyone rolls for what moved into their house while they were gone. Supplies burn at 50% x N x 4/5 -- a party eats more than one and less than N. The ratio is exact: 0.8 as a float truncates a party of three to 119%, a permanent tax nobody would have found. Solo is untouched, byte for byte. One seat means one build, one INSERT, no participant rows, the same RNG draws in the same order -- the combat characterization golden does not move, and neither does the balance corpus.
268 lines
9.1 KiB
Go
268 lines
9.1 KiB
Go
package plugin
|
|
|
|
import (
|
|
"fmt"
|
|
"math/rand/v2"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// !rest short / !rest long.
|
|
//
|
|
// Short rest: spend 1 hit-dice charge (max charges = character level,
|
|
// restored on long rest). Heals 1d6+CON HP, x2 at L5+. Sets a 1h activity
|
|
// lockout — the character is *actually resting* for that hour and can't
|
|
// !zone enter / !expedition start until the timer expires.
|
|
//
|
|
// Long rest: 24h cooldown. Full HP recovery, restores all short rest
|
|
// charges, sets an 8h activity lockout. Requires housing (HouseTier > 0)
|
|
// OR pays the Thom Krooke inn (200 euros). Slot/spell refresh runs here.
|
|
//
|
|
// These commands operate on the D&D layer only. The legacy `!adventure`
|
|
// menu "rest" choice is unchanged — it remains the daily-action narrative
|
|
// day-skip.
|
|
|
|
const (
|
|
dndLongRestCooldown = 24 * time.Hour
|
|
dndShortRestLockoutHours = 1
|
|
dndLongRestLockoutHours = 8
|
|
dndInnCost = 200
|
|
)
|
|
|
|
// restingLockoutRemaining returns the time left on a character's rest
|
|
// lockout (zero if not currently resting). Callers gate !zone enter and
|
|
// !expedition start on this so a freshly-rested character can't
|
|
// immediately jump back into combat.
|
|
func restingLockoutRemaining(c *DnDCharacter) time.Duration {
|
|
if c == nil || c.RestingUntil == nil {
|
|
return 0
|
|
}
|
|
remaining := time.Until(*c.RestingUntil)
|
|
if remaining <= 0 {
|
|
return 0
|
|
}
|
|
return remaining
|
|
}
|
|
|
|
// restBlockedReason returns a player-facing message when the character
|
|
// cannot rest right now because they're mid-fight or mid-expedition. An
|
|
// empty string means rest is allowed. Both !rest short and !rest long
|
|
// honor this — the dungeon shouldn't be a campfire.
|
|
//
|
|
// Both lookups resolve through the party: a seated member owns neither the
|
|
// session row nor the expedition row, so the owner-keyed reads would wave them
|
|
// through to a full heal mid boss fight.
|
|
func restBlockedReason(uid id.UserID) string {
|
|
if sess, _ := activeCombatSessionFor(uid); sess != nil {
|
|
return "You're mid-fight. Finish it (or `!flee`) before resting."
|
|
}
|
|
exp, isLeader, _ := activeExpeditionFor(uid)
|
|
switch {
|
|
case exp == nil:
|
|
return ""
|
|
case !isLeader:
|
|
return "You can't rest while on an expedition. Ask your leader to `!extract`, or `!expedition leave` to walk out alone."
|
|
}
|
|
return "You can't rest while on an expedition. Use `!expedition extract` first."
|
|
}
|
|
|
|
func (p *AdventurePlugin) handleDnDRestCmd(ctx MessageContext, args string) error {
|
|
args = strings.TrimSpace(strings.ToLower(args))
|
|
switch args {
|
|
case "short":
|
|
return p.handleDnDShortRest(ctx)
|
|
case "long":
|
|
return p.handleDnDLongRest(ctx)
|
|
case "":
|
|
return p.SendDM(ctx.Sender, dndRestHelpText())
|
|
}
|
|
return p.SendDM(ctx.Sender, "Unknown rest type. Use `!rest short` or `!rest long`.")
|
|
}
|
|
|
|
func dndRestHelpText() string {
|
|
return "🛌 **Adv 2.0 Rest**\n\n" +
|
|
"`!rest short` — spend 1 hit-dice charge. Heals 1d6 + CON HP (x2 at L5+). Locks zone/expedition for **1 hour**.\n" +
|
|
"`!rest long` — once per 24h. Full HP, restores hit-dice charges. Locks zone/expedition for **8 hours**. Requires housing or pays the inn (€200)."
|
|
}
|
|
|
|
// ── Short rest ───────────────────────────────────────────────────────────────
|
|
|
|
func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error {
|
|
userMu := p.advUserLock(ctx.Sender)
|
|
userMu.Lock()
|
|
defer userMu.Unlock()
|
|
|
|
advChar, _ := loadAdvCharacter(ctx.Sender)
|
|
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
|
|
if err != nil || c == nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't load your character.")
|
|
}
|
|
|
|
if msg := restBlockedReason(ctx.Sender); msg != "" {
|
|
return p.SendDM(ctx.Sender, msg)
|
|
}
|
|
if c.ShortRestCharges <= 0 {
|
|
return p.SendDM(ctx.Sender,
|
|
"You're out of short rest charges. Take a `!rest long` to restore them.")
|
|
}
|
|
hpFull := c.HPCurrent >= c.HPMax
|
|
hasRefreshableSlots, _ := casterHasUsedSlots(ctx.Sender)
|
|
if hpFull && !hasRefreshableSlots {
|
|
return p.SendDM(ctx.Sender, "You're at full HP and your slots are full. Save the charge for when you need it.")
|
|
}
|
|
|
|
before := c.HPCurrent
|
|
if !hpFull {
|
|
conMod := abilityModifier(c.CON)
|
|
healDie := 1 + rand.IntN(6) // 1d6
|
|
heal := healDie + conMod
|
|
if heal < 1 {
|
|
heal = 1
|
|
}
|
|
if c.Level >= 5 {
|
|
heal *= 2 // v1.0 §10.1: "x2 at levels 5+"
|
|
}
|
|
c.HPCurrent += heal
|
|
if c.HPCurrent > c.HPMax {
|
|
c.HPCurrent = c.HPMax
|
|
}
|
|
}
|
|
c.ShortRestCharges--
|
|
now := time.Now().UTC()
|
|
c.LastShortRestAt = &now
|
|
lockoutEnd := now.Add(dndShortRestLockoutHours * time.Hour)
|
|
c.RestingUntil = &lockoutEnd
|
|
|
|
slotsRestored, _ := partialRefreshSpellSlots(ctx.Sender, c.Level)
|
|
|
|
if err := SaveDnDCharacter(c); err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't save rest state: "+err.Error())
|
|
}
|
|
markActedToday(ctx.Sender)
|
|
|
|
var msg string
|
|
if c.HPCurrent > before {
|
|
msg = fmt.Sprintf(
|
|
"🛌 **Short rest.** You recover **%d HP** (%d→%d / %d).\n_Charges remaining: %d. You're resting — `!zone` and `!expedition` locked for 1 hour._",
|
|
c.HPCurrent-before, before, c.HPCurrent, c.HPMax, c.ShortRestCharges)
|
|
} else {
|
|
msg = fmt.Sprintf(
|
|
"🛌 **Short rest.** You catch your breath — HP already full (%d/%d).\n_Charges remaining: %d. You're resting — `!zone` and `!expedition` locked for 1 hour._",
|
|
c.HPCurrent, c.HPMax, c.ShortRestCharges)
|
|
}
|
|
if slotLine := dndShortRestSlotLine(slotsRestored); slotLine != "" {
|
|
msg += "\n_" + slotLine + "_"
|
|
}
|
|
if line := dndRestShortFlavorLine(); line != "" {
|
|
msg += "\n\n_" + line + "_"
|
|
}
|
|
return p.SendDM(ctx.Sender, msg)
|
|
}
|
|
|
|
// dndShortRestSlotLine renders a "Spell slots restored: 2 (L1), 1 (L2)."
|
|
// footer. Returns "" if nothing was restored, suppressing the line for
|
|
// martials and casters already at full.
|
|
func dndShortRestSlotLine(restored map[int]int) string {
|
|
if len(restored) == 0 {
|
|
return ""
|
|
}
|
|
levels := make([]int, 0, len(restored))
|
|
for lvl := range restored {
|
|
levels = append(levels, lvl)
|
|
}
|
|
sort.Ints(levels)
|
|
parts := make([]string, 0, len(levels))
|
|
for _, lvl := range levels {
|
|
parts = append(parts, fmt.Sprintf("%d (L%d)", restored[lvl], lvl))
|
|
}
|
|
return "Spell slots restored: " + strings.Join(parts, ", ") + "."
|
|
}
|
|
|
|
// ── Long rest ────────────────────────────────────────────────────────────────
|
|
|
|
func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error {
|
|
userMu := p.advUserLock(ctx.Sender)
|
|
userMu.Lock()
|
|
defer userMu.Unlock()
|
|
|
|
advChar, err := loadAdvCharacter(ctx.Sender)
|
|
if err != nil || advChar == nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't load your character.")
|
|
}
|
|
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
|
|
if err != nil || c == nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.")
|
|
}
|
|
|
|
if msg := restBlockedReason(ctx.Sender); msg != "" {
|
|
return p.SendDM(ctx.Sender, msg)
|
|
}
|
|
if c.LastLongRestAt != nil {
|
|
elapsed := time.Since(*c.LastLongRestAt)
|
|
if elapsed < dndLongRestCooldown {
|
|
remaining := dndLongRestCooldown - elapsed
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"Long rest on cooldown — %s remaining.", formatRespecDuration(remaining)))
|
|
}
|
|
}
|
|
|
|
// Eligibility: housing OR pay inn fee.
|
|
house, _ := loadHouseState(ctx.Sender)
|
|
hasHousing := house.Tier > 0
|
|
innPaid := false
|
|
if !hasHousing {
|
|
if p.euro == nil || !p.euro.Debit(ctx.Sender, float64(dndInnCost), "dnd_inn_long_rest") {
|
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
|
"You need housing or €%d for the inn at Thom Krooke's. Run `!thom` to see housing options.",
|
|
dndInnCost))
|
|
}
|
|
innPaid = true
|
|
}
|
|
|
|
c.HPCurrent = c.HPMax
|
|
c.TempHP = 0
|
|
c.ShortRestCharges = c.Level
|
|
// Phase 10 SUB2a — long rest clears one level of exhaustion (5e: a
|
|
// long rest clears one). For Berserker who racks up exhaustion via
|
|
// Frenzy, this is the recovery cadence.
|
|
if c.Exhaustion > 0 {
|
|
c.Exhaustion--
|
|
}
|
|
now := time.Now().UTC()
|
|
c.LastLongRestAt = &now
|
|
lockoutEnd := now.Add(dndLongRestLockoutHours * time.Hour)
|
|
c.RestingUntil = &lockoutEnd
|
|
|
|
if err := SaveDnDCharacter(c); err != nil {
|
|
return p.SendDM(ctx.Sender, "Couldn't save rest state: "+err.Error())
|
|
}
|
|
markActedToday(ctx.Sender)
|
|
_ = refreshAllResources(ctx.Sender)
|
|
// Phase 9: spell slots refresh on long rest.
|
|
_ = refreshSpellSlots(ctx.Sender)
|
|
// Phase 9: Cleric prep flags reset (SP4) — until SP4 ships, default
|
|
// Cleric grants are already prepared=1 so this is a no-op for them.
|
|
// Voluntary concentration ends at long rest (mage_armor's 8h is exactly
|
|
// the rest interval; resetting here keeps state clean).
|
|
c.ConcentrationSpell = ""
|
|
c.ConcentrationExpiresAt = nil
|
|
c.PendingCast = ""
|
|
_ = SaveDnDCharacter(c)
|
|
|
|
loc := "your home"
|
|
if innPaid {
|
|
loc = fmt.Sprintf("the inn (€%d spent)", dndInnCost)
|
|
}
|
|
msg := fmt.Sprintf(
|
|
"🌙 **Long rest** at %s. Full HP recovered (%d/%d). Hit-dice charges restored: **%d**.\n_You're resting — `!zone` and `!expedition` locked for 8 hours. Next long rest in 24 hours._",
|
|
loc, c.HPCurrent, c.HPMax, c.ShortRestCharges)
|
|
// HomeLongRest pool when at home; generic RestLong otherwise.
|
|
if line := dndRestLongFlavorLine(hasHousing); line != "" {
|
|
msg += "\n\n_" + line + "_"
|
|
}
|
|
return p.SendDM(ctx.Sender, msg)
|
|
}
|