mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Pete's entire taxonomy was arrival, companion_hire, death, milestone,
rival_result and zone_first — every one of them a win, a death, or an
introduction. An expedition that ended with the player walking out alive emitted
nothing at all, so the feed showed a realm where adventurers only ever triumph
or die.
That is not a rare gap. Before the §6 slot work, a cleric retreated on 167 of
500 simulated expeditions: a third of that class's runs ended in a way the news
was structurally incapable of reporting. Casters did not read as unlucky in the
feed. They read as absent.
So: a `retreat` bulletin, filed from forceExtractExpeditionForRunLoss — the one
chokepoint every bad ending already passes through. It carries who, where, and
the day they got to before it came apart.
Gated on the reason, not on "the expedition ended":
- a death already files its own priority dispatch, and must not ALSO be
reported as a retreat;
- an idle reap is not a retreat. A player who closed their laptop did not flee
anything, and Pete announcing by name that they were driven from the field
would be a lie about a person.
The four reason strings were bare literals at their call sites; they are
constants now, because the gate cannot be allowed to drift from them.
The day count is read BEFORE forcedExtractExpedition, which stamps the row
'abandoned' and takes the live fields with it.
Claude-Session: https://claude.ai/code/session_01B2MwktU4RgfWkar8HM3zZn
656 lines
23 KiB
Go
656 lines
23 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, "")
|
||
}
|
||
|
||
// emitRetreatNews files a BULLETIN when an expedition ends with the player
|
||
// walking out alive.
|
||
//
|
||
// Until this existed the news had no way to say "it went badly but nobody
|
||
// died", so it never said it. Pete's whole taxonomy was arrival, companion_hire,
|
||
// death, milestone, rival_result and zone_first — every one of them a win, a
|
||
// death, or an introduction. A run that simply fell apart emitted nothing, and
|
||
// the feed showed a realm where adventurers only ever triumph or die.
|
||
//
|
||
// That was not a rare gap. Before §6, a cleric retreated on 167 of 500
|
||
// simulated expeditions: a third of that class's runs ended in a way the news
|
||
// was structurally incapable of reporting. Casters did not look unlucky in the
|
||
// feed — they looked absent.
|
||
//
|
||
// Bulletin, not priority: a retreat is a bad day, not a funeral. A death already
|
||
// files its own priority dispatch from markAdventureDead, and a wipe that killed
|
||
// someone must not ALSO be reported as a retreat — hence the reason gate rather
|
||
// than an "expedition ended" catch-all. An idle reap is excluded too: a player
|
||
// who closed their laptop did not flee anything, and Pete announcing that they
|
||
// were driven from the field would be a lie about a person by name.
|
||
func emitRetreatNews(userID id.UserID, reason string, zoneID ZoneID, day int) {
|
||
switch reason {
|
||
case lossCombatRetreat, lossCombatFlee:
|
||
default:
|
||
return // a death tells its own story; an idle reap is not a story
|
||
}
|
||
if !peteclient.Enabled() || !newsEmissionOn() {
|
||
return
|
||
}
|
||
name := charName(userID)
|
||
if name == "" {
|
||
return
|
||
}
|
||
zone := zoneOrFallback(zoneID)
|
||
ts := nowUnix()
|
||
emitFact(peteclient.Fact{
|
||
GUID: fmt.Sprintf("retreat:%s:%d", eventToken(userID, fmt.Sprintf("%d", ts)), ts),
|
||
EventType: "retreat",
|
||
Tier: "bulletin",
|
||
Subject: name,
|
||
Zone: zone.Display,
|
||
Level: charLevel(userID),
|
||
Count: day, // the day they got to before it fell apart
|
||
Outcome: "retreated",
|
||
OccurredAt: ts,
|
||
}, userID, "")
|
||
}
|