mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Diagnosed a cleric "death loop" (L14 dying at T2/T3 bosses while over-levelled): the boss isn't overtuned — caster sustained DPS is under-delivered, compounded by a fragile healer build. Engine fix — concentration AOE re-tick: - Concentration damage spells (spirit_guardians, heat_metal, spike_growth, call_lightning, flaming_sphere) now tick the enemy every round at round_end instead of resolving as a one-shot, via a new CombatStatuses.ConcentrationDmg armed on cast and round-tripped through the turn engine. Closes the long-tracked turn-engine concentration gap; the burst still lands the casting round, then the aura lingers. - Sim picker skips re-casting an already-active aura (models competent play and prevents a burst+aura double-dip). Re-baseline (n=30 sweep + n=100 confirm): bard +47pp T3 (heat_metal), druid +3-7, cleric/mage flat, fighter unchanged — no regressions. Player-data bootstraps (idempotent, run once on Init): - bootstrapCasterSpellBackfill: ensureSpellsForCharacter only seeds an empty book, so defaults added after a character's roll never reach it. Backfill missing defaults into known+prepared for existing casters (gives the affected cleric inflict_wounds + a working healing_word, since her healing_word_spell is a dead alias). - bootstrapGrantStarterPet: one-off L10 pet for an endgame player who never got the morning arrival roll; adds per-round proc damage + deflect. - TestScenario_JosieCasterAid verifies both against a copy of the live DB, incl. idempotency. Also fix a pre-existing wall-clock flake in TestFireBriefings_EventAnchoredActivePlayerDelivers (start_date defaulted to real now, filtering the row out when the suite runs after 06:00 UTC).
145 lines
5.0 KiB
Go
145 lines
5.0 KiB
Go
package plugin
|
|
|
|
import (
|
|
"log/slog"
|
|
"time"
|
|
|
|
"gogobee/internal/db"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// Two one-shot startup bootstraps that lift a low-DPS caster who was stuck in a
|
|
// boss-wall "death loop". Diagnosis: the boss isn't overtuned — the player is an
|
|
// over-levelled cleric whose damage output is structurally low. Two account
|
|
// gaps, fixed idempotently here so they reach the live player without a respec.
|
|
//
|
|
// 1. bootstrapCasterSpellBackfill — characters created before a default spell
|
|
// was added to defaultKnownSpells keep their original spellbook forever:
|
|
// ensureSpellsForCharacter only seeds when the known-spell list is EMPTY, so
|
|
// later default additions never reach existing casters. This backfills any
|
|
// missing default into known+prepared (e.g. inflict_wounds, added to the
|
|
// cleric defaults after the affected character was rolled). General + future
|
|
// proof — it fixes any caster with the same stale-default gap.
|
|
//
|
|
// 2. bootstrapGrantStarterPet — a targeted gift of a combat companion to a
|
|
// specific endgame player who never received the 25% morning pet-arrival
|
|
// roll. A pet adds sustained per-round damage + deflect mitigation, which
|
|
// helps caster trailers most.
|
|
|
|
// bootstrapCasterSpellBackfill adds any missing defaultKnownSpells entry to
|
|
// every existing caster as known+prepared. addKnownSpell is idempotent and
|
|
// leaves the prepared flag of already-known spells untouched (ON CONFLICT only
|
|
// refreshes source), so this only ever adds the genuinely-missing defaults.
|
|
func bootstrapCasterSpellBackfill() {
|
|
const jobName = "caster_default_spell_backfill_v1"
|
|
if db.JobCompleted(jobName, "once") {
|
|
return
|
|
}
|
|
|
|
chars, err := loadAllAdvCharacters()
|
|
if err != nil {
|
|
slog.Error("bootstrap: caster spell backfill — load characters failed", "err", err)
|
|
return
|
|
}
|
|
|
|
added := 0
|
|
for _, ac := range chars {
|
|
c, err := LoadDnDCharacter(ac.UserID)
|
|
if err != nil || c == nil || !isSpellcaster(c) {
|
|
continue
|
|
}
|
|
known, err := listKnownSpells(c.UserID)
|
|
if err != nil {
|
|
slog.Warn("bootstrap: caster spell backfill — list known failed", "user", c.UserID, "err", err)
|
|
continue
|
|
}
|
|
have := make(map[string]bool, len(known))
|
|
for _, k := range known {
|
|
have[k.SpellID] = true
|
|
}
|
|
defaults := defaultKnownSpells(c.Class, c.Level)
|
|
if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster {
|
|
defaults = arcaneTricksterDefaultSpells(c.Level)
|
|
}
|
|
for _, sid := range defaults {
|
|
if have[sid] {
|
|
continue
|
|
}
|
|
if err := addKnownSpell(c.UserID, sid, "class", true); err != nil {
|
|
slog.Error("bootstrap: caster spell backfill — add failed", "user", c.UserID, "spell", sid, "err", err)
|
|
continue
|
|
}
|
|
slog.Info("bootstrap: backfilled default spell", "user", c.UserID, "spell", sid)
|
|
added++
|
|
}
|
|
}
|
|
|
|
db.MarkJobCompleted(jobName, "once")
|
|
if added > 0 {
|
|
slog.Warn("bootstrap: caster default-spell backfill complete", "spells_added", added)
|
|
}
|
|
}
|
|
|
|
// josieStarterPet identifies the one player the pet gift targets and the pet
|
|
// it grants. Kept as data so the intent is legible: this is an admin gift, not
|
|
// a game-wide policy.
|
|
var josieStarterPet = struct {
|
|
userID id.UserID
|
|
typ string
|
|
name string
|
|
level int
|
|
}{
|
|
userID: "@holymachina:parodia.dev",
|
|
typ: "dog",
|
|
name: "Biscuit",
|
|
level: 10,
|
|
}
|
|
|
|
// bootstrapGrantStarterPet gives the targeted player a combat companion if they
|
|
// have none. No-op once they have a pet (this gift, a later arrival, or one
|
|
// chased away — we don't override the player's own pet history). Idempotent via
|
|
// the job gate AND the has-pet guard.
|
|
func bootstrapGrantStarterPet() {
|
|
const jobName = "grant_starter_pet_holymachina_v1"
|
|
if db.JobCompleted(jobName, "once") {
|
|
return
|
|
}
|
|
g := josieStarterPet
|
|
|
|
char, err := loadAdvCharacter(g.userID)
|
|
if err != nil || char == nil {
|
|
// Target not present in this DB (e.g. fresh deploy) — mark done so we
|
|
// don't re-scan every startup; the gift is a one-off, not a standing rule.
|
|
db.MarkJobCompleted(jobName, "once")
|
|
return
|
|
}
|
|
if char.PetType != "" || char.PetArrived {
|
|
slog.Info("bootstrap: starter pet — target already has a pet, skipping", "user", g.userID)
|
|
db.MarkJobCompleted(jobName, "once")
|
|
return
|
|
}
|
|
|
|
char.PetType = g.typ
|
|
char.PetName = g.name
|
|
char.PetLevel = g.level
|
|
char.PetXP = 0
|
|
char.PetArrived = true
|
|
char.PetChasedAway = false
|
|
if g.level >= 10 {
|
|
// Mirror the babysit path that stamps the L10 date when a pet first
|
|
// reaches the cap, so milestone/supply-shop logic stays consistent.
|
|
char.PetLevel10Date = time.Now().UTC().Format("2006-01-02")
|
|
}
|
|
if err := saveAdvCharacter(char); err != nil {
|
|
slog.Error("bootstrap: starter pet — save failed", "user", g.userID, "err", err)
|
|
return
|
|
}
|
|
if err := upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char)); err != nil {
|
|
slog.Error("bootstrap: starter pet — player_meta mirror failed", "user", g.userID, "err", err)
|
|
return
|
|
}
|
|
|
|
db.MarkJobCompleted(jobName, "once")
|
|
slog.Warn("bootstrap: granted starter pet", "user", g.userID, "pet", g.name, "level", g.level)
|
|
}
|