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 BULLETIN 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.
//
// Bulletin, not priority: the room watched the character go down in TwinBee's
// narration, and a priority fact is what makes Pete post a live beat — he'd be
// breaking the news to the people who were there. The site row and the digest
// still carry it.
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:       "bulletin",
		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, "")
}
