mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 00:52:40 +00:00
Lift caster trailers: concentration re-tick + Josie caster-aid bootstraps
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).
This commit is contained in:
144
internal/plugin/bootstrap_josie_caster_aid.go
Normal file
144
internal/plugin/bootstrap_josie_caster_aid.go
Normal file
@@ -0,0 +1,144 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user