mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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.
381 lines
12 KiB
Go
381 lines
12 KiB
Go
package plugin
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"math/rand/v2"
|
|
"sync"
|
|
"time"
|
|
|
|
"gogobee/internal/db"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
type advActiveEvent struct {
|
|
ID int64
|
|
UserID id.UserID
|
|
EventKey string
|
|
TriggeredAt time.Time
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// ── Event anchors ────────────────────────────────────────────────────────────
|
|
// N1/A6 — events used to roll at 0.5%/player/day from a deferred ticker slot,
|
|
// which put one sighting in front of a daily player roughly every 200 days.
|
|
// They now roll at moments the player is demonstrably present and reading a
|
|
// DM: the end-of-day digest, a sale at Thom's, and an arena cashout. Each
|
|
// player still sees at most one event per UTC day.
|
|
//
|
|
// The per-anchor chances below put a player who hits all three at ~1 event
|
|
// per week; see TestAnchoredEventWeeklyRate.
|
|
const (
|
|
advEventChanceDigest = 0.08
|
|
advEventChanceSell = 0.05
|
|
advEventChanceArena = 0.05
|
|
)
|
|
|
|
var (
|
|
advEventFiredMu sync.Mutex
|
|
advEventFired map[string]bool // userID -> an event already fired today
|
|
advEventFiredDay string // "2006-01-02" the map belongs to
|
|
)
|
|
|
|
// claimDailyEventSlot reserves today's single event slot for `userID`.
|
|
// Returns false when the player already had one fire today.
|
|
func claimDailyEventSlot(userID id.UserID, day string) bool {
|
|
advEventFiredMu.Lock()
|
|
defer advEventFiredMu.Unlock()
|
|
if advEventFiredDay != day {
|
|
advEventFired = make(map[string]bool)
|
|
advEventFiredDay = day
|
|
}
|
|
if advEventFired[string(userID)] {
|
|
return false
|
|
}
|
|
advEventFired[string(userID)] = true
|
|
return true
|
|
}
|
|
|
|
// releaseDailyEventSlot hands the slot back when the trigger bailed before
|
|
// anything reached the player, so a later anchor the same day can still fire.
|
|
func releaseDailyEventSlot(userID id.UserID) {
|
|
advEventFiredMu.Lock()
|
|
defer advEventFiredMu.Unlock()
|
|
delete(advEventFired, string(userID))
|
|
}
|
|
|
|
// maybeFireAnchoredEvent rolls `chance` at one of the A6 anchor moments and,
|
|
// on a hit, triggers the player's one mid-day event for today.
|
|
func (p *AdventurePlugin) maybeFireAnchoredEvent(userID id.UserID, chance float64) {
|
|
if rand.Float64() >= chance {
|
|
return
|
|
}
|
|
if !claimDailyEventSlot(userID, time.Now().UTC().Format("2006-01-02")) {
|
|
return
|
|
}
|
|
if !p.tryTriggerEvent(userID) {
|
|
releaseDailyEventSlot(userID)
|
|
}
|
|
}
|
|
|
|
// ── Event Ticker ─────────────────────────────────────────────────────────────
|
|
|
|
func (p *AdventurePlugin) eventTicker() {
|
|
ticker := time.NewTicker(1 * time.Minute)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-p.stopCh:
|
|
return
|
|
case <-ticker.C:
|
|
// Expire stale pending events every tick
|
|
expireAdvPendingEvents()
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
}
|
|
|
|
// tryTriggerEvent fires the player's mid-day event. Reports whether an event
|
|
// actually reached them — a false return means the caller's daily slot was
|
|
// never spent. The trigger roll itself lives in maybeFireAnchoredEvent.
|
|
func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) bool {
|
|
// Load character — must be alive.
|
|
char, err := loadAdvCharacter(userID)
|
|
if err != nil || char == nil || !char.Alive {
|
|
return false
|
|
}
|
|
|
|
// Already has an active event?
|
|
active, _ := loadAdvActiveEvent(userID)
|
|
if active != nil {
|
|
return false
|
|
}
|
|
|
|
// Mid-fight: a turn-based session locks the run. Don't drop a random
|
|
// overworld event into a live fight — the player can't act on it without
|
|
// finishing the fight first, and the trigger DM talks over the combat feed.
|
|
if hasActiveCombatSession(userID) {
|
|
return false
|
|
}
|
|
|
|
// Determine today's activity for filtering
|
|
activityType := advPlayerTodayActivity(userID)
|
|
|
|
// Pick an event
|
|
event := advPickRandomEvent(userID, activityType)
|
|
if event == nil {
|
|
return false
|
|
}
|
|
|
|
// Insert into DB
|
|
now := time.Now().UTC()
|
|
expiresAt := now.Add(2 * time.Hour)
|
|
eventID, err := insertAdvEvent(userID, event.Key, expiresAt)
|
|
if err != nil {
|
|
slog.Error("adventure: events: failed to insert event", "user", userID, "err", err)
|
|
return false
|
|
}
|
|
|
|
slog.Info("adventure: mid-day event triggered", "user", userID, "event", event.Key, "id", eventID)
|
|
|
|
displayName, _ := loadDisplayName(userID)
|
|
|
|
// DM the player
|
|
triggerDM := advSubstituteFlavor(event.TriggerDM, map[string]string{
|
|
"{name}": displayName,
|
|
})
|
|
if err := p.SendDM(userID, triggerDM); err != nil {
|
|
slog.Error("adventure: events: failed to send trigger DM", "user", userID, "err", err)
|
|
}
|
|
|
|
// Post to game room
|
|
gr := gamesRoom()
|
|
if gr != "" {
|
|
roomLine := advSubstituteFlavor(advEventRoomTriggerWrapper, map[string]string{
|
|
"{trigger_room_line}": advSubstituteFlavor(event.TriggerRoomLine, map[string]string{"{name}": displayName}),
|
|
})
|
|
_ = p.SendMessage(id.RoomID(gr), roomLine)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// handleEventRespond processes `!adventure respond`.
|
|
func (p *AdventurePlugin) handleEventRespond(ctx MessageContext) error {
|
|
mu := p.advUserLock(ctx.Sender)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
active, err := loadAdvActiveEvent(ctx.Sender)
|
|
if err != nil {
|
|
slog.Error("adventure: events: failed to load active event", "user", ctx.Sender, "err", err)
|
|
return p.SendDM(ctx.Sender, "Something went wrong checking for events. Try again in a moment.")
|
|
}
|
|
if active == nil {
|
|
return p.SendDM(ctx.Sender, "You don't have an active event to respond to.")
|
|
}
|
|
|
|
// Look up event definition
|
|
event := advFindEventByKey(active.EventKey)
|
|
if event == nil {
|
|
slog.Error("adventure: events: unknown event key in DB", "key", active.EventKey)
|
|
return p.SendDM(ctx.Sender, "Something went wrong — the event couldn't be found. This shouldn't happen.")
|
|
}
|
|
|
|
// Roll gold reward
|
|
gold := event.GoldMin
|
|
if event.GoldMax > event.GoldMin {
|
|
gold += rand.Int64N(event.GoldMax - event.GoldMin + 1)
|
|
}
|
|
|
|
// Credit gold (5% community tax)
|
|
net, _ := communityTax(ctx.Sender, float64(gold), 0.05)
|
|
p.euro.Credit(ctx.Sender, net, "adventure_event_"+event.Key)
|
|
|
|
// Apply XP if applicable
|
|
xpSkill := event.XPSkill
|
|
if xpSkill == "" && event.XP > 0 {
|
|
// For "any" events, apply XP to whatever they did today
|
|
activityType := advPlayerTodayActivity(ctx.Sender)
|
|
xpSkill = advXPSkill(AdvActivityType(activityType))
|
|
}
|
|
|
|
if event.XP > 0 && xpSkill != "" {
|
|
char, err := loadAdvCharacter(ctx.Sender)
|
|
if err == nil && char != nil {
|
|
switch xpSkill {
|
|
case "combat":
|
|
char.CombatXP += event.XP
|
|
case "mining":
|
|
char.MiningXP += event.XP
|
|
case "foraging":
|
|
char.ForagingXP += event.XP
|
|
}
|
|
checkAdvLevelUp(char, xpSkill)
|
|
_ = saveAdvCharacter(char)
|
|
_ = upsertPlayerMetaSkillState(ctx.Sender, skillStateFromAdvChar(char))
|
|
}
|
|
}
|
|
|
|
// Mark responded in DB
|
|
markAdvEventResponded(active.ID, gold, event.XP)
|
|
|
|
// Load display name for substitutions
|
|
displayName := p.DisplayName(ctx.Sender)
|
|
|
|
// Send outcome DM
|
|
goldStr := fmt.Sprintf("%d", gold)
|
|
xpStr := fmt.Sprintf("%d", event.XP)
|
|
outcomeDM := advSubstituteFlavor(event.OutcomeDM, map[string]string{
|
|
"{gold}": goldStr,
|
|
"{xp}": xpStr,
|
|
"{name}": displayName,
|
|
})
|
|
_ = p.SendDM(ctx.Sender, outcomeDM)
|
|
|
|
// Post outcome to game room
|
|
gr := gamesRoom()
|
|
if gr != "" {
|
|
xpSuffix := ""
|
|
if event.XP > 0 {
|
|
xpSuffix = fmt.Sprintf(" · +%d XP", event.XP)
|
|
}
|
|
roomLine := advSubstituteFlavor(advEventRoomOutcomeWrapper, map[string]string{
|
|
"{outcome_room_line}": advSubstituteFlavor(event.OutcomeRoomLine, map[string]string{
|
|
"{name}": displayName,
|
|
"{gold}": goldStr,
|
|
}),
|
|
"{gold}": goldStr,
|
|
"{xp_suffix}": xpSuffix,
|
|
})
|
|
_ = p.SendMessage(id.RoomID(gr), roomLine)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ── Event Selection ──────────────────────────────────────────────────────────
|
|
|
|
func advPickRandomEvent(userID id.UserID, activityType string) *AdvRandomEvent {
|
|
// Load recent event keys for dedup
|
|
recent, _ := loadAdvRecentEventKeys(userID, 10)
|
|
recentSet := make(map[string]bool, len(recent))
|
|
for _, k := range recent {
|
|
recentSet[k] = true
|
|
}
|
|
|
|
// Filter eligible events
|
|
var candidates []int
|
|
for i, e := range advRandomEvents {
|
|
if e.Activity != "any" && e.Activity != activityType {
|
|
continue
|
|
}
|
|
if recentSet[e.Key] {
|
|
continue
|
|
}
|
|
candidates = append(candidates, i)
|
|
}
|
|
|
|
if len(candidates) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return &advRandomEvents[candidates[rand.IntN(len(candidates))]]
|
|
}
|
|
|
|
func advFindEventByKey(key string) *AdvRandomEvent {
|
|
for i := range advRandomEvents {
|
|
if advRandomEvents[i].Key == key {
|
|
return &advRandomEvents[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// advPlayerTodayActivity returns the activity type string for what the player did today.
|
|
func advPlayerTodayActivity(userID id.UserID) string {
|
|
d := db.Get()
|
|
today := time.Now().UTC().Format("2006-01-02")
|
|
var actType string
|
|
err := d.QueryRow(`SELECT activity_type FROM adventure_activity_log
|
|
WHERE user_id = ? AND logged_at >= ? AND logged_at < DATE(?, '+1 day') LIMIT 1`, string(userID), today, today).Scan(&actType)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return actType
|
|
}
|
|
|
|
// ── DB Operations ────────────────────────────────────────────────────────────
|
|
|
|
func insertAdvEvent(userID id.UserID, eventKey string, expiresAt time.Time) (int64, error) {
|
|
d := db.Get()
|
|
res, err := d.Exec(`INSERT INTO adventure_events_log (user_id, event_key, expires_at)
|
|
VALUES (?, ?, ?)`, string(userID), eventKey, expiresAt)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
func loadAdvActiveEvent(userID id.UserID) (*advActiveEvent, error) {
|
|
d := db.Get()
|
|
var e advActiveEvent
|
|
err := d.QueryRow(`SELECT id, user_id, event_key, triggered_at, expires_at
|
|
FROM adventure_events_log
|
|
WHERE user_id = ? AND outcome = 'pending' AND expires_at > CURRENT_TIMESTAMP
|
|
ORDER BY triggered_at DESC LIMIT 1`, string(userID)).Scan(
|
|
&e.ID, &e.UserID, &e.EventKey, &e.TriggeredAt, &e.ExpiresAt)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &e, nil
|
|
}
|
|
|
|
func markAdvEventResponded(eventID int64, goldAwarded int64, xpAwarded int) {
|
|
db.Exec("adventure: mark event responded",
|
|
`UPDATE adventure_events_log
|
|
SET outcome = 'responded', responded_at = CURRENT_TIMESTAMP,
|
|
gold_awarded = ?, xp_awarded = ?
|
|
WHERE id = ?`, goldAwarded, xpAwarded, eventID)
|
|
}
|
|
|
|
func expireAdvPendingEvents() {
|
|
db.Exec("adventure: expire pending events",
|
|
`UPDATE adventure_events_log SET outcome = 'expired'
|
|
WHERE outcome = 'pending' AND expires_at < CURRENT_TIMESTAMP`)
|
|
}
|
|
|
|
func loadAdvRecentEventKeys(userID id.UserID, limit int) ([]string, error) {
|
|
d := db.Get()
|
|
rows, err := d.Query(`SELECT event_key FROM adventure_events_log
|
|
WHERE user_id = ? ORDER BY triggered_at DESC LIMIT ?`, string(userID), limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var keys []string
|
|
for rows.Next() {
|
|
var k string
|
|
if err := rows.Scan(&k); err != nil {
|
|
return nil, err
|
|
}
|
|
keys = append(keys, k)
|
|
}
|
|
return keys, rows.Err()
|
|
}
|