mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
- emitZoneClearNews: claim realm-first before the name guard so an unnamed straggler's first clear seeds news_realm_firsts (else the next named clearer is mis-announced as first-ever). Mirrors backfillZoneFirsts. - RunMaintenance: reap permanently-parked pete_emit_queue rows (unsent, >30d) so a durable Pete outage can't accrete rows forever. - emitDeathNews: gate on Enabled()/newsEmissionOn() before the char DB reads; markAdventureDead fires per-member on party wipes + in the sim.
608 lines
21 KiB
Go
608 lines
21 KiB
Go
package plugin
|
||
|
||
import (
|
||
"fmt"
|
||
"log/slog"
|
||
"time"
|
||
|
||
"gogobee/internal/db"
|
||
"gogobee/internal/peteclient"
|
||
"maunium.net/go/mautrix/id"
|
||
)
|
||
|
||
// D&D combat layer.
|
||
//
|
||
// applyDnDPlayerLayer reseats stats.MaxHP onto the dnd_character HP scale
|
||
// (c.HPMax + the gear/arena/housing bonus DerivePlayerStats captured in
|
||
// HPBonus). Wound carry-over via applyDnDHPScaling is a direct integer
|
||
// copy — there is no longer a percentage bridge between scales.
|
||
|
||
// ── Tunable constants ────────────────────────────────────────────────────────
|
||
|
||
// Class-derived "weapon proficiency" bonus baked into AttackBonus.
|
||
// Fighter is a martial class; Mage uses spell-attack baseline.
|
||
var dndClassWeaponBonus = map[DnDClass]int{
|
||
ClassFighter: 2,
|
||
ClassPaladin: 2,
|
||
ClassRanger: 1,
|
||
ClassRogue: 1,
|
||
ClassDruid: 1,
|
||
ClassBard: 1,
|
||
ClassCleric: 0,
|
||
ClassMage: 0,
|
||
ClassSorcerer: 0,
|
||
ClassWarlock: 0,
|
||
}
|
||
|
||
// Monster AC formulas. Tuned so a typical L1 player (+5 attack bonus) hits
|
||
// roughly 70-80% at low tiers and 50-60% at high tiers. Adjust after live
|
||
// data lands.
|
||
const (
|
||
dndArenaACBase = 10
|
||
dndArenaACPerThreat = 0.25 // ThreatLevel 0..30 → AC bump 0..7
|
||
dndDungeonACBase = 9
|
||
// Dungeon AC scales linearly with tier: T1=10, T5=14
|
||
)
|
||
|
||
// Monster attack bonus. Counters player AC growth. T1 monster +5, T5 +9.
|
||
const (
|
||
dndArenaAtkBase = 4
|
||
dndArenaAtkPerThreat = 0.30
|
||
dndDungeonAtkBase = 4
|
||
// T1 dungeon +5, T5 +9
|
||
)
|
||
|
||
// ── Player layer ─────────────────────────────────────────────────────────────
|
||
|
||
// proficiencyBonus implements ceil(level/4) + 1, matching D&D5e (L1-4 = +2,
|
||
// L5-8 = +3, L9-12 = +4, ...).
|
||
func proficiencyBonus(level int) int {
|
||
if level < 1 {
|
||
level = 1
|
||
}
|
||
return (level-1)/4 + 2
|
||
}
|
||
|
||
// classAttackStatMod returns the ability modifier the class uses for its
|
||
// primary attack roll. Fighters use STR (melee), Rogue/Ranger use DEX, Mage
|
||
// uses INT (spell attack), Cleric uses WIS.
|
||
func classAttackStatMod(c *DnDCharacter) int {
|
||
switch c.Class {
|
||
case ClassFighter, ClassPaladin:
|
||
return abilityModifier(c.STR)
|
||
case ClassRogue, ClassRanger:
|
||
return abilityModifier(c.DEX)
|
||
case ClassMage:
|
||
return abilityModifier(c.INT)
|
||
case ClassCleric, ClassDruid:
|
||
return abilityModifier(c.WIS)
|
||
case ClassBard, ClassSorcerer, ClassWarlock:
|
||
return abilityModifier(c.CHA)
|
||
}
|
||
return abilityModifier(c.STR)
|
||
}
|
||
|
||
// dndPlayerAttackBonus = primary stat mod + proficiency + class weapon bonus.
|
||
func dndPlayerAttackBonus(c *DnDCharacter) int {
|
||
return classAttackStatMod(c) + proficiencyBonus(c.Level) + dndClassWeaponBonus[c.Class]
|
||
}
|
||
|
||
// applyDnDPlayerLayer sets HP/AC/AttackBonus on a stat block from the
|
||
// player's D&D character. Combat MaxHP is the sheet HPMax plus the
|
||
// equipment/arena/housing bonus DerivePlayerStats accumulated into
|
||
// stats.HPBonus — so dnd_character.hp_current can be the single source of
|
||
// truth for wounds while gear keeps adding flat HP to the fight.
|
||
//
|
||
// Phase 8 wires equipped weapon/armor profiles in via applyDnDEquipmentLayer.
|
||
func applyDnDPlayerLayer(stats *CombatStats, c *DnDCharacter) {
|
||
stats.MaxHP = c.HPMax + stats.HPBonus
|
||
stats.AC = c.ArmorClass
|
||
stats.AttackBonus = dndPlayerAttackBonus(c)
|
||
}
|
||
|
||
// applyDnDEquipmentLayer populates the Phase 8 equipment-driven fields on
|
||
// CombatStats from synthesized profiles of the player's legacy gear. Also
|
||
// overrides stats.AC with the equipment-derived computation when an armor
|
||
// or shield is equipped.
|
||
//
|
||
// `equip` is the legacy adventure_equipment map; the synthesizer infers a
|
||
// D&D weapon/armor profile from slot+tier+name without requiring a loot
|
||
// migration. Helmets, boots, and non-shield tools have no D&D profile yet
|
||
// and don't affect this layer.
|
||
func applyDnDEquipmentLayer(stats *CombatStats, c *DnDCharacter, equip map[EquipmentSlot]*AdvEquipment) {
|
||
if c == nil {
|
||
return
|
||
}
|
||
// Weapon synthesis.
|
||
weapon := synthesizeWeaponProfile(equip[SlotWeapon])
|
||
if weapon != nil {
|
||
stats.Weapon = weapon
|
||
// Pick STR vs DEX modifier per finesse rule and class default.
|
||
stats.AbilityModForDamage = pickWeaponAbilityMod(weapon, c)
|
||
stats.WeaponProficient = dndClassWeaponProficiency(c.Class, weapon)
|
||
// Magic bonus on the weapon also stacks onto AttackBonus.
|
||
stats.AttackBonus += weapon.MagicBonus
|
||
// Two-handed when the weapon has the property AND no shield is held.
|
||
hasShield := synthesizeShield(equip[SlotTool]) != nil
|
||
if weapon.HasProperty(PropTwoHanded) || (weapon.HasProperty(PropVersatile) && !hasShield) {
|
||
stats.TwoHandedMode = true
|
||
}
|
||
}
|
||
|
||
// Armor + shield → AC override per appendix.
|
||
armor := synthesizeArmorProfile(equip[SlotArmor])
|
||
shield := synthesizeShield(equip[SlotTool])
|
||
// Two-handed weapons forbid shields per appendix §5.4.
|
||
if weapon != nil && weapon.HasProperty(PropTwoHanded) {
|
||
shield = nil
|
||
}
|
||
dexMod := abilityModifier(c.DEX)
|
||
// Heavy armor STR-requirement penalty: -2 to DEX-based rolls if STR < req.
|
||
// In our model, AC is the prominent DEX-derived value; honoring this
|
||
// strictly would penalize attack rolls (DEX-attacking finesse/ranged) too.
|
||
// For the AC computation we just clamp the dex bonus to 0 anyway when
|
||
// armor is heavy (MaxDEXBonus=0), so the STR check has no AC effect —
|
||
// it would only matter to attack rolls. Skip the penalty for now;
|
||
// document it as a future refinement.
|
||
if armor != nil || shield != nil {
|
||
stats.AC = computeArmorAC(armor, shield, dexMod)
|
||
}
|
||
|
||
// Phase 5-B player power floor — see applyPhase5BPlayerFloor doc.
|
||
// Lives at the end of the equipment layer so it lands AFTER the AC
|
||
// override above (otherwise the +3 AC bonus from the floor would be
|
||
// stomped by computeArmorAC's overwrite).
|
||
applyPhase5BPlayerFloor(stats)
|
||
}
|
||
|
||
// Phase 5-B player power floor — uniform combat-stat lift added on top
|
||
// of class + equipment to land the expedition harness in the band the
|
||
// user chose for the "fairly breezy with some death" difficulty target.
|
||
// See gogobee_expedition_difficulty.md Phase 5-B for the sweep that
|
||
// picked these values (gear floor +3, paired with HP ×phase5BHPMult in
|
||
// computeMaxHP).
|
||
//
|
||
// Applied as a *flat* lift, not via gearTier — keeps the in-game gear
|
||
// narrative untouched (a player's +1 longsword still reads as +1 in
|
||
// their inventory; the floor stacks invisibly at combat-stat time).
|
||
// Uniform across classes so the class-balance harness's in-tier parity
|
||
// spread is unaffected.
|
||
//
|
||
// Called from both applyDnDEquipmentLayer (live combat) and
|
||
// buildHarnessPlayer (expedition / class-balance harnesses) so the
|
||
// harness's measurement matches what live players experience.
|
||
const (
|
||
phase5BACBonus = 3
|
||
phase5BAttackBonus = 3
|
||
phase5BWeaponMagicBonus = 3
|
||
)
|
||
|
||
func applyPhase5BPlayerFloor(stats *CombatStats) {
|
||
stats.AC += phase5BACBonus
|
||
stats.AttackBonus += phase5BAttackBonus
|
||
if stats.Weapon != nil {
|
||
// Mutate the weapon copy in place — applyDnDEquipmentLayer and
|
||
// buildHarnessPlayer both already hand us a fresh copy (the
|
||
// synthesize* / classLoadout chain returns a new profile), so
|
||
// this doesn't leak into the registry.
|
||
stats.Weapon.MagicBonus += phase5BWeaponMagicBonus
|
||
}
|
||
}
|
||
|
||
// pickWeaponAbilityMod returns the ability modifier added to weapon damage:
|
||
// STR for melee unless the weapon is finesse/ranged and DEX is higher (then DEX).
|
||
func pickWeaponAbilityMod(w *WeaponProfile, c *DnDCharacter) int {
|
||
str, dex := abilityModifier(c.STR), abilityModifier(c.DEX)
|
||
switch w.Category {
|
||
case WeaponCatSimpleRanged, WeaponCatMartialRanged:
|
||
return dex
|
||
}
|
||
// Melee: finesse picks the better of STR/DEX.
|
||
if w.HasProperty(PropFinesse) {
|
||
if dex > str {
|
||
return dex
|
||
}
|
||
}
|
||
return str
|
||
}
|
||
|
||
// ── Monster layer ────────────────────────────────────────────────────────────
|
||
|
||
func applyDnDArenaMonsterLayer(stats *CombatStats, threatLevel int) {
|
||
stats.AC = dndArenaACBase + int(float64(threatLevel)*dndArenaACPerThreat)
|
||
stats.AttackBonus = dndArenaAtkBase + int(float64(threatLevel)*dndArenaAtkPerThreat)
|
||
}
|
||
|
||
func applyDnDDungeonMonsterLayer(stats *CombatStats, tier int) {
|
||
stats.AC = dndDungeonACBase + tier
|
||
stats.AttackBonus = dndDungeonAtkBase + tier
|
||
}
|
||
|
||
// ── Auto-migration on first combat ───────────────────────────────────────────
|
||
|
||
// classStatPriority returns the standard-array values {15,14,13,12,10,8}
|
||
// assigned to STR/DEX/CON/INT/WIS/CHA in the order the class cares about.
|
||
// Used by ensureDnDCharacterForCombat — players can rebuild later via !setup
|
||
// (which is allowed without respec cooldown when auto_migrated=1).
|
||
func classStatPriority(class DnDClass) [6]int {
|
||
// Returned array is in STR, DEX, CON, INT, WIS, CHA order.
|
||
//
|
||
// Design note: every class gets DEX ≥ 14 baseline. DEX drives AC (via
|
||
// armor synthesis) and initiative, so dumping it leaves players in
|
||
// armor dead-zones where T3 chain shirt provides no AC over their
|
||
// class floor. The previous Cleric DEX=10 / Mage DEX=12 spread was
|
||
// faithful to standard array prioritization but produced unfun
|
||
// outcomes — gear upgrades didn't visibly help. Pumping the floor
|
||
// trades canonical purity for "armor upgrades feel like upgrades."
|
||
switch class {
|
||
case ClassFighter:
|
||
return [6]int{15, 14, 13, 8, 12, 10} // STR, DEX, CON
|
||
case ClassRogue:
|
||
return [6]int{8, 15, 13, 14, 10, 12} // DEX, INT, CON
|
||
case ClassMage:
|
||
return [6]int{8, 14, 13, 15, 12, 10} // INT, DEX, CON
|
||
case ClassCleric:
|
||
return [6]int{12, 14, 13, 8, 15, 10} // WIS, DEX, CON
|
||
case ClassRanger:
|
||
return [6]int{12, 15, 13, 10, 14, 8} // DEX, WIS, CON
|
||
case ClassDruid:
|
||
return [6]int{8, 14, 13, 10, 15, 12} // WIS, DEX, CON
|
||
case ClassBard:
|
||
return [6]int{8, 14, 13, 10, 12, 15} // CHA, DEX, CON
|
||
case ClassSorcerer:
|
||
return [6]int{8, 14, 13, 12, 10, 15} // CHA, DEX, CON
|
||
case ClassWarlock:
|
||
return [6]int{8, 14, 13, 12, 10, 15} // CHA, DEX, CON
|
||
case ClassPaladin:
|
||
return [6]int{15, 14, 13, 8, 10, 12} // STR, DEX, CON
|
||
}
|
||
return [6]int{15, 14, 13, 12, 10, 8} // fallback: martial-ish
|
||
}
|
||
|
||
// ensureCharForDnDCmd is the helper non-combat D&D command handlers should
|
||
// use when they want auto-migration semantics. Combat paths in
|
||
// combat_bridge.go use ensureDnDCharacterForCombat directly.
|
||
func (p *AdventurePlugin) ensureCharForDnDCmd(userID id.UserID, char *AdventureCharacter) (*DnDCharacter, error) {
|
||
c, _, err := ensureDnDCharacterForCombat(userID, char)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return c, nil
|
||
}
|
||
|
||
// ensureDnDCharacterForCombat returns a usable D&D character for combat. If
|
||
// the player already has a confirmed sheet, it's returned. Otherwise an
|
||
// auto-migrated character is created using:
|
||
//
|
||
// - Race/class inferred from archetypes (Human Fighter fallback)
|
||
// - Class-tuned standard array assignment
|
||
// - Racial modifiers applied
|
||
// - dnd_level seeded from the legacy combat_level
|
||
// - HP/AC computed from class + ability scores + level
|
||
//
|
||
// auto_migrated=1 is set on the row so !setup can freely overwrite it
|
||
// without consuming the !respec cooldown.
|
||
//
|
||
// Returns (character, freshlyMigrated, err). freshlyMigrated is true only
|
||
// on the first call that creates the row — used by callers to fire the
|
||
// one-shot onboarding DM for legacy players.
|
||
func ensureDnDCharacterForCombat(userID id.UserID, char *AdventureCharacter) (*DnDCharacter, bool, error) {
|
||
existing, err := LoadDnDCharacter(userID)
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
if existing != nil && !existing.PendingSetup {
|
||
return existing, false, nil
|
||
}
|
||
// existing == nil OR pending_setup=1. In the pending case we leave the
|
||
// player's draft alone and overlay a temporary auto-migrated working
|
||
// character — the draft survives untouched in the DB, but the fight
|
||
// they're trying to start gets a usable sheet *for this fight only*.
|
||
// (We don't write — pending_setup=1 stays so they can finish !setup.)
|
||
if existing != nil && existing.PendingSetup {
|
||
return autoBuildCharacter(userID, char), false, nil
|
||
}
|
||
|
||
// Fresh auto-migration. Build, save, return.
|
||
c := autoBuildCharacter(userID, char)
|
||
c.AutoMigrated = true
|
||
c.PendingSetup = false
|
||
// Seed the canonical player_meta row before handing back a confirmed
|
||
// character. Auto-migration otherwise mints a player_meta-less character
|
||
// (the camcast straggler) that fails every legacy-layer command with
|
||
// "sql: no rows". No-op for legacy players who already have the row.
|
||
if err := ensurePlayerMetaSeed(userID); err != nil {
|
||
return nil, false, fmt.Errorf("seed player_meta: %w", err)
|
||
}
|
||
if err := SaveDnDCharacter(c); err != nil {
|
||
return nil, false, err
|
||
}
|
||
_ = initResources(userID, c.Class)
|
||
// Phase 9: caster auto-migrants get a starter spell list + slot pool so
|
||
// !cast/!spells work the moment they land. Idempotent.
|
||
_ = ensureSpellsForCharacter(c)
|
||
slog.Info("dnd: auto-migrated character", "user", userID,
|
||
"race", c.Race, "class", c.Class, "level", c.Level)
|
||
return c, true, nil
|
||
}
|
||
|
||
// autoBuildCharacter constructs a complete DnDCharacter from archetype
|
||
// inference + the player's adventure state. Does not save.
|
||
func autoBuildCharacter(userID id.UserID, char *AdventureCharacter) *DnDCharacter {
|
||
sug := inferDnDFromArchetypes(userID)
|
||
scores := classStatPriority(sug.Class)
|
||
scores = applyRaceMods(sug.Race, scores)
|
||
|
||
level := 1
|
||
if char != nil {
|
||
level = dndLevelFromCombatLevel(char.CombatLevel)
|
||
}
|
||
|
||
c := &DnDCharacter{
|
||
UserID: userID,
|
||
Race: sug.Race,
|
||
Class: sug.Class,
|
||
Level: level,
|
||
STR: scores[0], DEX: scores[1], CON: scores[2],
|
||
INT: scores[3], WIS: scores[4], CHA: scores[5],
|
||
PendingSetup: false,
|
||
AutoMigrated: true,
|
||
CreatedAt: time.Now().UTC(),
|
||
UpdatedAt: time.Now().UTC(),
|
||
}
|
||
conMod := abilityModifier(c.CON)
|
||
dexMod := abilityModifier(c.DEX)
|
||
c.HPMax = computeMaxHP(c.Class, conMod, c.Level)
|
||
c.HPCurrent = c.HPMax
|
||
c.ArmorClass = computeAC(c.Class, dexMod)
|
||
c.ShortRestCharges = c.Level
|
||
return c
|
||
}
|
||
|
||
// ── Roll summary line ────────────────────────────────────────────────────────
|
||
|
||
// dndRollSummaryLine scans a CombatResult's events for d20 rolls and returns
|
||
// a one-liner summarizing the player's accuracy. Returns "" if no D&D rolls
|
||
// are present (legacy combat, or a fight that resolved on consumables alone).
|
||
//
|
||
// Format: "🎲 d20 — 5/8 hit (2 crits, 1 fumble). Best: nat 20."
|
||
//
|
||
// Surfaced post-combat by arena and dungeon callers; keeps the d20 system
|
||
// visible without modifying combat_narrative.go's flavor pools.
|
||
func dndRollSummaryLine(result CombatResult) string {
|
||
hits, misses, crits, fumbles := 0, 0, 0, 0
|
||
bestRoll, bestSeen := 0, false
|
||
for _, ev := range result.Events {
|
||
if ev.Actor != "player" || ev.Roll == 0 {
|
||
continue
|
||
}
|
||
switch ev.Action {
|
||
case "hit", "block":
|
||
hits++
|
||
case "crit":
|
||
hits++
|
||
crits++
|
||
case "miss":
|
||
misses++
|
||
if ev.Desc == "fumble" {
|
||
fumbles++
|
||
}
|
||
default:
|
||
continue
|
||
}
|
||
if !bestSeen || ev.Roll > bestRoll {
|
||
bestRoll = ev.Roll
|
||
bestSeen = true
|
||
}
|
||
}
|
||
total := hits + misses
|
||
if total == 0 {
|
||
return ""
|
||
}
|
||
|
||
var b []byte
|
||
b = append(b, []byte("🎲 d20 — ")...)
|
||
b = appendInt(b, hits)
|
||
b = append(b, '/')
|
||
b = appendInt(b, total)
|
||
b = append(b, []byte(" hit")...)
|
||
|
||
notes := []string{}
|
||
if crits > 0 {
|
||
notes = append(notes, formatN(crits, "crit"))
|
||
}
|
||
if fumbles > 0 {
|
||
notes = append(notes, formatN(fumbles, "fumble"))
|
||
}
|
||
if len(notes) > 0 {
|
||
b = append(b, []byte(" (")...)
|
||
for i, n := range notes {
|
||
if i > 0 {
|
||
b = append(b, []byte(", ")...)
|
||
}
|
||
b = append(b, []byte(n)...)
|
||
}
|
||
b = append(b, ')')
|
||
}
|
||
if bestSeen {
|
||
b = append(b, []byte(". Best: ")...)
|
||
if bestRoll == 20 {
|
||
b = append(b, []byte("nat 20!")...)
|
||
} else {
|
||
b = appendInt(b, bestRoll)
|
||
b = append(b, '.')
|
||
}
|
||
} else {
|
||
b = append(b, '.')
|
||
}
|
||
// Append narrative for nat 20 / nat 1 occurrences. One line per fight
|
||
// regardless of how many rolled — avoids spam.
|
||
sawNat20, sawNat1 := bestRoll == 20, fumbles > 0
|
||
if sawNat20 {
|
||
if line := dndNat20Line(); line != "" {
|
||
b = append(b, []byte("\n_")...)
|
||
b = append(b, []byte(line)...)
|
||
b = append(b, '_')
|
||
}
|
||
}
|
||
if sawNat1 && !sawNat20 {
|
||
if line := dndNat1Line(); line != "" {
|
||
b = append(b, []byte("\n_")...)
|
||
b = append(b, []byte(line)...)
|
||
b = append(b, '_')
|
||
}
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
func appendInt(b []byte, n int) []byte {
|
||
if n == 0 {
|
||
return append(b, '0')
|
||
}
|
||
if n < 0 {
|
||
b = append(b, '-')
|
||
n = -n
|
||
}
|
||
digits := []byte{}
|
||
for n > 0 {
|
||
digits = append([]byte{byte('0' + n%10)}, digits...)
|
||
n /= 10
|
||
}
|
||
return append(b, digits...)
|
||
}
|
||
|
||
func formatN(n int, word string) string {
|
||
if n == 1 {
|
||
return "1 " + word
|
||
}
|
||
out := []byte{}
|
||
out = appendInt(out, n)
|
||
out = append(out, ' ')
|
||
out = append(out, []byte(word+"s")...)
|
||
return string(out)
|
||
}
|
||
|
||
// ── Combat HP carry-over ─────────────────────────────────────────────────────
|
||
|
||
// applyDnDHPScaling sets stats.StartHP from the player's persisted wounds.
|
||
// A full-HP character enters at MaxHP (StartHP=0 means "use MaxHP"); a
|
||
// wounded character enters at c.HPCurrent + HPBonus — the persistent dnd
|
||
// wound layered with the fresh equipment cushion. There is no scale
|
||
// conversion; persistDnDHPAfterCombat is the inverse direct copy.
|
||
func applyDnDHPScaling(stats *CombatStats, c *DnDCharacter) {
|
||
if c == nil || c.HPMax <= 0 {
|
||
return
|
||
}
|
||
// Well-rested temporary HP (from a long rest at a T2+ home) is a cushion
|
||
// layered above the fight's MaxHP. It is dormant (0) for anyone not
|
||
// currently rested — including every scenario in the balance corpus and
|
||
// the class-balance harness, which never sets it — so the full-HP fast
|
||
// path below stays byte-identical for them and the golden does not move.
|
||
if c.TempHP > 0 {
|
||
stats.MaxHP += c.TempHP
|
||
}
|
||
if c.HPCurrent >= c.HPMax {
|
||
// Full HP: StartHP=0 sentinel already means "enter at MaxHP", which
|
||
// now includes the +TempHP cushion.
|
||
return
|
||
}
|
||
startHP := c.HPCurrent + stats.HPBonus + c.TempHP
|
||
if startHP < 1 {
|
||
startHP = 1
|
||
}
|
||
if startHP > stats.MaxHP {
|
||
startHP = stats.MaxHP
|
||
}
|
||
stats.StartHP = startHP
|
||
}
|
||
|
||
// ── HP persistence ───────────────────────────────────────────────────────────
|
||
|
||
// persistDnDHPAfterCombat copies endHP into dnd_character.hp_current,
|
||
// clamped to [0, c.HPMax]. The HPBonus from gear is fight-only and does
|
||
// not carry over — anything above c.HPMax is treated as unused armor
|
||
// cushion and clamped down. No-op if the row is missing or pending setup.
|
||
func persistDnDHPAfterCombat(userID id.UserID, endHP int) {
|
||
c, err := LoadDnDCharacter(userID)
|
||
if err != nil || c == nil || c.PendingSetup {
|
||
return
|
||
}
|
||
if c.HPMax <= 0 {
|
||
return
|
||
}
|
||
newHP := endHP
|
||
if newHP < 0 {
|
||
newHP = 0
|
||
}
|
||
if newHP > c.HPMax {
|
||
newHP = c.HPMax
|
||
}
|
||
|
||
if _, err := db.Get().Exec(
|
||
`UPDATE dnd_character SET hp_current = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?`,
|
||
newHP, string(userID),
|
||
); err != nil {
|
||
slog.Error("dnd: persist hp after combat", "user", userID, "err", err)
|
||
}
|
||
}
|
||
|
||
// dndHPSnapshot returns the user's persisted (current, max) HP. Combat
|
||
// callers snapshot pre-combat, run the fight (persistDnDHPAfterCombat
|
||
// updates hp_current), then re-snapshot for the post values to render
|
||
// the sheet's wound state in narration.
|
||
func dndHPSnapshot(userID id.UserID) (cur, max int) {
|
||
c, err := LoadDnDCharacter(userID)
|
||
if err != nil || c == nil {
|
||
return 0, 0
|
||
}
|
||
return c.HPCurrent, c.HPMax
|
||
}
|
||
|
||
// markAdventureDead flips Alive=false in player_meta (via saveAdvCharacter
|
||
// fan-out) and starts the 6h respawn timer for a player who went down in a
|
||
// D&D-layer combat. Without this, hp_current persists as 0 but Alive stays
|
||
// true — the "zombie" state where !hospital says "you're alive!" while the
|
||
// sheet shows 0/33. Idempotent: bails if already dead.
|
||
//
|
||
// source is "zone" / "expedition" / "patrol"; location is human-readable
|
||
// (e.g. "Forest of Shadows") and surfaces in the daily report and
|
||
// standout-loss flavor.
|
||
func markAdventureDead(userID id.UserID, source, location string) {
|
||
char, err := loadAdvCharacter(userID)
|
||
if err != nil || char == nil || !char.Alive {
|
||
return
|
||
}
|
||
char.Kill(source, location)
|
||
if err := saveAdvCharacter(char); err != nil {
|
||
slog.Error("dnd: kill on combat loss", "user", userID, "err", err)
|
||
}
|
||
emitDeathNews(userID, location)
|
||
}
|
||
|
||
// emitDeathNews files a PRIORITY death dispatch to Pete's adventure news. No-op
|
||
// unless the seam is enabled. Uses the character name (never the Matrix handle);
|
||
// skips silently if the name is unknown.
|
||
func emitDeathNews(userID id.UserID, location string) {
|
||
// Gate before the char lookups: markAdventureDead fires per-member on a
|
||
// party wipe and in the sim harness, so skip the DB reads when disabled.
|
||
if !peteclient.Enabled() || !newsEmissionOn() {
|
||
return
|
||
}
|
||
name := charName(userID)
|
||
if name == "" {
|
||
return
|
||
}
|
||
lvl := charLevel(userID)
|
||
ts := nowUnix()
|
||
emitFact(peteclient.Fact{
|
||
GUID: fmt.Sprintf("death:%s:%d", eventToken(userID, fmt.Sprintf("%d", ts)), ts),
|
||
EventType: "death",
|
||
Tier: "priority",
|
||
Subject: name,
|
||
Zone: location,
|
||
Level: lvl,
|
||
Outcome: "lost",
|
||
OccurredAt: ts,
|
||
}, userID, "")
|
||
}
|