mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 19:01:09 +00:00
Compare commits
12
Commits
85e5ba5fce
...
6e2782ac48
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e2782ac48 | ||
|
|
7e59697754 | ||
|
|
22b7949791 | ||
|
|
fbed45fc96 | ||
|
|
7960838b3f | ||
|
|
32520eb7ec | ||
|
|
b6d4e4ccec | ||
|
|
479f77b9c5 | ||
|
|
189a44e1eb | ||
|
|
db13ed75b9 | ||
|
|
686434f8e3 | ||
|
|
fc9e055083 |
@@ -1807,6 +1807,21 @@ CREATE INDEX IF NOT EXISTS idx_mischief_target ON mischief_contracts(target_id,
|
||||
CREATE INDEX IF NOT EXISTS idx_mischief_buyer ON mischief_contracts(buyer_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_mischief_due ON mischief_contracts(status, window_ends_at);
|
||||
|
||||
-- The web equip queue's idempotency ledger. Pete records an owner's equip/unequip
|
||||
-- intent and we poll it; the guid stamped here is what makes a re-offered order a
|
||||
-- no-op. Mischief can lean on its contract row for the same job, but an equip
|
||||
-- opens no durable object of its own — worse, the underlying action is NOT
|
||||
-- idempotent (equipping consumes an inventory row, unequip mints a fresh one), so
|
||||
-- without this a poll loop whose verdict-ack was lost would re-run the equip and
|
||||
-- double-move the item. We record the guid the instant the mutation lands and
|
||||
-- short-circuit on it before touching anything on a re-offer.
|
||||
CREATE TABLE IF NOT EXISTS equip_applied_orders (
|
||||
guid TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL, -- the terminal verdict we filed, replayed on re-offer
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Babysitting Service
|
||||
CREATE TABLE IF NOT EXISTS adventure_babysit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -48,6 +48,13 @@ type Fact struct {
|
||||
Milestone string `json:"milestone,omitempty"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
NoPush bool `json:"no_push,omitempty"` // backfill: suppress Pete web-push
|
||||
// Headline/Lede are LLM-authored prose for this fact, both optional. Pete
|
||||
// prefers them over its own template when present and past its prose-guard,
|
||||
// and falls back to the template otherwise — so an empty pair (LLM off, or
|
||||
// authoring failed) is the normal, safe case. Populated by emitFact; see
|
||||
// authorDispatch. Names in the prose must come only from Actors.
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Lede string `json:"lede,omitempty"`
|
||||
}
|
||||
|
||||
// Config controls the seam. Enabled=false makes Emit a durable no-op (nothing
|
||||
@@ -266,9 +273,39 @@ type RosterDetail struct {
|
||||
Modifiers [6]int `json:"modifiers"` // matching ability modifiers
|
||||
Gear []GearItem `json:"gear,omitempty"`
|
||||
// Expedition context, present only while on a run.
|
||||
Supplies int `json:"supplies,omitempty"`
|
||||
ThreatLevel int `json:"threat_level,omitempty"`
|
||||
Room string `json:"room,omitempty"`
|
||||
Supplies int `json:"supplies,omitempty"`
|
||||
ThreatLevel int `json:"threat_level,omitempty"`
|
||||
Room string `json:"room,omitempty"`
|
||||
Map *RosterMap `json:"map,omitempty"`
|
||||
}
|
||||
|
||||
// RosterMap is the fog-of-war cut of an adventurer's zone graph: every node
|
||||
// they have visited, plus the one-hop frontier of doors leading out of visited
|
||||
// nodes, with the rooms behind those doors withheld. It is per-adventurer and
|
||||
// rides the roster push beside Room. Only ids and kinds cross the wire — a
|
||||
// ZoneNode's Label and Content (encounter, loot bias, narration) are spoilers
|
||||
// and never leave the game box. Frontier nodes carry kind "unknown".
|
||||
type RosterMap struct {
|
||||
ZoneID string `json:"zone_id"`
|
||||
CurrentNode string `json:"current_node"`
|
||||
Visited []string `json:"visited"`
|
||||
Nodes []RosterMapNode `json:"nodes"`
|
||||
Edges []RosterMapEdge `json:"edges"`
|
||||
}
|
||||
|
||||
// RosterMapNode is one room reduced to what a public map may show.
|
||||
type RosterMapNode struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"` // ZoneNodeKind, or "unknown" for an unreached frontier room
|
||||
}
|
||||
|
||||
// RosterMapEdge is one directed passage. Lock names the gate kind
|
||||
// (perception_check, key_required, ...) so the map can mark a door as barred;
|
||||
// LockData and Hint stay behind on the game box.
|
||||
type RosterMapEdge struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Lock string `json:"lock,omitempty"`
|
||||
}
|
||||
|
||||
// GearItem is one equipped piece for the armor/gear panel.
|
||||
@@ -348,17 +385,79 @@ type PlayerDetail struct {
|
||||
Token string `json:"token"`
|
||||
Inventory []ItemView `json:"inventory,omitempty"`
|
||||
Vault []ItemView `json:"vault,omitempty"`
|
||||
Equipped []ItemView `json:"equipped,omitempty"`
|
||||
House HouseView `json:"house"`
|
||||
Pets []PetView `json:"pets,omitempty"`
|
||||
}
|
||||
|
||||
// ItemView is one backpack or vault item for the private inventory panel.
|
||||
// ItemView is one item in the private panels — backpack, vault, or worn.
|
||||
//
|
||||
// Slot/SkillSource/Desc/Effect are display resolutions done at the push site,
|
||||
// because an adventure_inventory row carries none of them: descriptions live on
|
||||
// MagicItem/EquipmentDef, and the combat delta is computed, never stored.
|
||||
//
|
||||
// SkillSource is only the player-facing skill a masterwork piece draws on
|
||||
// ("mining"). Inventory rows smuggle "magic_item:<id>" through the same column
|
||||
// as an internal registry pointer; that is not a fact about the item and never
|
||||
// goes on the wire.
|
||||
//
|
||||
// Attunement (does it need a bond) and Attuned (does it have one) are distinct:
|
||||
// with a hard cap of 3 bonds, a worn item can sit inert, and a player deciding
|
||||
// what to wear needs to see the difference.
|
||||
type ItemView struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Tier int `json:"tier"`
|
||||
Value int64 `json:"value"`
|
||||
Temper int `json:"temper,omitempty"`
|
||||
// ID is the adventure_inventory row id, sent only for a backpack item the
|
||||
// magic-item equip path will accept — so a non-zero ID is also the signal that
|
||||
// this item can be equipped from the web. Worn and vault rows carry none: a
|
||||
// worn item unequips by slot, and a vault item can't be equipped at all. Pete
|
||||
// round-trips this id in an equip order; the table is AUTOINCREMENT, so a stale
|
||||
// id (item already moved) misses cleanly rather than hitting the wrong row.
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Tier int `json:"tier"`
|
||||
Value int64 `json:"value"`
|
||||
Temper int `json:"temper,omitempty"`
|
||||
Slot string `json:"slot,omitempty"`
|
||||
SkillSource string `json:"skill_source,omitempty"`
|
||||
Desc string `json:"desc,omitempty"`
|
||||
Effect string `json:"effect,omitempty"`
|
||||
Attunement bool `json:"attunement,omitempty"`
|
||||
Attuned bool `json:"attuned,omitempty"`
|
||||
// Compare pairs a backpack magic item against whatever is worn in the slot it
|
||||
// would equip into, so the owner can tell an upgrade from a sidegrade without
|
||||
// eyeballing two opaque effect strings. Set only on backpack magic items (the
|
||||
// ones that carry an equip ID); worn and vault rows never have it. Owner-private,
|
||||
// rides detail_json — no migration, no public surface.
|
||||
Compare *ItemCompare `json:"compare,omitempty"`
|
||||
}
|
||||
|
||||
// ItemCompare is the per-stat verdict for equipping a backpack magic item over
|
||||
// what is currently worn in its slot. gogobee computes it (the power math folds
|
||||
// in tempering and bond availability, which live in the engine); Pete only
|
||||
// renders it and does no arithmetic.
|
||||
type ItemCompare struct {
|
||||
// Verdict is one of: upgrade, downgrade, sidegrade, same, new, inert.
|
||||
// upgrade every changed stat a gain
|
||||
// downgrade every changed stat a loss
|
||||
// sidegrade mixed — some better, some worse; no winner claimed
|
||||
// same no stat differs
|
||||
// new the target slot is empty; equipping fills it
|
||||
// inert needs a bond and none is free — wearing it would do nothing
|
||||
Verdict string `json:"verdict"`
|
||||
// VsName is the worn item being replaced; "" when Verdict is new (empty slot).
|
||||
VsName string `json:"vs_name,omitempty"`
|
||||
// VsSlot is the slot the item would land in (e.g. "ring_1").
|
||||
VsSlot string `json:"vs_slot,omitempty"`
|
||||
// Deltas is one entry per changed stat, each flagged better/worse. Engine-
|
||||
// rendered player-facing text; Pete draws arrows off Better and does no math.
|
||||
Deltas []ItemDelta `json:"deltas,omitempty"`
|
||||
}
|
||||
|
||||
// ItemDelta is one stat's change between the candidate item and the worn item.
|
||||
type ItemDelta struct {
|
||||
Label string `json:"label"`
|
||||
Better bool `json:"better"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// HouseView is the owner's housing summary.
|
||||
@@ -568,6 +667,59 @@ func ClaimMischief(ctx context.Context, guid, status, detail string) error {
|
||||
return std.post(ctx, "/api/mischief/claim", payload)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The equip queue's reverse pipe
|
||||
//
|
||||
// An owner asks, on their own detail page, to wear or take off an item. Pete
|
||||
// records the intent; we poll for it, run the real equip against our own
|
||||
// equipment tables, and file a verdict. Same shape as mischief — Pete has no
|
||||
// route in — but with one crucial difference: the game action is NOT naturally
|
||||
// idempotent (equipping consumes an inventory row and regenerates it on
|
||||
// unequip), so re-running a drained order would double-move items. The poller
|
||||
// therefore short-circuits on the order guid before it mutates, the way
|
||||
// placeWebMischief does on its contract; the guid is still the end-to-end key,
|
||||
// but here it guards a non-idempotent action rather than riding a naturally
|
||||
// idempotent one.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// EquipOrder is one equip/unequip as Pete describes it. owner_localpart is the
|
||||
// Matrix localpart of the character to dress; item_id is the adventure_inventory
|
||||
// row id for an equip (0 for an unequip, which keys on slot). character_name and
|
||||
// item_name are display copy Pete froze at order time; we don't need them.
|
||||
type EquipOrder struct {
|
||||
GUID string `json:"guid"`
|
||||
OwnerLocalpart string `json:"owner_localpart"`
|
||||
ItemID int64 `json:"item_id"`
|
||||
ItemName string `json:"item_name"`
|
||||
Slot string `json:"slot"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// PendingEquip asks Pete for equip orders waiting on us. A Pete predating the
|
||||
// queue answers 404, surfaced here as an error the poll loop logs quietly.
|
||||
func PendingEquip(ctx context.Context) ([]EquipOrder, error) {
|
||||
if !Enabled() {
|
||||
return nil, nil
|
||||
}
|
||||
var out []EquipOrder
|
||||
if err := std.getJSON(ctx, "/api/equip/pending", &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// VerdictEquip files our verdict on an equip order. Idempotent on Pete, so a
|
||||
// retried verdict is safe; the verdict rides this call directly.
|
||||
func VerdictEquip(ctx context.Context, guid, status, detail string) error {
|
||||
payload, err := json.Marshal(map[string]string{"guid": guid, "status": status, "detail": detail})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return std.post(ctx, "/api/equip/verdict", payload)
|
||||
}
|
||||
|
||||
// getJSON does a bearer-authed GET and decodes the body.
|
||||
func (c *Client) getJSON(ctx context.Context, path string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.IngestURL+path, nil)
|
||||
|
||||
@@ -291,6 +291,7 @@ func (p *AdventurePlugin) Init() error {
|
||||
go p.expeditionBoredomTicker()
|
||||
go p.mischiefTicker()
|
||||
go p.peteMischiefTicker()
|
||||
go p.peteEquipTicker()
|
||||
|
||||
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
||||
p.arenaCleanupStaleRuns()
|
||||
@@ -1382,6 +1383,10 @@ func (p *AdventurePlugin) announceTreasureToRoom(char *AdventureCharacter, def *
|
||||
if def == nil || def.RoomAnnounce == "" {
|
||||
return
|
||||
}
|
||||
// The same story-grade gate feeds Pete's trophy case. Emit before the
|
||||
// games-room check so a find is still recorded as news even when no room is
|
||||
// configured to announce it in.
|
||||
emitTreasureFound(char.UserID, def, loc)
|
||||
gr := gamesRoom()
|
||||
if gr == "" {
|
||||
return
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestBuildFightSeats_ConsumesTheAbilityOnceAndCarriesItOnTheSeat(t *testing.
|
||||
ragingBerserker(t, uid)
|
||||
|
||||
seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats(
|
||||
uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0)
|
||||
uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0, nil)
|
||||
if refusal != "" {
|
||||
t.Fatalf("fight refused: %s", refusal)
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func TestBuildZoneCombatants_RebuildKeepsTheRageForTheWholeFight(t *testing.T) {
|
||||
ragingBerserker(t, uid)
|
||||
p := &AdventurePlugin{}
|
||||
|
||||
seats, _, _, refusal := p.buildFightSeats(uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0)
|
||||
seats, _, _, refusal := p.buildFightSeats(uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0, nil)
|
||||
if refusal != "" {
|
||||
t.Fatalf("fight refused: %s", refusal)
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func TestBuildFightSeats_SatOutMemberKeepsTheirArmedAbility(t *testing.T) {
|
||||
}
|
||||
|
||||
seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats(
|
||||
leader, []id.UserID{leader, downed}, dndBestiary["goblin"], 1, 0)
|
||||
leader, []id.UserID{leader, downed}, dndBestiary["goblin"], 1, 0, nil)
|
||||
if refusal != "" {
|
||||
t.Fatalf("fight refused: %s", refusal)
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
||||
|
||||
// Seat the whole party, leader first. A solo player is a one-seat roster and
|
||||
// takes the path they always took: one build, one INSERT, no participant rows.
|
||||
seats, enemy, senderSkip, refusal := p.buildFightSeats(ctx.Sender, roster, monster, int(zone.Tier), run.DMMood)
|
||||
seats, enemy, senderSkip, refusal := p.buildFightSeats(ctx.Sender, roster, monster, int(zone.Tier), run.DMMood, run)
|
||||
if refusal != "" {
|
||||
return p.replyDM(ctx, refusal)
|
||||
}
|
||||
@@ -119,6 +119,17 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
||||
return p.replyDM(ctx, "Couldn't start the fight: "+err.Error())
|
||||
}
|
||||
|
||||
// Layer-2 boss state that is spent mid-fight (Valdris's Phylactery Verses)
|
||||
// is seeded onto the fresh session ONCE, from the route the player walked to
|
||||
// get here — not on the per-round rebuild, which would resurrect spent
|
||||
// rebirths. enemyHP is the party-scaled pool persisted above. No-op for every
|
||||
// non-hooked enemy.
|
||||
if seedBossRunStatuses(sess, monster.ID, enemyHP, run) {
|
||||
if err := saveCombatSession(sess); err != nil {
|
||||
return p.replyDM(ctx, "Couldn't start the fight: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
if isBoss {
|
||||
if line := composeBossEntry(zone.ID, run.RunID, run.CurrentRoom); line != "" {
|
||||
|
||||
@@ -181,6 +181,20 @@ type CombatModifiers struct {
|
||||
SpellPreDamage int
|
||||
SpellPreDamageDesc string
|
||||
SpellEnemySkipFirst bool
|
||||
|
||||
// At-will cantrip channel. Arcane blasters (Mage/Sorcerer/Warlock) throw a
|
||||
// scaling damage cantrip EVERY round — 5e cantrips are the caster's at-will
|
||||
// floor and scale to 4 dice at L17 (Fire Bolt 4d10, Eldritch Blast 4 beams).
|
||||
// The pre-combat one-shot SpellPreDamage modelled a single leveled cast and
|
||||
// left the caster swinging a stick for the rest of the fight; that is the
|
||||
// whole of the caster T5-room wall (one burst can't finish a 65-HP monster
|
||||
// and the quarterstaff floor does nothing). CantripPerRound is dealt as flat
|
||||
// magic damage at the top of resolvePlayerSwings each round — no dice roll,
|
||||
// so the RNG stream is stable and variance stays low. 0 for non-casters, so
|
||||
// martial combat is byte-identical. Halved by enemy spell_resist like any
|
||||
// spell. CantripDesc is the narration hook (spell name).
|
||||
CantripPerRound int
|
||||
CantripDesc string
|
||||
}
|
||||
|
||||
type Combatant struct {
|
||||
@@ -428,6 +442,14 @@ type combatState struct {
|
||||
enemyRegen int // regenerate: enemy heals this much each round end
|
||||
enemySurviveArmed bool // survive_at_1: enemy cheats death once, dropping to 1 HP
|
||||
|
||||
// Phylactery Verses (T6 Valdris) — stackable rebirth. enemyReviveCharges is
|
||||
// how many times the boss still cheats death; each revives it to
|
||||
// enemyReviveHP. Seeded once at fight start from unfound Verses, round-tripped
|
||||
// through CombatStatuses. Distinct from enemySurviveArmed (a one-shot 1-HP
|
||||
// proc): a rebirth restores a meaningful pool, and there can be several.
|
||||
enemyReviveCharges int
|
||||
enemyReviveHP int
|
||||
|
||||
// Phase 13 bestiary slice 4 — the former flavor-only placeholders, now
|
||||
// backed by real state.
|
||||
enemySpellResist bool // spell_resist: player spell damage against this enemy is halved
|
||||
@@ -435,6 +457,25 @@ type combatState struct {
|
||||
enemyFearImmune bool // fear_immune: player control spells (enemy-skip) fizzle against this enemy
|
||||
enemyAtkBuff int // ally_buff: flat, accumulating bonus to the enemy's attack damage
|
||||
|
||||
// Amendment (T6 Custodian) — an in-combat Layer-2 hook resolved at round end
|
||||
// by applyBossInCombatRoundEnd. enemyRewindHP is the boss's round-3 HP
|
||||
// snapshot (0 until captured); enemyRewindUsed gates the once-only rewind
|
||||
// that restores it to that snapshot when the boss crosses into phase 2. The
|
||||
// soft midnight timer past round 20 rides enemyAtkBuff. Round-tripped through
|
||||
// CombatStatuses; zero/false for every non-Custodian fight.
|
||||
enemyRewindHP int
|
||||
enemyRewindUsed bool
|
||||
|
||||
// Inversion Stitch (T6 Seamstress) — an in-combat Layer-2 hook resolved at
|
||||
// round end by applyBossInCombatRoundEnd, live only in the boss's phase 2.
|
||||
// inversionActive is the number of rounds the room stays sewn inside-out
|
||||
// (heals sting instead of mend, gated in stepPlayerActionEffect); it counts
|
||||
// down one per round end. inversionTelegraph warns the round before a pulse
|
||||
// activates, so a player who watches the tell can hold their heals. Both
|
||||
// round-trip through CombatStatuses; zero/false for every non-Seamstress fight.
|
||||
inversionActive int
|
||||
inversionTelegraph bool
|
||||
|
||||
round int
|
||||
events []CombatEvent
|
||||
|
||||
@@ -541,6 +582,31 @@ func maybeTriggerOrcRage(st *combatState, player *Combatant, phaseName string) {
|
||||
// consumes once-per-fight openers (AutoCritFirst, FirstAttackBonus,
|
||||
// AssassinateAdvantage) via st flags — extras roll vanilla.
|
||||
func resolvePlayerSwings(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool {
|
||||
// At-will cantrip: fires once per round before the weapon swing, independent
|
||||
// of whether the swing connects (a caster who whiffs the stick still throws
|
||||
// its Fire Bolt). Flat magic damage, halved by spell_resist. 0 for
|
||||
// non-casters → skipped entirely, so martial combat draws no extra events
|
||||
// and no RNG. See CantripPerRound in CombatModifiers.
|
||||
if player.Mods.CantripPerRound > 0 && st.enemyHP > 0 {
|
||||
dmg := player.Mods.CantripPerRound
|
||||
if enemyResistsSpells(enemy, st) {
|
||||
dmg = max(1, dmg/2)
|
||||
}
|
||||
st.enemyHP = max(0, st.enemyHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phase.Name, Actor: "player", Action: "cantrip",
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Desc: player.Mods.CantripDesc,
|
||||
})
|
||||
// Route the kill through enemyDown, not a raw HP read: a boss that cheats
|
||||
// death (survive_at_1) or holds a phylactery rebirth (T6 Valdris) must get
|
||||
// that chance even when the lethal blow is the at-will cantrip. enemyDown
|
||||
// restores its HP and returns false, so the weapon swing below resolves
|
||||
// against the revived pool. Mirrors resolvePlayerAttack's own kill routing.
|
||||
if enemyDown(st, phase.Name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if resolvePlayerAttack(st, player, enemy, phase, result) {
|
||||
return true
|
||||
}
|
||||
@@ -1308,6 +1374,19 @@ func enemyDown(st *combatState, phaseName string) bool {
|
||||
})
|
||||
return false
|
||||
}
|
||||
// Phylactery Verses (T6 Valdris): a stackable rebirth. Each unfound Verse
|
||||
// left one of these charges, and each restores a real pool rather than the
|
||||
// 1-HP stay above — a full-clear explorer stripped them all and fights a
|
||||
// mortal, a skip-route fights a god who keeps getting back up.
|
||||
if st.enemyReviveCharges > 0 {
|
||||
st.enemyReviveCharges--
|
||||
st.enemyHP = max(1, st.enemyReviveHP)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "phylactery_rebirth",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -242,6 +242,10 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
|
||||
case "concentration_tick":
|
||||
return fmt.Sprintf(pickRand(narrativeConcentrationTick), e.Damage)
|
||||
|
||||
case "cantrip":
|
||||
// e.Desc is the spell name (Fire Bolt / Eldritch Blast); e.Damage the hit.
|
||||
return fmt.Sprintf(pickRand(narrativeCantrip), e.Desc, e.Damage)
|
||||
|
||||
case "pet_deflect":
|
||||
return pickRand(narrativePetDeflect)
|
||||
|
||||
@@ -331,6 +335,18 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
|
||||
return pickRand(narrativeSurviveArmed)
|
||||
case "survive_at_1":
|
||||
return pickRand(narrativeSurvive)
|
||||
case "phylactery_rebirth":
|
||||
return pickRand(narrativePhylacteryRebirth)
|
||||
case "amendment_rewind":
|
||||
return pickRand(narrativeAmendmentRewind)
|
||||
case "midnight_toll":
|
||||
return fmt.Sprintf(pickRand(narrativeMidnightToll), e.Damage)
|
||||
case "inversion_telegraph":
|
||||
return pickRand(narrativeInversionTelegraph)
|
||||
case "inversion_stitch":
|
||||
return pickRand(narrativeInversionStitch)
|
||||
case "heal_inverted":
|
||||
return fmt.Sprintf(pickRand(narrativeHealInverted), e.Damage)
|
||||
case "stat_drain":
|
||||
return fmt.Sprintf(pickRand(narrativeStatDrain), e.Damage)
|
||||
case "debuff":
|
||||
@@ -546,6 +562,15 @@ var narrativeConcentrationTick = []string{
|
||||
"🌀 The enemy steps wrong and the standing magic answers, %d damage. It does not move on.",
|
||||
}
|
||||
|
||||
// narrativeCantrip fires each round an arcane blaster throws its at-will cantrip
|
||||
// (Fire Bolt / Eldritch Blast) before the weapon swing. %s is the spell name,
|
||||
// %d the damage — a caster's sustained floor, so it lands every round.
|
||||
var narrativeCantrip = []string{
|
||||
"✨ %s streaks out and burns home — %d damage. The at-will floor never stops.",
|
||||
"✨ A bolt of %s answers before the staff even moves, scorching for %d.",
|
||||
"✨ %s lances the enemy for %d. No incantation, no wind-up — just the steady arcane drum.",
|
||||
}
|
||||
|
||||
var narrativePetDeflect = []string{
|
||||
"🐾 Your pet intercepts the blow. Damage halved. Your pet is now your best piece of equipment.",
|
||||
"🐾 Your pet pushes you aside at the last second. Impact reduced. You did not ask to be pushed. Results speak for themselves.",
|
||||
@@ -761,6 +786,52 @@ var narrativeSurvive = []string{
|
||||
"🕯️ The enemy by all rights should be down. It is, instead, very barely up.",
|
||||
}
|
||||
|
||||
// narrativePhylacteryRebirth fires when Valdris burns a Verse the player left
|
||||
// un-found: a bound rebirth spends and the lich reassembles. Each line reads as
|
||||
// "you skipped one of these" so the mechanic teaches itself over a wipe.
|
||||
var narrativePhylacteryRebirth = []string{
|
||||
"💀 The lich comes apart — and a Verse you never found sings him back together. He rises, unhurried.",
|
||||
"💀 Bone-dust swirls up off the floor and re-seats itself. A rebirth you didn't unbind just spent itself. He stands.",
|
||||
"💀 That should have been the end of him. A Verse still hums somewhere in the cathedral, and Valdris simply *begins again*.",
|
||||
}
|
||||
|
||||
// narrativeAmendmentRewind fires when the Custodian rewinds itself to its round-3
|
||||
// HP snapshot — the once-only Amendment. Each line reads as time being undone so
|
||||
// the mechanic (front-loaded burst is partly refunded) teaches itself over a run.
|
||||
var narrativeAmendmentRewind = []string{
|
||||
"🕰️ The Custodian raises a hand and *edits the last few minutes out of the record.* Wounds close in reverse; the clock-golem stands as it did rounds ago.",
|
||||
"🕰️ \"That entry is amended.\" The damage you dealt simply un-happens — the Custodian rewinds to where it was and resumes, unhurried.",
|
||||
"🕰️ Verdigris rings spin backward. Time you spent hurting it is refunded to the golem; it returns to its round-three self and keeps working.",
|
||||
}
|
||||
|
||||
// narrativeMidnightToll fires past round 20 — the soft closing-time timer, the
|
||||
// Custodian's Attack climbing each round a stalled fight refuses to end.
|
||||
var narrativeMidnightToll = []string{
|
||||
"🔔 A bell tolls somewhere above. Closing time — the Custodian's swings come harder. (+%d attack)",
|
||||
"🔔 The hour is nearly spent, and so is its patience; each blow lands with more weight now. (+%d attack)",
|
||||
}
|
||||
|
||||
// narrativeInversionTelegraph fires one round before an Inversion Stitch pulse —
|
||||
// the Seamstress's tell. It reads as a warning so a player learns to hold heals.
|
||||
var narrativeInversionTelegraph = []string{
|
||||
"🧵 The Seamstress draws a thread taut and the room *shivers* — walls flexing toward inside-out. Whatever you were about to mend, hold it. (next round, healing turns against you)",
|
||||
"🧵 A seam in the air puckers. The geometry is about to flip; a cure cast into it will run backward. (inversion incoming next round)",
|
||||
}
|
||||
|
||||
// narrativeInversionStitch fires when a pulse activates — the room is sewn
|
||||
// inside-out and healing now wounds for the pulse's duration.
|
||||
var narrativeInversionStitch = []string{
|
||||
"🧵 The stitch pulls through. The room is inside-out now — for a moment, to heal is to hurt.",
|
||||
"🧵 Everything turns wrong-way-round. Mending and wounding have swapped ends of the needle.",
|
||||
}
|
||||
|
||||
// narrativeHealInverted fires each time a heal lands during an active pulse: the
|
||||
// cure runs backward and stings instead. Teaches the mechanic on the spot.
|
||||
var narrativeHealInverted = []string{
|
||||
"🧵 The heal runs backward through the inverted room — the cure opens the wound it meant to close. (%d damage)",
|
||||
"🧵 Healing turns against its target in the sewn-inside-out air; the mend lands as a sting. (%d damage)",
|
||||
}
|
||||
|
||||
var narrativeStatDrain = []string{
|
||||
"🩸 The enemy saps your strength — your swings feel heavier, weaker. (-%d hit damage)",
|
||||
"🩸 Something drains out of your limbs. Your hits won't bite as deep now. (-%d damage)",
|
||||
|
||||
@@ -51,7 +51,7 @@ func fightRoster(sender id.UserID) []id.UserID {
|
||||
// The enemy is built once. Every seat's build derives the identical stat block
|
||||
// from (monster, tier, dmMood); only the player half varies.
|
||||
func (p *AdventurePlugin) buildFightSeats(
|
||||
sender id.UserID, roster []id.UserID, monster DnDMonsterTemplate, tier, dmMood int,
|
||||
sender id.UserID, roster []id.UserID, monster DnDMonsterTemplate, tier, dmMood int, run *DungeonRun,
|
||||
) (seats []CombatSeatSetup, enemy *Combatant, senderSkip, refusal string) {
|
||||
skip := func(uid id.UserID, why string) {
|
||||
if uid == sender {
|
||||
@@ -157,6 +157,13 @@ func (p *AdventurePlugin) buildFightSeats(
|
||||
// actually seated — a member who was skipped (downed, busy elsewhere) never
|
||||
// joined the fight and must not be charged to the enemy.
|
||||
applySeatWeights(seatCombatants(seats), levels, companions)
|
||||
|
||||
// Fold in any Layer-2 pre-combat boss mechanic before this enemy is used to
|
||||
// persist the initial HP pool. partyCombatantsForSession re-applies the same
|
||||
// modifier on every round's rebuild; doing it here keeps the persisted stat
|
||||
// block consistent with the fight the engine will actually run. No-op for
|
||||
// every non-hooked enemy.
|
||||
applyBossRunModifiers(monster.ID, enemy, run)
|
||||
return seats, enemy, senderSkip, ""
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ func TestBuildFightSeats_SoloSeatsExactlyThePlayer(t *testing.T) {
|
||||
fightTestChar(t, solo, 30)
|
||||
|
||||
seats, enemy, skip, refusal := (&AdventurePlugin{}).buildFightSeats(
|
||||
solo, []id.UserID{solo}, dndBestiary["goblin"], 1, 0)
|
||||
solo, []id.UserID{solo}, dndBestiary["goblin"], 1, 0, nil)
|
||||
if refusal != "" || skip != "" {
|
||||
t.Fatalf("solo fight refused: %s / %s", refusal, skip)
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func TestBuildFightSeats_DownedMemberSitsOut(t *testing.T) {
|
||||
|
||||
roster := []id.UserID{leader, downed, standing}
|
||||
seats, _, skip, refusal := (&AdventurePlugin{}).buildFightSeats(
|
||||
leader, roster, dndBestiary["goblin"], 1, 0)
|
||||
leader, roster, dndBestiary["goblin"], 1, 0, nil)
|
||||
if refusal != "" {
|
||||
t.Fatalf("party refused over a downed member: %s", refusal)
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func TestBuildFightSeats_DownedMemberSitsOut(t *testing.T) {
|
||||
|
||||
// The one who was left behind typed `!fight` too, and silence is not an answer.
|
||||
_, _, skip, refusal = (&AdventurePlugin{}).buildFightSeats(
|
||||
downed, roster, dndBestiary["goblin"], 1, 0)
|
||||
downed, roster, dndBestiary["goblin"], 1, 0, nil)
|
||||
if refusal != "" {
|
||||
t.Fatalf("a downed member must not refuse the party's fight: %s", refusal)
|
||||
}
|
||||
@@ -176,7 +176,7 @@ func TestBuildFightSeats_DownedLeaderRefusesTheFightForEveryone(t *testing.T) {
|
||||
roster := []id.UserID{leader, member}
|
||||
p := &AdventurePlugin{}
|
||||
|
||||
seats, _, _, refusal := p.buildFightSeats(leader, roster, dndBestiary["goblin"], 1, 0)
|
||||
seats, _, _, refusal := p.buildFightSeats(leader, roster, dndBestiary["goblin"], 1, 0, nil)
|
||||
if len(seats) != 0 || refusal == "" {
|
||||
t.Fatalf("downed leader seated %d players, refusal %q", len(seats), refusal)
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func TestBuildFightSeats_DownedLeaderRefusesTheFightForEveryone(t *testing.T) {
|
||||
t.Errorf("the leader should be told to rest, got %q", refusal)
|
||||
}
|
||||
|
||||
_, _, _, refusal = p.buildFightSeats(member, roster, dndBestiary["goblin"], 1, 0)
|
||||
_, _, _, refusal = p.buildFightSeats(member, roster, dndBestiary["goblin"], 1, 0, nil)
|
||||
if !strings.Contains(refusal, "leader") {
|
||||
t.Errorf("the member should be told it is the leader holding things up, got %q", refusal)
|
||||
}
|
||||
|
||||
@@ -215,12 +215,44 @@ type CombatStatuses struct {
|
||||
EnemyRegen int `json:"enemy_regen,omitempty"`
|
||||
EnemySurviveArmed bool `json:"enemy_survive_armed,omitempty"`
|
||||
|
||||
// Phylactery Verses (Tier-6 postgame, Valdris Ascendant). Unlike the
|
||||
// proc-armed EnemySurviveArmed one-shot, this is a *count* of rebirths seeded
|
||||
// once at fight start (seedBossRunStatuses) from how many of the zone's secret
|
||||
// Verses the player left un-found. Each consumed rebirth (enemyDown) revives
|
||||
// the boss to EnemyReviveHP. Both fields are frozen at seed time except
|
||||
// EnemyReviveCharges, which decrements as rebirths are spent — so they must
|
||||
// round-trip through combatState to survive a suspend/resume. Zero for every
|
||||
// other enemy, so omitempty keeps them off every non-Valdris row.
|
||||
EnemyReviveCharges int `json:"enemy_revive_charges,omitempty"`
|
||||
EnemyReviveHP int `json:"enemy_revive_hp,omitempty"`
|
||||
|
||||
// Slice-4 monster-ability effects — the former flavor-only placeholders.
|
||||
// EnemyRevealNext is a one-shot; the other three persist for the fight.
|
||||
EnemySpellResist bool `json:"enemy_spell_resist,omitempty"`
|
||||
EnemyRevealNext bool `json:"enemy_reveal_next,omitempty"`
|
||||
EnemyFearImmune bool `json:"enemy_fear_immune,omitempty"`
|
||||
EnemyAtkBuff int `json:"enemy_atk_buff,omitempty"`
|
||||
|
||||
// Amendment (Tier-6 postgame, The Custodian of the Last Hour). An in-combat
|
||||
// Layer-2 hook resolved at round end (applyBossInCombatRoundEnd), not proc-
|
||||
// armed: EnemyRewindHP snapshots the boss's HP at the end of round 3 (0 until
|
||||
// captured); when the boss then crosses into phase 2 the hook restores it to
|
||||
// that snapshot exactly once and sets EnemyRewindUsed. Both round-trip through
|
||||
// combatState so the once-only rewind survives a suspend/resume. Zero/false
|
||||
// for every other enemy. The soft midnight timer past round 20 rides the
|
||||
// existing EnemyAtkBuff, so it needs no field of its own.
|
||||
EnemyRewindHP int `json:"enemy_rewind_hp,omitempty"`
|
||||
EnemyRewindUsed bool `json:"enemy_rewind_used,omitempty"`
|
||||
|
||||
// Inversion Stitch (Tier-6 postgame, The Seamstress). An in-combat Layer-2
|
||||
// hook resolved at round end (applyBossInCombatRoundEnd), live only in the
|
||||
// boss's phase 2. InversionActive is the rounds-remaining of the inside-out
|
||||
// pulse during which player heals sting instead of mend (gated in
|
||||
// stepPlayerActionEffect); InversionTelegraph is the one-round warning before
|
||||
// a pulse activates. Both round-trip through combatState so a suspend/resume
|
||||
// can't lose or replay a pulse mid-fight. Zero/false for every other enemy.
|
||||
InversionActive int `json:"inversion_active,omitempty"`
|
||||
InversionTelegraph bool `json:"inversion_telegraph,omitempty"`
|
||||
}
|
||||
|
||||
// applyBuffDelta folds one resolved buff (the result of a !cast / !consume
|
||||
|
||||
@@ -211,6 +211,13 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
|
||||
// until every seat is built.
|
||||
applySeatWeights(players, levels, companions)
|
||||
|
||||
// Layer-2 pre-combat boss mechanics: fold in any run-state-derived
|
||||
// adjustment (e.g. Aurvandryx's Greed Tax) before the party HP scaling. This
|
||||
// is re-derived every round like everything else here; its inputs are frozen
|
||||
// for a terminal boss fight, so the result is stable. No-op for every
|
||||
// non-hooked enemy.
|
||||
applyBossRunModifiers(monster.ID, &enemy, run)
|
||||
|
||||
// Party-only enemy HP bump, re-derived each turn from the template so it never
|
||||
// compounds. Matches the scalar startPartyCombatSession used for the initial
|
||||
// persist; solo (one seat, weight 1) scales by 1.0.
|
||||
|
||||
@@ -363,12 +363,20 @@ func resumeTurnEngine(sess *CombatSession, players []*Combatant, enemy *Combatan
|
||||
enemyRetaliateFrac: sess.Statuses.EnemyRetaliateFrac,
|
||||
enemyRegen: sess.Statuses.EnemyRegen,
|
||||
enemySurviveArmed: sess.Statuses.EnemySurviveArmed,
|
||||
enemyReviveCharges: sess.Statuses.EnemyReviveCharges,
|
||||
enemyReviveHP: sess.Statuses.EnemyReviveHP,
|
||||
// Slice-4 monster-ability effects — the former flavor-only placeholders.
|
||||
enemySpellResist: sess.Statuses.EnemySpellResist,
|
||||
enemyRevealNext: sess.Statuses.EnemyRevealNext,
|
||||
enemyFearImmune: sess.Statuses.EnemyFearImmune,
|
||||
enemyAtkBuff: sess.Statuses.EnemyAtkBuff,
|
||||
rng: rng,
|
||||
// Amendment (T6 Custodian) — round-3 snapshot + once-only rewind.
|
||||
enemyRewindHP: sess.Statuses.EnemyRewindHP,
|
||||
enemyRewindUsed: sess.Statuses.EnemyRewindUsed,
|
||||
// Inversion Stitch (T6 Seamstress) — phase-2 heal-inverting pulses.
|
||||
inversionActive: sess.Statuses.InversionActive,
|
||||
inversionTelegraph: sess.Statuses.InversionTelegraph,
|
||||
rng: rng,
|
||||
}
|
||||
order := turnOrder(sess, sess.Round, players, enemy)
|
||||
sess.Statuses.TurnIdx = turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)
|
||||
@@ -568,10 +576,22 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
||||
st.enemyHP = max(0, st.enemyHP-enemyDmg)
|
||||
}
|
||||
if eff.PlayerHeal > 0 {
|
||||
// Respect any max_hp_drain monster ability — a drained player can't be
|
||||
// healed back past the lowered ceiling.
|
||||
hpCap := max(1, st.hpMax-st.maxHPDrain)
|
||||
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
|
||||
if st.inversionActive > 0 {
|
||||
// Inversion Stitch (T6 Seamstress phase 2): the room is sewn inside-out,
|
||||
// so the cure lands as a wound. Floored at 1 so a player is never killed
|
||||
// by their own heal — the Seamstress's own blows do the finishing; the
|
||||
// sting just denies the sustain and softens the seat for them.
|
||||
st.playerHP = max(1, st.playerHP-eff.PlayerHeal)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "heal_inverted",
|
||||
Damage: eff.PlayerHeal, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
} else {
|
||||
// Respect any max_hp_drain monster ability — a drained player can't be
|
||||
// healed back past the lowered ceiling.
|
||||
hpCap := max(1, st.hpMax-st.maxHPDrain)
|
||||
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
|
||||
}
|
||||
}
|
||||
// §1 — heal somebody else. The caster's cursor stays where it is; only the
|
||||
// target's HP moves.
|
||||
@@ -582,14 +602,27 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
||||
// path depends on. Healing keeps people up; it does not bring them back.
|
||||
if eff.AllyHeal > 0 && eff.AllySeat >= 0 && eff.AllySeat < len(st.actors) {
|
||||
if tgt := st.actors[eff.AllySeat]; tgt.playerHP > 0 {
|
||||
cap := max(1, tgt.hpMax-tgt.maxHPDrain)
|
||||
before := tgt.playerHP
|
||||
tgt.playerHP = min(cap, tgt.playerHP+eff.AllyHeal)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: "ally_heal",
|
||||
Damage: tgt.playerHP - before, PlayerHP: tgt.playerHP, EnemyHP: st.enemyHP,
|
||||
Seat: eff.AllySeat, Desc: eff.Label,
|
||||
})
|
||||
if st.inversionActive > 0 {
|
||||
// Inversion Stitch: the ally-heal wounds the friend it was meant to
|
||||
// mend. Floored at 1 like the self-heal sting above — the sting denies
|
||||
// the sustain, it does not kill.
|
||||
before := tgt.playerHP
|
||||
tgt.playerHP = max(1, tgt.playerHP-eff.AllyHeal)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "heal_inverted",
|
||||
Damage: before - tgt.playerHP, PlayerHP: tgt.playerHP, EnemyHP: st.enemyHP,
|
||||
Seat: eff.AllySeat, Desc: eff.Label,
|
||||
})
|
||||
} else {
|
||||
cap := max(1, tgt.hpMax-tgt.maxHPDrain)
|
||||
before := tgt.playerHP
|
||||
tgt.playerHP = min(cap, tgt.playerHP+eff.AllyHeal)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: "ally_heal",
|
||||
Damage: tgt.playerHP - before, PlayerHP: tgt.playerHP, EnemyHP: st.enemyHP,
|
||||
Seat: eff.AllySeat, Desc: eff.Label,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// Arm / replace the concentration aura. A new concentration cast overwrites
|
||||
@@ -840,7 +873,12 @@ func (te *turnEngine) stepRoundEnd() {
|
||||
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "player", Action: "concentration_tick",
|
||||
Damage: st.concentrationDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
|
||||
})
|
||||
if st.enemyHP <= 0 {
|
||||
// Route the kill through enemyDown, not a raw HP read: a boss that cheats
|
||||
// death (survive_at_1) or holds a phylactery rebirth (T6 Valdris) must get
|
||||
// that chance even when the lethal blow is a lingering concentration pulse.
|
||||
// enemyDown restores its HP and returns false, so the next seat's pulse (or
|
||||
// the following round) resolves against the revived pool.
|
||||
if enemyDown(st, CombatPhaseRoundEnd) {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
@@ -872,6 +910,14 @@ func (te *turnEngine) stepRoundEnd() {
|
||||
Damage: st.enemyRegen, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
// Tier-6 in-combat Layer-2 boss hooks (Amendment): round-3 HP snapshot +
|
||||
// once-only phase-2 rewind + soft midnight timer, resolved on the round that
|
||||
// just finished. A no-op for every enemy but the hooked bosses, and only
|
||||
// while the enemy still stands, so it is safe to call unconditionally here
|
||||
// after the round's damage has settled.
|
||||
if st.enemyHP > 0 {
|
||||
applyBossInCombatRoundEnd(st, te.sess.EnemyID, te.enemy.Stats.MaxHP)
|
||||
}
|
||||
st.round++
|
||||
// Initiative is re-rolled each round, so the next round's order is derived
|
||||
// here — off st.round, since commit has not yet pushed it onto the session.
|
||||
@@ -926,10 +972,16 @@ func (te *turnEngine) commit() {
|
||||
s.EnemyRetaliateFrac = st.enemyRetaliateFrac
|
||||
s.EnemyRegen = st.enemyRegen
|
||||
s.EnemySurviveArmed = st.enemySurviveArmed
|
||||
s.EnemyReviveCharges = st.enemyReviveCharges
|
||||
s.EnemyReviveHP = st.enemyReviveHP
|
||||
s.EnemySpellResist = st.enemySpellResist
|
||||
s.EnemyRevealNext = st.enemyRevealNext
|
||||
s.EnemyFearImmune = st.enemyFearImmune
|
||||
s.EnemyAtkBuff = st.enemyAtkBuff
|
||||
s.EnemyRewindHP = st.enemyRewindHP
|
||||
s.EnemyRewindUsed = st.enemyRewindUsed
|
||||
s.InversionActive = st.inversionActive
|
||||
s.InversionTelegraph = st.inversionTelegraph
|
||||
|
||||
te.sess.TurnLog = append(te.sess.TurnLog, st.events...)
|
||||
}
|
||||
|
||||
@@ -275,3 +275,57 @@ func TestTurnEngine_CommitPersistsSeatZeroNotTheCursor(t *testing.T) {
|
||||
t.Error("seat 1's consumed Lucky reroll leaked onto the session row")
|
||||
}
|
||||
}
|
||||
|
||||
// A lingering concentration pulse that lands the killing blow must still give a
|
||||
// revive-armed boss (survive_at_1 / T6 Valdris's phylactery rebirth) its chance
|
||||
// to stand back up — the round-end tick routes the kill through enemyDown, not a
|
||||
// raw enemyHP<=0 read. Regression for the concentration-bypass gap found in the
|
||||
// P8 Layer-2 review.
|
||||
func TestTurnEngine_ConcentrationKillHonorsRebirth(t *testing.T) {
|
||||
// A charged rebirth: the pulse drops the enemy, a charge spends, it revives.
|
||||
sess := turnSession(CombatPhaseRoundEnd, 500, 30)
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
te := resumeTurnEngine(sess, []*Combatant{&p}, &e, combatSessionStepRNG(sess, enemySeat))
|
||||
te.st.concentrationDmg = 100 // lethal against 30 HP
|
||||
te.st.enemyReviveCharges = 1
|
||||
te.st.enemyReviveHP = 40
|
||||
if _, err := te.step(PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
te.commit()
|
||||
|
||||
if !sess.IsActive() {
|
||||
t.Fatalf("a concentration kill ended the fight (%q) while a rebirth charge was armed", sess.Status)
|
||||
}
|
||||
if sess.EnemyHP != 40 {
|
||||
t.Errorf("revived EnemyHP = %d, want the 40-HP revive pool", sess.EnemyHP)
|
||||
}
|
||||
if sess.Statuses.EnemyReviveCharges != 0 {
|
||||
t.Errorf("post-revive charges = %d, want 0 (one spent)", sess.Statuses.EnemyReviveCharges)
|
||||
}
|
||||
rebirths := 0
|
||||
for _, ev := range sess.TurnLog {
|
||||
if ev.Action == "phylactery_rebirth" {
|
||||
rebirths++
|
||||
}
|
||||
}
|
||||
if rebirths != 1 {
|
||||
t.Errorf("phylactery_rebirth events = %d, want 1", rebirths)
|
||||
}
|
||||
|
||||
// With no charge left, the same pulse ends the fight cleanly (the win path
|
||||
// is not broken by the enemyDown routing).
|
||||
mortal := turnSession(CombatPhaseRoundEnd, 500, 30)
|
||||
p2 := basePlayer()
|
||||
e2 := baseEnemy()
|
||||
te2 := resumeTurnEngine(mortal, []*Combatant{&p2}, &e2, combatSessionStepRNG(mortal, enemySeat))
|
||||
te2.st.concentrationDmg = 100
|
||||
if _, err := te2.step(PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
te2.commit()
|
||||
if mortal.Status != CombatStatusWon {
|
||||
t.Errorf("charge-less concentration kill status = %q, want %q", mortal.Status, CombatStatusWon)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
@@ -559,6 +560,133 @@ func magicItemEffectSummary(mi MagicItem) string {
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// The equip mutation, message-free, shared by the DM resolver above and the web
|
||||
// equip queue (pete_equip.go). Splitting it out is deliberate: the ordering below
|
||||
// — evict occupant, then remove-from-inventory *before* equip, with a rollback if
|
||||
// equip fails — is the whole defence against item duplication, and a second copy
|
||||
// of it living in the web path is exactly where that defence would silently rot.
|
||||
// One implementation, two callers.
|
||||
|
||||
// errItemNotEquippable is a *permanent* refusal (not a magic item, or a slotless
|
||||
// curio) — the caller should reject the request, not retry it. Every other error
|
||||
// from applyMagicEquip is a transient DB fault the caller may retry.
|
||||
var errItemNotEquippable = errors.New("magic-item: not equippable")
|
||||
|
||||
// errSlotEmpty is applyMagicUnequip's permanent refusal: nothing is in that slot.
|
||||
var errSlotEmpty = errors.New("magic-item: slot empty")
|
||||
|
||||
// magicEquipOutcome is what an equip did, for the caller to narrate. BondsBefore
|
||||
// is the attunement count *after* any swap-eviction and *before* this item, so a
|
||||
// caller reporting "bonded (N/3)" adds one.
|
||||
type magicEquipOutcome struct {
|
||||
Effective MagicItem // the item as worn, tempering folded in
|
||||
SwappedBack string // name of the occupant sent back to inventory, or ""
|
||||
Bonded bool // this item took a bond just now
|
||||
AtCap bool // worn but inert: it wants a bond and all 3 are used
|
||||
BondsBefore int // bonds in use before this item was worn
|
||||
Healed []string // stragglers a freed slot let bond, post-equip
|
||||
}
|
||||
|
||||
// applyMagicEquip wears one inventory item, preserving the anti-duplication
|
||||
// ordering. It mutates the equipment tables and sends nothing.
|
||||
func applyMagicEquip(userID id.UserID, it AdvItem) (magicEquipOutcome, error) {
|
||||
mi, ok := magicItemFromAdvItem(it)
|
||||
if !ok || mi.Slot == "" {
|
||||
return magicEquipOutcome{}, errItemNotEquippable
|
||||
}
|
||||
equipped, err := loadEquippedMagicItems(userID)
|
||||
if err != nil {
|
||||
return magicEquipOutcome{}, err
|
||||
}
|
||||
|
||||
// Return whatever occupies that slot to inventory at full value, and drop it
|
||||
// from the local map so the bond count below reflects the post-swap state.
|
||||
var swappedBack string
|
||||
if prev, exists := equipped[mi.Slot]; exists && prev.Item.ID != "" {
|
||||
back := magicItemSellAt(prev.Item, prev.Temper)
|
||||
back.SkillSource = "magic_item:" + prev.Item.ID
|
||||
if err := addAdvInventoryItem(userID, back); err != nil {
|
||||
return magicEquipOutcome{}, err
|
||||
}
|
||||
swappedBack = prev.Item.Name
|
||||
delete(equipped, mi.Slot)
|
||||
}
|
||||
|
||||
bondsBefore := countAttunedMagicItems(equipped)
|
||||
bonded, atCap := false, false
|
||||
if mi.Attunement {
|
||||
if bondsBefore >= dndMagicItemAttuneLimit {
|
||||
atCap = true
|
||||
} else {
|
||||
bonded = true
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the inventory row FIRST, then equip; if equip fails, restore the row.
|
||||
// The reverse order left a transient failure with the item both worn and in the
|
||||
// pack — a free duplicate.
|
||||
if err := removeAdvInventoryItem(it.ID); err != nil {
|
||||
slog.Error("magic-item: failed to remove from inventory before equip",
|
||||
"user", userID, "item", mi.ID, "err", err)
|
||||
return magicEquipOutcome{}, err
|
||||
}
|
||||
if err := equipMagicItem(userID, mi.Slot, mi.ID, bonded, it.Temper); err != nil {
|
||||
restored := magicItemSellAt(mi, it.Temper)
|
||||
restored.Value = it.Value
|
||||
restored.SkillSource = "magic_item:" + mi.ID
|
||||
if rbErr := addAdvInventoryItem(userID, restored); rbErr != nil {
|
||||
slog.Error("magic-item: equip failed AND inventory rollback failed",
|
||||
"user", userID, "item", mi.ID, "equip_err", err, "rollback_err", rbErr)
|
||||
}
|
||||
return magicEquipOutcome{}, err
|
||||
}
|
||||
|
||||
// Swapping the occupant out may have freed a bond slot — light up any straggler.
|
||||
healed, _ := reconcileMagicAttunements(userID)
|
||||
return magicEquipOutcome{
|
||||
Effective: temperedItem(mi, it.Temper),
|
||||
SwappedBack: swappedBack,
|
||||
Bonded: bonded,
|
||||
AtCap: atCap,
|
||||
BondsBefore: bondsBefore,
|
||||
Healed: healed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// magicUnequipOutcome is what an unequip did, for the caller to narrate.
|
||||
type magicUnequipOutcome struct {
|
||||
Item MagicItem // the item taken off, as it was worn
|
||||
Healed []string // stragglers the freed bond slot let bond
|
||||
}
|
||||
|
||||
// applyMagicUnequip takes the item off a slot and returns it to inventory,
|
||||
// mirroring the equip ordering (destructive op first, restore on failure). Sends
|
||||
// nothing.
|
||||
func applyMagicUnequip(userID id.UserID, slot DnDSlot) (magicUnequipOutcome, error) {
|
||||
equipped, err := loadEquippedMagicItems(userID)
|
||||
if err != nil {
|
||||
return magicUnequipOutcome{}, err
|
||||
}
|
||||
e, ok := equipped[slot]
|
||||
if !ok || e.Item.ID == "" {
|
||||
return magicUnequipOutcome{}, errSlotEmpty
|
||||
}
|
||||
if err := unequipMagicItem(userID, slot); err != nil {
|
||||
return magicUnequipOutcome{}, err
|
||||
}
|
||||
back := magicItemSellAt(e.Item, e.Temper)
|
||||
back.SkillSource = "magic_item:" + e.Item.ID
|
||||
if err := addAdvInventoryItem(userID, back); err != nil {
|
||||
if rbErr := equipMagicItem(userID, slot, e.Item.ID, e.Attuned, e.Temper); rbErr != nil {
|
||||
slog.Error("magic-item: unequip failed AND re-equip rollback failed",
|
||||
"user", userID, "item", e.Item.ID, "inv_err", err, "rollback_err", rbErr)
|
||||
}
|
||||
return magicUnequipOutcome{}, err
|
||||
}
|
||||
healed, _ := reconcileMagicAttunements(userID)
|
||||
return magicUnequipOutcome{Item: e.Effective(), Healed: healed}, nil
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleEquipMagicCmd(ctx MessageContext) error {
|
||||
// Self-heal first: bond any worn item stranded inert while a slot is free
|
||||
// (e.g. equipped at cap, then a bond slot opened). This is the only path a
|
||||
@@ -639,86 +767,31 @@ func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction
|
||||
}
|
||||
|
||||
it := data.Items[idx]
|
||||
mi, ok := magicItemFromAdvItem(it)
|
||||
if !ok || mi.Slot == "" {
|
||||
out, err := applyMagicEquip(ctx.Sender, it)
|
||||
if errors.Is(err, errItemNotEquippable) {
|
||||
return p.SendDM(ctx.Sender, "That item can't be equipped anymore.")
|
||||
}
|
||||
|
||||
equipped, err := loadEquippedMagicItems(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your equipped magic items.")
|
||||
}
|
||||
|
||||
// Return whatever currently occupies that slot to inventory at full
|
||||
// value — swapping a curio shouldn't tax it. Evict from the local map
|
||||
// too, so the attunement count below reflects the post-swap state and
|
||||
// can re-open a slot the prior occupant was holding.
|
||||
var swappedBackName string
|
||||
if prev, exists := equipped[mi.Slot]; exists && prev.Item.ID != "" {
|
||||
back := magicItemSellAt(prev.Item, prev.Temper)
|
||||
back.SkillSource = "magic_item:" + prev.Item.ID
|
||||
if err := addAdvInventoryItem(ctx.Sender, back); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to return your currently-equipped item to inventory.")
|
||||
}
|
||||
swappedBackName = prev.Item.Name
|
||||
delete(equipped, mi.Slot)
|
||||
}
|
||||
|
||||
// Auto-attune when the item needs it and an attunement slot is free.
|
||||
// Otherwise it equips inert until the player frees a slot.
|
||||
attune := false
|
||||
atCap := false
|
||||
if mi.Attunement {
|
||||
if countAttunedMagicItems(equipped) >= dndMagicItemAttuneLimit {
|
||||
atCap = true
|
||||
} else {
|
||||
attune = true
|
||||
}
|
||||
}
|
||||
// Remove the inventory row FIRST, then equip. If equip fails after the
|
||||
// remove succeeded, restore inventory. Doing it in the other order
|
||||
// meant a transient DB error on remove left the item both equipped
|
||||
// *and* still in inventory — a free duplication.
|
||||
if err := removeAdvInventoryItem(it.ID); err != nil {
|
||||
slog.Error("magic-item: failed to remove from inventory before equip",
|
||||
"user", ctx.Sender, "item", mi.ID, "err", err)
|
||||
return p.SendDM(ctx.Sender, "Failed to equip that item.")
|
||||
}
|
||||
if err := equipMagicItem(ctx.Sender, mi.Slot, mi.ID, attune, it.Temper); err != nil {
|
||||
// Roll back: try to put the item back in inventory so the player
|
||||
// doesn't lose it. Best-effort; log if the rollback also fails.
|
||||
restored := magicItemSellAt(mi, it.Temper)
|
||||
restored.Value = it.Value
|
||||
restored.SkillSource = "magic_item:" + mi.ID
|
||||
if rbErr := addAdvInventoryItem(ctx.Sender, restored); rbErr != nil {
|
||||
slog.Error("magic-item: equip failed AND inventory rollback failed",
|
||||
"user", ctx.Sender, "item", mi.ID, "equip_err", err, "rollback_err", rbErr)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Failed to equip that item.")
|
||||
}
|
||||
|
||||
eqMI := temperedItem(mi, it.Temper)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("✨ **%s** equipped in your %s slot — %s.",
|
||||
eqMI.Name, eqMI.Slot, magicItemEffectSummary(eqMI)))
|
||||
if swappedBackName != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n📦 **%s** moved back to inventory.", swappedBackName))
|
||||
out.Effective.Name, out.Effective.Slot, magicItemEffectSummary(out.Effective)))
|
||||
if out.SwappedBack != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n📦 **%s** moved back to inventory.", out.SwappedBack))
|
||||
}
|
||||
switch {
|
||||
case mi.Attunement && attune:
|
||||
case out.Bonded:
|
||||
sb.WriteString(fmt.Sprintf("\nBonded (%d/%d bond slots used).",
|
||||
countAttunedMagicItems(equipped)+1, dndMagicItemAttuneLimit))
|
||||
case mi.Attunement && atCap:
|
||||
out.BondsBefore+1, dndMagicItemAttuneLimit))
|
||||
case out.AtCap:
|
||||
sb.WriteString(fmt.Sprintf("\n⚠️ All %d bond slots are full — it's worn but **inert** until you free one (`!adventure unequip-magic` to take a bonded item off; this one bonds automatically once a slot opens).",
|
||||
dndMagicItemAttuneLimit))
|
||||
}
|
||||
|
||||
// Swapping out the prior occupant may have freed a bond slot — light up any
|
||||
// item that was stranded inert (including a previously-equipped one the
|
||||
// picker could never reach).
|
||||
if healed, _ := reconcileMagicAttunements(ctx.Sender); len(healed) > 0 {
|
||||
if len(out.Healed) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("\n🔗 A freed bond slot also activated **%s**.",
|
||||
strings.Join(healed, "**, **")))
|
||||
strings.Join(out.Healed, "**, **")))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, sb.String())
|
||||
}
|
||||
@@ -783,39 +856,19 @@ func (p *AdventurePlugin) resolveMagicUnequipReply(ctx MessageContext, interacti
|
||||
}
|
||||
|
||||
slot := data.Slots[idx]
|
||||
equipped, err := loadEquippedMagicItems(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your equipped magic items.")
|
||||
}
|
||||
e, ok := equipped[slot]
|
||||
if !ok || e.Item.ID == "" {
|
||||
out, err := applyMagicUnequip(ctx.Sender, slot)
|
||||
if errors.Is(err, errSlotEmpty) {
|
||||
return p.SendDM(ctx.Sender, "That slot is already empty.")
|
||||
}
|
||||
|
||||
// Clear the slot FIRST, then return the item to inventory at full value.
|
||||
// This mirrors the equip resolver's ordering (destructive op first, restore
|
||||
// on failure): the other order could leave the item both worn and in
|
||||
// inventory — a free duplicate — on a transient DB error.
|
||||
if err := unequipMagicItem(ctx.Sender, slot); err != nil {
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to take that item off.")
|
||||
}
|
||||
back := magicItemSellAt(e.Item, e.Temper)
|
||||
back.SkillSource = "magic_item:" + e.Item.ID
|
||||
if err := addAdvInventoryItem(ctx.Sender, back); err != nil {
|
||||
// Roll back: re-equip exactly as it was so the item isn't lost.
|
||||
if rbErr := equipMagicItem(ctx.Sender, slot, e.Item.ID, e.Attuned, e.Temper); rbErr != nil {
|
||||
slog.Error("magic-item: unequip failed AND re-equip rollback failed",
|
||||
"user", ctx.Sender, "item", e.Item.ID, "inv_err", err, "rollback_err", rbErr)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Failed to return that item to your inventory.")
|
||||
}
|
||||
|
||||
mi := e.Effective()
|
||||
msg := fmt.Sprintf("📦 **%s** taken off your %s slot and returned to inventory.", mi.Name, slot)
|
||||
msg := fmt.Sprintf("📦 **%s** taken off your %s slot and returned to inventory.", out.Item.Name, slot)
|
||||
// Freeing a bonded slot may let a worn-but-inert item finally bond.
|
||||
if healed, _ := reconcileMagicAttunements(ctx.Sender); len(healed) > 0 {
|
||||
if len(out.Healed) > 0 {
|
||||
msg += fmt.Sprintf("\n🔗 That freed a bond slot — **%s** is now active.",
|
||||
strings.Join(healed, "**, **"))
|
||||
strings.Join(out.Healed, "**, **"))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, msg)
|
||||
}
|
||||
|
||||
@@ -183,6 +183,12 @@ func emitFact(f peteclient.Fact, subjectUser, opponentUser id.UserID) {
|
||||
}
|
||||
}
|
||||
f.Actors = actors
|
||||
// Author the prose in Pete's voice from the FINAL fact, so the names in the
|
||||
// dispatch match the Actors allow-list Pete guards against. Best-effort: an
|
||||
// empty pair (LLM off or authoring failed) just means Pete templates the
|
||||
// fact. Synchronous, like the holdem tip rewrite — news facts are infrequent
|
||||
// and the call is tightly bounded (dispatchLLMTimeout).
|
||||
f.Headline, f.Lede = authorDispatch(f)
|
||||
peteclient.Emit(f)
|
||||
}
|
||||
|
||||
@@ -336,3 +342,64 @@ func emitZoneClearNews(userID id.UserID, exp *Expedition) {
|
||||
OccurredAt: ts,
|
||||
}, userID, "")
|
||||
}
|
||||
|
||||
// treasureRarityWord maps a treasure def's tier to the rarity adjective Pete
|
||||
// weaves into a find's dispatch. Story-grade treasures are typically tier 5, so
|
||||
// "legendary" is the common case; the lower tiers are here for completeness.
|
||||
func treasureRarityWord(tier int) string {
|
||||
switch {
|
||||
case tier >= 5:
|
||||
return "legendary"
|
||||
case tier == 4:
|
||||
return "epic"
|
||||
case tier == 3:
|
||||
return "rare"
|
||||
case tier == 2:
|
||||
return "uncommon"
|
||||
default:
|
||||
return "common"
|
||||
}
|
||||
}
|
||||
|
||||
// emitTreasureFound files a story-grade treasure find. Only treasures carrying a
|
||||
// RoomAnnounce string reach here — the same gate that earns them a public moment
|
||||
// — so a copper-piece pickup never becomes news. The realm's first finder of a
|
||||
// given treasure is a PRIORITY hoard; a later finder of the same item is a
|
||||
// BULLETIN, the first/repeat split zone_first already uses. Character name only;
|
||||
// no-op unless the seam is enabled.
|
||||
//
|
||||
// treasure_found is an event_type Pete's ingest must already know: an unknown
|
||||
// type 400s, retries to the cap, then parks the bulletin forever. Deploy Pete
|
||||
// first.
|
||||
func emitTreasureFound(userID id.UserID, def *AdvTreasureDef, loc *AdvLocation) {
|
||||
if !peteclient.Enabled() || !newsEmissionOn() {
|
||||
return
|
||||
}
|
||||
if def == nil || loc == nil {
|
||||
return
|
||||
}
|
||||
// Claim the realm-first BEFORE the name guard, so an unnamed straggler's
|
||||
// genuine first find still seeds the ledger and the next finder isn't
|
||||
// mis-billed as the first-ever. Mirrors emitZoneClearNews.
|
||||
tier := "bulletin"
|
||||
if claimRealmFirst("treasure", def.Key) {
|
||||
tier = "priority"
|
||||
}
|
||||
name := charName(userID)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
ts := nowUnix()
|
||||
disc := fmt.Sprintf("%s:%d", def.Key, ts)
|
||||
emitFact(peteclient.Fact{
|
||||
GUID: fmt.Sprintf("treasure_found:%s:%s:%d", eventToken(userID, disc), def.Key, ts),
|
||||
EventType: "treasure_found",
|
||||
Tier: tier,
|
||||
Subject: name,
|
||||
Zone: loc.Name,
|
||||
Level: charLevel(userID),
|
||||
Stakes: def.Name,
|
||||
Outcome: treasureRarityWord(def.Tier),
|
||||
OccurredAt: ts,
|
||||
}, userID, "")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/peteclient"
|
||||
)
|
||||
|
||||
// weapon/ring/wondrous helpers build a MagicItem the codified effect formula
|
||||
// (magicItemEffectFor) can score without touching the DB.
|
||||
func mkItem(kind MagicItemKind, rarity DnDRarity, slot DnDSlot) MagicItem {
|
||||
return MagicItem{ID: string(kind) + "_" + string(rarity), Kind: kind, Rarity: rarity, Slot: slot}
|
||||
}
|
||||
|
||||
// TestMagicItemDeltasDirection pins the "which way is better" call for each stat,
|
||||
// including DamageReductMult where LOWER is the improvement.
|
||||
func TestMagicItemDeltasDirection(t *testing.T) {
|
||||
neutral := magicItemEffect{DamageReductMult: 1.0}
|
||||
|
||||
t.Run("more damage is better", func(t *testing.T) {
|
||||
d := magicItemDeltas(magicItemEffect{DamageBonus: 0.15, DamageReductMult: 1.0}, magicItemEffect{DamageBonus: 0.10, DamageReductMult: 1.0})
|
||||
if len(d) != 1 || d[0].Label != "damage" || !d[0].Better || d[0].Text != "+5% damage" {
|
||||
t.Fatalf("got %+v", d)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("less damage taken is better", func(t *testing.T) {
|
||||
// cand mult 0.90 (blocks 10%) vs worn neutral 1.0 (blocks nothing).
|
||||
d := magicItemDeltas(magicItemEffect{DamageReductMult: 0.90}, neutral)
|
||||
if len(d) != 1 || d[0].Label != "defense" || !d[0].Better || d[0].Text != "-10% damage taken" {
|
||||
t.Fatalf("got %+v", d)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("weaker armor reads as worse", func(t *testing.T) {
|
||||
// cand blocks less than worn: mult rises, damage taken goes up.
|
||||
d := magicItemDeltas(magicItemEffect{DamageReductMult: 0.96}, magicItemEffect{DamageReductMult: 0.90})
|
||||
if len(d) != 1 || d[0].Better || d[0].Text != "+6% damage taken" {
|
||||
t.Fatalf("got %+v", d)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hp and opening damage", func(t *testing.T) {
|
||||
d := magicItemDeltas(
|
||||
magicItemEffect{DamageReductMult: 1.0, MaxHP: 10, FlatDmgStart: 3},
|
||||
magicItemEffect{DamageReductMult: 1.0, MaxHP: 6, FlatDmgStart: 5},
|
||||
)
|
||||
byLabel := map[string]itemDelta{}
|
||||
for _, x := range d {
|
||||
byLabel[x.Label] = itemDelta{x.Better, x.Text}
|
||||
}
|
||||
if v := byLabel["hp"]; v.text != "+4 HP" || !v.better {
|
||||
t.Errorf("hp: %+v", v)
|
||||
}
|
||||
if v := byLabel["opening"]; v.text != "-2 opening damage" || v.better {
|
||||
t.Errorf("opening: %+v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sub-percent change is dropped", func(t *testing.T) {
|
||||
// 0.004 fraction = 0.4% → rounds to 0% → not a visible delta.
|
||||
if d := magicItemDeltas(
|
||||
magicItemEffect{DamageBonus: 0.104, DamageReductMult: 1.0},
|
||||
magicItemEffect{DamageBonus: 0.100, DamageReductMult: 1.0},
|
||||
); len(d) != 0 {
|
||||
t.Fatalf("expected no visible delta, got %+v", d)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type itemDelta struct {
|
||||
better bool
|
||||
text string
|
||||
}
|
||||
|
||||
// TestCompareVerdict pins strict-dominance classification and the overrides.
|
||||
func TestCompareVerdict(t *testing.T) {
|
||||
gain := []peteclient.ItemDelta{{Better: true}}
|
||||
loss := []peteclient.ItemDelta{{Better: false}}
|
||||
mixed := []peteclient.ItemDelta{{Better: true}, {Better: false}}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
deltas []peteclient.ItemDelta
|
||||
empty, inrt bool
|
||||
want string
|
||||
}{
|
||||
{"all gains", gain, false, false, "upgrade"},
|
||||
{"all losses", loss, false, false, "downgrade"},
|
||||
{"mixed", mixed, false, false, "sidegrade"},
|
||||
{"no change", nil, false, false, "same"},
|
||||
{"empty slot", gain, true, false, "new"},
|
||||
{"inert overrides upgrade", gain, false, true, "inert"},
|
||||
{"inert overrides new", gain, true, true, "inert"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := compareVerdict(c.deltas, c.empty, c.inrt); got != c.want {
|
||||
t.Errorf("compareVerdict(%s) = %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMagicItemCompareIntegration drives the whole builder against equipped maps.
|
||||
func TestMagicItemCompareIntegration(t *testing.T) {
|
||||
rareWpn := mkItem(MagicItemWeapon, RarityRare, DnDSlotMainHand) // +15% damage
|
||||
uncWpn := mkItem(MagicItemWeapon, RarityUncommon, DnDSlotMainHand) // +10% damage
|
||||
|
||||
t.Run("upgrade over a weaker worn weapon", func(t *testing.T) {
|
||||
eq := map[DnDSlot]EquippedMagicItem{DnDSlotMainHand: {Slot: DnDSlotMainHand, Item: uncWpn}}
|
||||
c := magicItemCompare(rareWpn, 0, eq)
|
||||
if c.Verdict != "upgrade" || c.VsName != uncWpn.Name || c.VsSlot != "main_hand" {
|
||||
t.Fatalf("got %+v", c)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("downgrade under a stronger worn weapon", func(t *testing.T) {
|
||||
eq := map[DnDSlot]EquippedMagicItem{DnDSlotMainHand: {Slot: DnDSlotMainHand, Item: rareWpn}}
|
||||
if c := magicItemCompare(uncWpn, 0, eq); c.Verdict != "downgrade" {
|
||||
t.Fatalf("got %+v", c)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same as an identical worn weapon", func(t *testing.T) {
|
||||
eq := map[DnDSlot]EquippedMagicItem{DnDSlotMainHand: {Slot: DnDSlotMainHand, Item: rareWpn}}
|
||||
c := magicItemCompare(rareWpn, 0, eq)
|
||||
if c.Verdict != "same" || len(c.Deltas) != 0 {
|
||||
t.Fatalf("got %+v", c)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("new into an empty slot names no worn item", func(t *testing.T) {
|
||||
c := magicItemCompare(rareWpn, 0, map[DnDSlot]EquippedMagicItem{})
|
||||
if c.Verdict != "new" || c.VsName != "" || len(c.Deltas) == 0 {
|
||||
t.Fatalf("got %+v", c)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("attunement item with no free bond is inert", func(t *testing.T) {
|
||||
ring := mkItem(MagicItemRing, RarityRare, DnDSlotRing1)
|
||||
ring.Attunement = true
|
||||
// Three bonds spent elsewhere; the ring slot is empty. Wearing it does nothing.
|
||||
eq := map[DnDSlot]EquippedMagicItem{
|
||||
DnDSlotChest: {Slot: DnDSlotChest, Item: mkItem(MagicItemArmor, RarityRare, DnDSlotChest), Attuned: true},
|
||||
DnDSlotAmulet: {Slot: DnDSlotAmulet, Item: mkItem(MagicItemWondrous, RarityRare, DnDSlotAmulet), Attuned: true},
|
||||
DnDSlotCloak: {Slot: DnDSlotCloak, Item: mkItem(MagicItemWondrous, RarityRare, DnDSlotCloak), Attuned: true},
|
||||
}
|
||||
if c := magicItemCompare(ring, 0, eq); c.Verdict != "inert" {
|
||||
t.Fatalf("got %+v", c)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("evicting the worn occupant frees its bond, so not inert", func(t *testing.T) {
|
||||
ring := mkItem(MagicItemRing, RarityRare, DnDSlotRing1)
|
||||
ring.Attunement = true
|
||||
wornRing := mkItem(MagicItemRing, RarityUncommon, DnDSlotRing1)
|
||||
wornRing.Attunement = true
|
||||
// Three bonds spent — but one of them is the ring the candidate would replace.
|
||||
eq := map[DnDSlot]EquippedMagicItem{
|
||||
DnDSlotRing1: {Slot: DnDSlotRing1, Item: wornRing, Attuned: true},
|
||||
DnDSlotChest: {Slot: DnDSlotChest, Item: mkItem(MagicItemArmor, RarityRare, DnDSlotChest), Attuned: true},
|
||||
DnDSlotAmulet: {Slot: DnDSlotAmulet, Item: mkItem(MagicItemWondrous, RarityRare, DnDSlotAmulet), Attuned: true},
|
||||
}
|
||||
if c := magicItemCompare(ring, 0, eq); c.Verdict == "inert" {
|
||||
t.Fatalf("bond freed by eviction, should not be inert: %+v", c)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/peteclient"
|
||||
)
|
||||
|
||||
// LLM-authored adventure dispatches. gogobee owns the raw model compute; this is
|
||||
// where a structured fact becomes warm-reporter prose for Pete to publish. Pete
|
||||
// is still the editor and the safety boundary: it runs its own prose-guard over
|
||||
// whatever we send and falls back to its templates on anything it does not like,
|
||||
// so authoring here is best-effort by design — every failure path returns an
|
||||
// empty pair and Pete templates the fact.
|
||||
//
|
||||
// The voice must live somewhere, and with no route for Pete to call back into
|
||||
// this box (see roster.go in the Pete repo) it lives in the prompt below. Keep
|
||||
// it faithful to pete_adventure_news_voice.md; Pete's persona, not gogobee's.
|
||||
|
||||
// dispatchLLMTimeout bounds the authoring call. emitFact runs on game-event
|
||||
// chokepoints (a party wipe fires one per member), so this is deliberately far
|
||||
// tighter than the interactive 120s tip budget: if the model cannot turn a
|
||||
// handful of facts into two sentences this fast, it is effectively down, and a
|
||||
// template dispatch now beats a voiced one late.
|
||||
const dispatchLLMTimeout = 15 * time.Second
|
||||
|
||||
// Length ceilings, mirrored from Pete's proseGuard so we never ship prose Pete
|
||||
// will reject for length alone. Byte counts, matching Pete's len() check.
|
||||
const (
|
||||
maxDispatchHeadline = 200
|
||||
maxDispatchLede = 800
|
||||
)
|
||||
|
||||
var dispatchHTTP = &http.Client{Timeout: dispatchLLMTimeout}
|
||||
|
||||
// authorDispatch turns a fact into a headline+lede in Pete's voice, or returns
|
||||
// two empty strings if the model is unconfigured, errors, times out, or produces
|
||||
// anything malformed. The fact must already have its FINAL Actors set (post
|
||||
// opt-out anonymisation) — that list is the only set of names the prose may use,
|
||||
// and it is what Pete's guard checks the output against.
|
||||
func authorDispatch(f peteclient.Fact) (headline, lede string) {
|
||||
host := os.Getenv("OLLAMA_HOST")
|
||||
model := os.Getenv("OLLAMA_MODEL")
|
||||
if host == "" || model == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
prompt := buildDispatchPrompt(f)
|
||||
raw, err := callOllamaDispatch(host, model, prompt)
|
||||
if err != nil {
|
||||
slog.Warn("pete dispatch: LLM authoring failed, Pete will template", "guid", f.GUID, "err", err)
|
||||
return "", ""
|
||||
}
|
||||
|
||||
h, l, ok := parseDispatch(raw)
|
||||
if !ok {
|
||||
slog.Warn("pete dispatch: unparseable LLM output, Pete will template", "guid", f.GUID)
|
||||
return "", ""
|
||||
}
|
||||
// Ship only a complete, in-bounds pair. A half-authored dispatch or an
|
||||
// over-long one is exactly what Pete would reject anyway; catch it here so a
|
||||
// bad generation costs nothing on the wire.
|
||||
if h == "" || l == "" || len(h) > maxDispatchHeadline || len(l) > maxDispatchLede {
|
||||
slog.Warn("pete dispatch: LLM output empty or over length, Pete will template",
|
||||
"guid", f.GUID, "headline_len", len(h), "lede_len", len(l))
|
||||
return "", ""
|
||||
}
|
||||
return h, l
|
||||
}
|
||||
|
||||
// buildDispatchPrompt renders the persona, the strict rules, and this fact's
|
||||
// structured facts into a single prompt. The facts block lists only the fields
|
||||
// that are set, each labelled, so the model has the who/what/where and no room
|
||||
// to invent the rest.
|
||||
func buildDispatchPrompt(f peteclient.Fact) string {
|
||||
var facts strings.Builder
|
||||
add := func(label, val string) {
|
||||
if val != "" {
|
||||
fmt.Fprintf(&facts, "- %s: %s\n", label, val)
|
||||
}
|
||||
}
|
||||
addN := func(label string, n int) {
|
||||
if n != 0 {
|
||||
fmt.Fprintf(&facts, "- %s: %d\n", label, n)
|
||||
}
|
||||
}
|
||||
add("event", f.EventType)
|
||||
add("who this is about (the subject)", f.Subject)
|
||||
add("the other person named", f.Opponent)
|
||||
add("monster or boss", f.Boss)
|
||||
add("dungeon or zone", f.Zone)
|
||||
add("region", f.Region)
|
||||
addN("character level", f.Level)
|
||||
addN("count", f.Count)
|
||||
add("outcome", f.Outcome)
|
||||
add("stakes or item", f.Stakes)
|
||||
add("class and race", f.ClassRace)
|
||||
add("milestone", f.Milestone)
|
||||
|
||||
names := "(none — this is a realm-level event with no named adventurer)"
|
||||
if len(f.Actors) > 0 {
|
||||
names = strings.Join(f.Actors, ", ")
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`You are Pete, a warm, friendly local news reporter for a fantasy adventuring town. Think a beloved local newscaster who genuinely knows everyone and is glad to see them. You have journalistic bones — a clear headline and a who/what/where lede that gets the facts right — delivered with personable, first-person warmth. You root for the community, celebrate wins, mourn losses gently, welcome newcomers. Conversational, never snarky, never a caps-lock hype-man. Warmth carries the register, not exclamation marks.
|
||||
|
||||
Write a short news dispatch about the event below.
|
||||
|
||||
STRICT RULES — do not violate these:
|
||||
- Use ONLY these adventurer names, exactly as written: %s. Never invent a name, never use any other person's name, never use an @-handle.
|
||||
- Use ONLY the facts listed. Do not invent numbers, outcomes, items, or events that are not below.
|
||||
- The monster/boss, zone, region and item names are game names — you may use them as given.
|
||||
- Do not address the reader as "you" unless the event is Pete's own duel.
|
||||
- No markdown, no emoji, no quotation marks around the whole thing.
|
||||
|
||||
Respond with ONLY a JSON object, no other text:
|
||||
{"headline": "one short sentence, a real headline", "lede": "one to three warm sentences with the who/what/where"}
|
||||
|
||||
The event:
|
||||
%s`, names, facts.String())
|
||||
}
|
||||
|
||||
// callOllamaDispatch posts a single non-streaming generation and returns the raw
|
||||
// completion (think-tags stripped). Its own bounded client, separate from the
|
||||
// interactive callOllama, because the game loop cannot wait 120s on the news.
|
||||
func callOllamaDispatch(host, model, prompt string) (string, error) {
|
||||
payload := map[string]interface{}{
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": false,
|
||||
"think": false,
|
||||
"options": map[string]interface{}{
|
||||
"num_ctx": 4096,
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
apiURL := strings.TrimRight(host, "/") + "/api/generate"
|
||||
resp, err := dispatchHTTP.Post(apiURL, "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ollama request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("ollama HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var result struct {
|
||||
Response string `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return result.Response, nil
|
||||
}
|
||||
|
||||
// parseDispatch pulls {headline, lede} out of the model's completion, tolerating
|
||||
// the usual noise (think blocks, markdown fences, prose around the JSON). ok is
|
||||
// false when no JSON object with a headline can be recovered.
|
||||
func parseDispatch(raw string) (headline, lede string, ok bool) {
|
||||
s := raw
|
||||
// Drop a Qwen-style reasoning block if present.
|
||||
if i := strings.Index(s, "<think>"); i != -1 {
|
||||
if j := strings.Index(s, "</think>"); j != -1 {
|
||||
s = s[:i] + s[j+len("</think>"):]
|
||||
}
|
||||
}
|
||||
// Isolate the first {...} object so surrounding prose or fences don't break
|
||||
// the decode.
|
||||
start := strings.Index(s, "{")
|
||||
end := strings.LastIndex(s, "}")
|
||||
if start < 0 || end <= start {
|
||||
return "", "", false
|
||||
}
|
||||
var out struct {
|
||||
Headline string `json:"headline"`
|
||||
Lede string `json:"lede"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(s[start:end+1]), &out); err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
headline = strings.TrimSpace(out.Headline)
|
||||
lede = strings.TrimSpace(out.Lede)
|
||||
if headline == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return headline, lede, true
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/peteclient"
|
||||
)
|
||||
|
||||
func TestParseDispatch(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantOK bool
|
||||
wantHeadPre string
|
||||
}{
|
||||
{
|
||||
name: "clean json",
|
||||
raw: `{"headline": "Josie cleared the Ossuary.", "lede": "Alone, no less."}`,
|
||||
wantOK: true,
|
||||
wantHeadPre: "Josie cleared",
|
||||
},
|
||||
{
|
||||
name: "wrapped in prose and fences",
|
||||
raw: "Sure! Here you go:\n```json\n{\"headline\":\"A win.\",\"lede\":\"Nice one.\"}\n```",
|
||||
wantOK: true,
|
||||
wantHeadPre: "A win.",
|
||||
},
|
||||
{
|
||||
name: "think block stripped",
|
||||
raw: "<think>let me consider the tone</think>\n{\"headline\":\"Held the line.\",\"lede\":\"Proud of you all.\"}",
|
||||
wantOK: true,
|
||||
wantHeadPre: "Held the line.",
|
||||
},
|
||||
{name: "no json", raw: "I could not write that.", wantOK: false},
|
||||
{name: "empty headline", raw: `{"headline":"","lede":"body"}`, wantOK: false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
h, l, ok := parseDispatch(c.raw)
|
||||
if ok != c.wantOK {
|
||||
t.Fatalf("ok = %v, want %v (h=%q l=%q)", ok, c.wantOK, h, l)
|
||||
}
|
||||
if ok && !strings.HasPrefix(h, c.wantHeadPre) {
|
||||
t.Errorf("headline = %q, want prefix %q", h, c.wantHeadPre)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDispatchPrompt pins the two properties the prose-guard depends on:
|
||||
// the allowed names are stated verbatim, and only the set facts appear (no empty
|
||||
// labels for the model to fill in with invention).
|
||||
func TestBuildDispatchPrompt(t *testing.T) {
|
||||
f := peteclient.Fact{
|
||||
EventType: "boss_kill", Subject: "Josie", Boss: "the Bone Warden",
|
||||
Zone: "the Ossuary", Level: 14, Actors: []string{"Josie"},
|
||||
}
|
||||
p := buildDispatchPrompt(f)
|
||||
|
||||
if !strings.Contains(p, "ONLY these adventurer names, exactly as written: Josie") {
|
||||
t.Errorf("prompt does not constrain names to Actors:\n%s", p)
|
||||
}
|
||||
if !strings.Contains(p, "the Bone Warden") || !strings.Contains(p, "the Ossuary") {
|
||||
t.Error("prompt dropped a supplied fact")
|
||||
}
|
||||
// Unset fields must not appear as empty labels.
|
||||
if strings.Contains(p, "region:") || strings.Contains(p, "milestone:") {
|
||||
t.Errorf("prompt lists an unset fact:\n%s", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDispatchPromptRealmEvent: a realm-level fact with no named adventurer
|
||||
// still produces a usable prompt that tells the model there is no name to use.
|
||||
func TestBuildDispatchPromptRealmEvent(t *testing.T) {
|
||||
f := peteclient.Fact{EventType: "siege_start", Boss: "the Horde", Stakes: "the whole town"}
|
||||
p := buildDispatchPrompt(f)
|
||||
if !strings.Contains(p, "no named adventurer") {
|
||||
t.Errorf("realm event prompt missing the no-name note:\n%s", p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package plugin
|
||||
|
||||
// The web equip queue's game-side loop.
|
||||
//
|
||||
// An owner asks, on their own detail page on Pete, to wear or take off an item.
|
||||
// Pete records the intent; we poll for it, run the real equip against our own
|
||||
// equipment tables, and file a verdict Pete shows them. Same reverse-pipe shape
|
||||
// as mischief — Pete has no route into this box, so we ask for work rather than
|
||||
// being told about it.
|
||||
//
|
||||
// The one thing that is NOT like mischief: the underlying action isn't
|
||||
// idempotent. Equipping consumes an inventory row and unequipping mints a fresh
|
||||
// one, so simply re-running a re-offered order would double-move the item. So
|
||||
// before we touch anything we check the equip_applied_orders ledger: if this
|
||||
// order's guid is already there, the mutation happened on an earlier tick and we
|
||||
// only lost the verdict-ack — we re-file the stored verdict and mutate nothing.
|
||||
// The guid is still the end-to-end key; here it guards a non-idempotent action
|
||||
// instead of riding a naturally idempotent one.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/peteclient"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
const (
|
||||
equipPollInterval = 30 * time.Second
|
||||
equipPollTimeout = 20 * time.Second
|
||||
)
|
||||
|
||||
// peteEquipTicker polls Pete for equip orders and fulfils them. Started alongside
|
||||
// the other adventure tickers; exits on stopCh.
|
||||
func (p *AdventurePlugin) peteEquipTicker() {
|
||||
if !peteclient.Enabled() {
|
||||
return // no Pete wire configured; the equip queue is simply off
|
||||
}
|
||||
ticker := time.NewTicker(equipPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.pollEquipOrders()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) pollEquipOrders() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), equipPollTimeout)
|
||||
defer cancel()
|
||||
|
||||
orders, err := peteclient.PendingEquip(ctx)
|
||||
if err != nil {
|
||||
// A Pete predating the queue answers 404; a wire blip looks the same. Quiet
|
||||
// on purpose — this must not spam while Pete hasn't shipped the endpoint.
|
||||
slog.Debug("equip: poll failed", "err", err)
|
||||
return
|
||||
}
|
||||
for _, order := range orders {
|
||||
p.fulfilEquipOrder(ctx, order)
|
||||
}
|
||||
}
|
||||
|
||||
// fulfilEquipOrder applies one order and files its verdict. A transient failure is
|
||||
// left pending for the next poll (no verdict); a permanent one gets a specific
|
||||
// rejection. The guid ledger makes a re-offer after a lost ack a no-op that simply
|
||||
// re-files the verdict.
|
||||
func (p *AdventurePlugin) fulfilEquipOrder(ctx context.Context, order peteclient.EquipOrder) {
|
||||
// Already applied on an earlier tick? Re-file the stored verdict, mutate nothing.
|
||||
if status, detail, ok := equipOrderAlreadyApplied(order.GUID); ok {
|
||||
if err := peteclient.VerdictEquip(ctx, order.GUID, status, detail); err != nil {
|
||||
slog.Warn("equip: re-file verdict push failed, will retry next poll",
|
||||
"order", order.GUID, "status", status, "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
owner, ok := p.equipOwnerMXID(order.OwnerLocalpart)
|
||||
if !ok {
|
||||
// The client isn't up (tests) or the localpart is empty. Not our order to
|
||||
// fail permanently — leave it pending and try again once we can name the owner.
|
||||
slog.Debug("equip: cannot resolve owner, leaving pending", "order", order.GUID, "owner", order.OwnerLocalpart)
|
||||
return
|
||||
}
|
||||
|
||||
status, detail, retry := p.applyEquipOrder(owner, order)
|
||||
if retry {
|
||||
return // transient; leave pending for the next tick
|
||||
}
|
||||
|
||||
// Record the verdict BEFORE pushing it, so a crash after the mutation still
|
||||
// short-circuits next tick and re-files rather than re-applying. The mutation
|
||||
// and this insert aren't one transaction, but the window between them is a
|
||||
// single statement — the same practical guarantee the DM equip path lives with.
|
||||
if err := recordEquipApplied(order.GUID, status, detail); err != nil {
|
||||
// If we can't record it, don't push the verdict either: leave the order
|
||||
// pending so the ledger and Pete stay in step. Re-running an equip is the
|
||||
// double-move we're guarding against, so a rare re-apply here is the lesser
|
||||
// evil versus a verdict with no ledger behind it. Transient; retry.
|
||||
slog.Warn("equip: failed to record applied order, leaving pending",
|
||||
"order", order.GUID, "status", status, "err", err)
|
||||
return
|
||||
}
|
||||
if err := peteclient.VerdictEquip(ctx, order.GUID, status, detail); err != nil {
|
||||
slog.Warn("equip: verdict push failed, will re-file next poll",
|
||||
"order", order.GUID, "status", status, "err", err)
|
||||
return
|
||||
}
|
||||
slog.Info("equip: web order fulfilled", "order", order.GUID, "action", order.Action, "status", status)
|
||||
}
|
||||
|
||||
// applyEquipOrder runs the real equip/unequip. It returns the terminal status and
|
||||
// a human note for Pete, or retry=true for a transient fault that should leave the
|
||||
// order pending. It records nothing and pushes nothing — the caller does both.
|
||||
func (p *AdventurePlugin) applyEquipOrder(owner id.UserID, order peteclient.EquipOrder) (status, detail string, retry bool) {
|
||||
switch order.Action {
|
||||
case "equip":
|
||||
inv, err := loadAdvInventory(owner)
|
||||
if err != nil {
|
||||
return "", "", true // transient
|
||||
}
|
||||
var it AdvItem
|
||||
found := false
|
||||
for _, cand := range inv {
|
||||
if cand.ID == order.ItemID {
|
||||
it, found = cand, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
// The row left the pack before we got here (worn already, sold, a stale
|
||||
// page). The table is AUTOINCREMENT, so the id can't have been reused for
|
||||
// a different item — this is a clean miss, not a wrong hit.
|
||||
return "rejected_not_owned", "That item wasn't in your pack anymore.", false
|
||||
}
|
||||
out, err := applyMagicEquip(owner, it)
|
||||
if errors.Is(err, errItemNotEquippable) {
|
||||
return "rejected_not_equippable", "That item can't be worn.", false
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", true // transient DB fault
|
||||
}
|
||||
return "applied", equipAppliedDetail(out), false
|
||||
|
||||
case "unequip":
|
||||
out, err := applyMagicUnequip(owner, DnDSlot(order.Slot))
|
||||
if errors.Is(err, errSlotEmpty) {
|
||||
return "rejected_not_worn", "That slot was already empty.", false
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", true
|
||||
}
|
||||
note := fmt.Sprintf("Took off %s, back in your pack.", out.Item.Name)
|
||||
if len(out.Healed) > 0 {
|
||||
note += fmt.Sprintf(" That freed a bond, so %s is active now.", strings.Join(out.Healed, ", "))
|
||||
}
|
||||
return "applied", note, false
|
||||
|
||||
default:
|
||||
// Pete validates the action before it ever queues an order, so this is a
|
||||
// contract breach, not a user mistake. Reject permanently rather than spin.
|
||||
return "rejected_not_equippable", "Unknown action.", false
|
||||
}
|
||||
}
|
||||
|
||||
// equipAppliedDetail turns an equip outcome into the plain note Pete shows.
|
||||
func equipAppliedDetail(out magicEquipOutcome) string {
|
||||
b := fmt.Sprintf("Now worn in your %s slot.", out.Effective.Slot)
|
||||
switch {
|
||||
case out.Bonded:
|
||||
b += fmt.Sprintf(" Bonded (%d of %d).", out.BondsBefore+1, dndMagicItemAttuneLimit)
|
||||
case out.AtCap:
|
||||
b += fmt.Sprintf(" Worn but inert: all %d bonds are in use, so take one off to activate it.", dndMagicItemAttuneLimit)
|
||||
}
|
||||
if out.SwappedBack != "" {
|
||||
b += fmt.Sprintf(" %s went back to your pack.", out.SwappedBack)
|
||||
}
|
||||
if len(out.Healed) > 0 {
|
||||
b += fmt.Sprintf(" A freed bond also activated %s.", strings.Join(out.Healed, ", "))
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// equipOwnerMXID reconstructs the owner's Matrix id from the localpart Pete sent,
|
||||
// the same construction as the mischief buyer's. Fails closed if the client isn't
|
||||
// up (tests) or the name is empty.
|
||||
func (p *AdventurePlugin) equipOwnerMXID(localpart string) (id.UserID, bool) {
|
||||
lp := strings.ToLower(strings.TrimSpace(localpart))
|
||||
if lp == "" || p.Client == nil {
|
||||
return "", false
|
||||
}
|
||||
server := p.Client.UserID.Homeserver()
|
||||
if server == "" {
|
||||
return "", false
|
||||
}
|
||||
return id.NewUserID(lp, server), true
|
||||
}
|
||||
|
||||
// ---- the applied-order ledger --------------------------------------------------
|
||||
|
||||
// equipOrderAlreadyApplied reports the verdict we filed for an order, if we have
|
||||
// already applied it. This is the short-circuit that keeps a re-offered order from
|
||||
// re-running its non-idempotent mutation.
|
||||
func equipOrderAlreadyApplied(guid string) (status, detail string, ok bool) {
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT status, detail FROM equip_applied_orders WHERE guid = ?`, guid,
|
||||
).Scan(&status, &detail)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", false
|
||||
}
|
||||
if err != nil {
|
||||
// A read failure here would send us down the mutation path and risk a
|
||||
// double-move, so treat it as "don't know" and let the caller leave the
|
||||
// order pending rather than assume it's fresh. We signal that by returning
|
||||
// ok=false but... the caller can't tell the difference. Log loudly; a
|
||||
// persistent read failure is a real problem, but a transient one self-heals
|
||||
// on the next poll because the mutation itself is guarded by this same table.
|
||||
slog.Error("equip: applied-ledger read failed", "order", guid, "err", err)
|
||||
return "", "", false
|
||||
}
|
||||
return status, detail, true
|
||||
}
|
||||
|
||||
// recordEquipApplied stamps an order as applied with the verdict we're about to
|
||||
// file. OR IGNORE so a re-file that somehow reaches here can't error on the guid.
|
||||
func recordEquipApplied(guid, status, detail string) error {
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT OR IGNORE INTO equip_applied_orders (guid, status, detail) VALUES (?, ?, ?)`,
|
||||
guid, status, detail)
|
||||
return err
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
@@ -198,29 +200,233 @@ func buildDetailSnapshot(now time.Time) (peteclient.DetailSnapshot, error) {
|
||||
}
|
||||
if items, err := loadAdvInventory(uid); err == nil {
|
||||
pd.Inventory = itemViews(items)
|
||||
if equipped, err := loadEquippedMagicItems(uid); err == nil {
|
||||
attachInventoryCompares(pd.Inventory, items, equipped)
|
||||
}
|
||||
}
|
||||
if items, err := loadAdvVault(uid); err == nil {
|
||||
pd.Vault = itemViews(items)
|
||||
}
|
||||
pd.Equipped = equippedViews(uid)
|
||||
snap.Players = append(snap.Players, pd)
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// itemViews renders inventory or vault rows for the private panel, resolving
|
||||
// the display facts the row itself doesn't carry.
|
||||
//
|
||||
// Attuned is always false here and that is not an omission: equipping *moves*
|
||||
// the row out of adventure_inventory into magic_item_equipped, so nothing in a
|
||||
// backpack can hold a bond. Worn items come from equippedViews instead.
|
||||
func itemViews(items []AdvItem) []peteclient.ItemView {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]peteclient.ItemView, 0, len(items))
|
||||
for _, it := range items {
|
||||
out = append(out, peteclient.ItemView{
|
||||
v := peteclient.ItemView{
|
||||
Name: it.Name,
|
||||
Type: it.Type,
|
||||
Tier: it.Tier,
|
||||
Value: it.Value,
|
||||
Temper: it.Temper,
|
||||
Slot: string(it.Slot),
|
||||
}
|
||||
// SkillSource is dual-use: a real skill name on masterwork gear, an
|
||||
// internal registry pointer on magic-item rows. Only the former is a
|
||||
// fact about the item; the latter is plumbing and stays home.
|
||||
if !strings.HasPrefix(it.SkillSource, "magic_item:") {
|
||||
v.SkillSource = it.SkillSource
|
||||
}
|
||||
if mi, ok := magicItemFromAdvItem(it); ok {
|
||||
eff := temperedItem(mi, it.Temper)
|
||||
v.Slot = string(eff.Slot)
|
||||
v.Desc = eff.Desc
|
||||
v.Effect = magicItemEffectSummary(eff)
|
||||
v.Attunement = eff.Attunement
|
||||
// The row id is the equip handle: a slotted magic item is the one thing
|
||||
// the web equip path can wear, so only it carries an id. Mundane gear (the
|
||||
// branch below) and unslotted curios get none, so no Equip button. This
|
||||
// runs for vault rows too — a vault magic item would carry an id — but Pete
|
||||
// offers the button on the backpack panel alone, so that's inert, not a leak.
|
||||
if eff.Slot != "" {
|
||||
v.ID = it.ID
|
||||
}
|
||||
} else if it.Slot != "" {
|
||||
// Shop equipment resolves by (slot, tier) — Name is decorative.
|
||||
v.Desc = equipmentDefByTier(it.Slot, it.Tier).Description
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// attachInventoryCompares fills the Compare card on every backpack magic item —
|
||||
// the ones that carry an equip id, which is exactly the set the web Equip button
|
||||
// acts on (equippedViews/vault rows are excluded by construction). views and items
|
||||
// are index-aligned: itemViews appends one view per item and skips none.
|
||||
func attachInventoryCompares(views []peteclient.ItemView, items []AdvItem, equipped map[DnDSlot]EquippedMagicItem) {
|
||||
for i := range views {
|
||||
if views[i].ID == 0 {
|
||||
continue // no equip id → mundane gear or unslotted curio → no button, no compare
|
||||
}
|
||||
mi, ok := magicItemFromAdvItem(items[i])
|
||||
if !ok || mi.Slot == "" {
|
||||
continue
|
||||
}
|
||||
views[i].Compare = magicItemCompare(mi, items[i].Temper, equipped)
|
||||
}
|
||||
}
|
||||
|
||||
// magicItemCompare pairs a candidate backpack item against whatever is worn in
|
||||
// the slot it would equip into (mi.Slot — the same slot applyMagicEquip targets,
|
||||
// so the card describes the trade the Equip button actually makes). The diff is
|
||||
// over *tempered* effects on both sides; bond availability decides the inert case.
|
||||
func magicItemCompare(cand MagicItem, temper int, equipped map[DnDSlot]EquippedMagicItem) *peteclient.ItemCompare {
|
||||
if cand.Slot == "" {
|
||||
return nil
|
||||
}
|
||||
candEff := magicItemEffectFor(temperedItem(cand, temper))
|
||||
|
||||
worn, wornExists := equipped[cand.Slot]
|
||||
if wornExists && worn.Item.ID == "" {
|
||||
wornExists = false // an empty EquippedMagicItem is not a real occupant
|
||||
}
|
||||
|
||||
// An empty slot compares against neutral: DamageReductMult is a multiplier, so
|
||||
// its neutral is 1.0, not the zero value (0 would read as -100% damage taken).
|
||||
wornEff := magicItemEffect{DamageReductMult: 1.0}
|
||||
vsName := ""
|
||||
if wornExists {
|
||||
wornEff = magicItemEffectFor(worn.Effective())
|
||||
vsName = worn.Effective().Name
|
||||
}
|
||||
deltas := magicItemDeltas(candEff, wornEff)
|
||||
|
||||
// Inert: the item wants a bond and none is free. Equipping evicts the slot's
|
||||
// occupant first, so an attuned occupant frees its own bond — count post-swap.
|
||||
inert := false
|
||||
if cand.Attunement {
|
||||
bonds := countAttunedMagicItems(equipped)
|
||||
if wornExists && worn.Attuned {
|
||||
bonds--
|
||||
}
|
||||
inert = bonds >= dndMagicItemAttuneLimit
|
||||
}
|
||||
|
||||
return &peteclient.ItemCompare{
|
||||
Verdict: compareVerdict(deltas, !wornExists, inert),
|
||||
VsName: vsName,
|
||||
VsSlot: string(cand.Slot),
|
||||
Deltas: deltas,
|
||||
}
|
||||
}
|
||||
|
||||
// compareVerdict classifies a set of deltas by strict dominance. Different stats
|
||||
// are not fungible — the engine can't say +3% damage beats -4 HP — so a mixed
|
||||
// result is a sidegrade with no winner claimed, which is the whole reason the
|
||||
// card exists. inert and new override the stat verdict.
|
||||
func compareVerdict(deltas []peteclient.ItemDelta, empty, inert bool) string {
|
||||
if inert {
|
||||
return "inert" // wearing it does nothing until a bond frees; stat diff is moot
|
||||
}
|
||||
if empty {
|
||||
return "new"
|
||||
}
|
||||
if len(deltas) == 0 {
|
||||
return "same"
|
||||
}
|
||||
gains, losses := 0, 0
|
||||
for _, d := range deltas {
|
||||
if d.Better {
|
||||
gains++
|
||||
} else {
|
||||
losses++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case losses == 0:
|
||||
return "upgrade"
|
||||
case gains == 0:
|
||||
return "downgrade"
|
||||
default:
|
||||
return "sidegrade"
|
||||
}
|
||||
}
|
||||
|
||||
// magicItemDeltas returns one entry per stat that visibly changes between the
|
||||
// candidate and the worn item. It diffs the structured effect fields, never the
|
||||
// summary string (which drops zero fields and would lose deltas). A change too
|
||||
// small to show at the rendered precision is omitted, so the verdict matches what
|
||||
// the player sees.
|
||||
func magicItemDeltas(cand, worn magicItemEffect) []peteclient.ItemDelta {
|
||||
var d []peteclient.ItemDelta
|
||||
|
||||
// DamageBonus / DamageReductMult are fractions rendered as whole percents.
|
||||
if pct := (cand.DamageBonus - worn.DamageBonus) * 100; roundedPct(pct) != 0 {
|
||||
d = append(d, peteclient.ItemDelta{Label: "damage", Better: pct > 0, Text: signedPct(pct, "damage")})
|
||||
}
|
||||
// DamageReductMult is a multiplier on damage TAKEN, so lower is better. Express
|
||||
// the change as damage taken: a positive number means you take more (worse).
|
||||
if taken := (cand.DamageReductMult - worn.DamageReductMult) * 100; roundedPct(taken) != 0 {
|
||||
d = append(d, peteclient.ItemDelta{Label: "defense", Better: taken < 0, Text: signedPct(taken, "damage taken")})
|
||||
}
|
||||
if diff := cand.FlatDmgStart - worn.FlatDmgStart; diff != 0 {
|
||||
d = append(d, peteclient.ItemDelta{Label: "opening", Better: diff > 0, Text: signedInt(diff, "opening damage")})
|
||||
}
|
||||
if diff := cand.MaxHP - worn.MaxHP; diff != 0 {
|
||||
d = append(d, peteclient.ItemDelta{Label: "hp", Better: diff > 0, Text: signedInt(diff, "HP")})
|
||||
}
|
||||
if cand.InitiativeBias != worn.InitiativeBias {
|
||||
faster := cand.InitiativeBias > worn.InitiativeBias
|
||||
text := "slower to act"
|
||||
if faster {
|
||||
text = "faster to act"
|
||||
}
|
||||
d = append(d, peteclient.ItemDelta{Label: "speed", Better: faster, Text: text})
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// roundedPct is the whole-percent a delta renders as; used to drop sub-percent
|
||||
// noise so the verdict never disagrees with the displayed chips.
|
||||
func roundedPct(v float64) int {
|
||||
if v < 0 {
|
||||
return int(v - 0.5)
|
||||
}
|
||||
return int(v + 0.5)
|
||||
}
|
||||
|
||||
func signedPct(v float64, noun string) string { return fmt.Sprintf("%+d%% %s", roundedPct(v), noun) }
|
||||
|
||||
func signedInt(v int, noun string) string { return fmt.Sprintf("%+d %s", v, noun) }
|
||||
|
||||
// equippedViews returns the magic items the player is actually wearing. This is
|
||||
// the only place Attuned means anything: the bond lives on the equipped row, and
|
||||
// with a cap of dndMagicItemAttuneLimit a worn item can be inert.
|
||||
func equippedViews(uid id.UserID) []peteclient.ItemView {
|
||||
equipped, err := loadEquippedMagicItems(uid)
|
||||
if err != nil || len(equipped) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]peteclient.ItemView, 0, len(equipped))
|
||||
for _, e := range equipped {
|
||||
eff := e.Effective()
|
||||
out = append(out, peteclient.ItemView{
|
||||
Name: eff.Name,
|
||||
Type: string(eff.Kind),
|
||||
Value: int64(eff.Value),
|
||||
Temper: e.Temper,
|
||||
Slot: string(e.Slot),
|
||||
Desc: eff.Desc,
|
||||
Effect: magicItemEffectSummary(eff),
|
||||
Attunement: eff.Attunement,
|
||||
Attuned: e.Attuned,
|
||||
})
|
||||
}
|
||||
// Map iteration is random; the panel must not reshuffle every 60s poll.
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Slot < out[j].Slot })
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -345,6 +551,7 @@ func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnap
|
||||
if exp.RunID != "" {
|
||||
if run, rerr := getZoneRun(exp.RunID); rerr == nil && run != nil && run.TotalRooms > 0 {
|
||||
e.Detail.Room = fmt.Sprintf("%d / %d", run.CurrentRoom+1, run.TotalRooms)
|
||||
e.Detail.Map = buildRosterMap(run)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -358,6 +565,71 @@ func buildRosterSnapshot(now time.Time, euro *EuroPlugin) (peteclient.RosterSnap
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// buildRosterMap computes the fog-of-war cut of a run's zone graph for the
|
||||
// public roster. It sends every visited node with its true kind, plus the
|
||||
// one-hop frontier — the destinations of edges leading out of visited nodes,
|
||||
// with their kind withheld as "unknown". Edges are directed and stored by
|
||||
// from-node, so "one hop out of a visited node" is exactly g.Edges[visited].
|
||||
// A frontier node's edges are NOT walked, so nothing past the first closed
|
||||
// door reaches the wire — "view source to find the boss room" is not fog of
|
||||
// war. Node Label/Content never leave the game box.
|
||||
//
|
||||
// Output order is deterministic (visited-path order, then frontier in
|
||||
// discovery order) so an unchanged run produces a byte-identical snapshot and
|
||||
// the roster push does not churn.
|
||||
func buildRosterMap(run *DungeonRun) *peteclient.RosterMap {
|
||||
g, ok := loadZoneGraph(run.ZoneID)
|
||||
if !ok || len(run.VisitedNodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unique visited nodes in path order — VisitedNodes repeats on backtrack.
|
||||
orderedVisited := make([]string, 0, len(run.VisitedNodes))
|
||||
seen := make(map[string]bool, len(run.VisitedNodes))
|
||||
for _, id := range run.VisitedNodes {
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
orderedVisited = append(orderedVisited, id)
|
||||
}
|
||||
}
|
||||
|
||||
m := &peteclient.RosterMap{
|
||||
ZoneID: string(run.ZoneID),
|
||||
CurrentNode: run.CurrentNode,
|
||||
Visited: orderedVisited,
|
||||
}
|
||||
|
||||
// Visited nodes first, in path order, with their real kind.
|
||||
emitted := make(map[string]bool, len(orderedVisited))
|
||||
for _, id := range orderedVisited {
|
||||
emitted[id] = true
|
||||
if n, ok := g.Nodes[id]; ok {
|
||||
m.Nodes = append(m.Nodes, peteclient.RosterMapNode{ID: id, Kind: string(n.Kind)})
|
||||
}
|
||||
}
|
||||
|
||||
// Frontier: destinations of edges out of visited nodes, kind withheld.
|
||||
// Walk visited in path order so the frontier order is stable.
|
||||
for _, from := range orderedVisited {
|
||||
for _, edge := range g.Edges[from] {
|
||||
lock := edge.Lock
|
||||
if lock == LockNone {
|
||||
lock = "" // an open door needs no mark; omitempty drops it
|
||||
}
|
||||
m.Edges = append(m.Edges, peteclient.RosterMapEdge{
|
||||
From: edge.From,
|
||||
To: edge.To,
|
||||
Lock: string(lock),
|
||||
})
|
||||
if !seen[edge.To] && !emitted[edge.To] {
|
||||
emitted[edge.To] = true
|
||||
m.Nodes = append(m.Nodes, peteclient.RosterMapNode{ID: edge.To, Kind: "unknown"})
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// resolveRosterToken maps a board token back to the adventurer it names. The
|
||||
// token is a one-way HMAC (eventToken), so it can't be inverted — instead we
|
||||
// recompute every live player's token and match. The salt is DB-persisted, so a
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mapFixtureGraph registers a small branching graph and returns a cleanup.
|
||||
//
|
||||
// n1(entry) --none--------> n2(exploration) --key_required--> n4(boss)
|
||||
// \--perception_check--> n3(trap) ------------------------/
|
||||
//
|
||||
// n4 is reachable two ways; the validator wants a single entry and a reachable
|
||||
// boss, which this satisfies.
|
||||
func mapFixtureGraph(t *testing.T) ZoneID {
|
||||
t.Helper()
|
||||
const zid ZoneID = "map_test_zone"
|
||||
g := ZoneGraph{
|
||||
ZoneID: zid,
|
||||
Entry: "n1",
|
||||
Boss: "n4",
|
||||
Nodes: map[string]ZoneNode{
|
||||
"n1": {NodeID: "n1", ZoneID: zid, Kind: NodeKindEntry, IsEntry: true, Label: "The Gate"},
|
||||
"n2": {NodeID: "n2", ZoneID: zid, Kind: NodeKindExploration, Label: "Dusty Hall"},
|
||||
"n3": {NodeID: "n3", ZoneID: zid, Kind: NodeKindTrap, Label: "Spiked Pit"},
|
||||
"n4": {NodeID: "n4", ZoneID: zid, Kind: NodeKindBoss, IsBoss: true, Label: "Throne of Bone"},
|
||||
},
|
||||
Edges: map[string][]ZoneEdge{
|
||||
"n1": {
|
||||
{From: "n1", To: "n2", Lock: LockNone, Weight: 1},
|
||||
{From: "n1", To: "n3", Lock: LockPerception, Weight: 1},
|
||||
},
|
||||
"n2": {{From: "n2", To: "n4", Lock: LockKey, Weight: 1}},
|
||||
"n3": {{From: "n3", To: "n4", Lock: LockNone, Weight: 1}},
|
||||
},
|
||||
}
|
||||
registerZoneGraph(g)
|
||||
t.Cleanup(func() { delete(zoneGraphRegistry, zid) })
|
||||
return zid
|
||||
}
|
||||
|
||||
func TestBuildRosterMap_FogOfWar(t *testing.T) {
|
||||
zid := mapFixtureGraph(t)
|
||||
run := &DungeonRun{
|
||||
ZoneID: zid,
|
||||
CurrentNode: "n2",
|
||||
VisitedNodes: []string{"n1", "n2"},
|
||||
TotalRooms: 4,
|
||||
}
|
||||
m := buildRosterMap(run)
|
||||
if m == nil {
|
||||
t.Fatal("buildRosterMap returned nil for a visited run")
|
||||
}
|
||||
if m.ZoneID != string(zid) || m.CurrentNode != "n2" {
|
||||
t.Fatalf("header wrong: %+v", m)
|
||||
}
|
||||
|
||||
kinds := map[string]string{}
|
||||
for _, n := range m.Nodes {
|
||||
if _, dup := kinds[n.ID]; dup {
|
||||
t.Errorf("node %q emitted twice", n.ID)
|
||||
}
|
||||
kinds[n.ID] = n.Kind
|
||||
}
|
||||
|
||||
// Visited nodes carry their true kind.
|
||||
if kinds["n1"] != "entry" || kinds["n2"] != "exploration" {
|
||||
t.Errorf("visited kinds wrong: %v", kinds)
|
||||
}
|
||||
// Frontier nodes are present but their kind is withheld.
|
||||
if kinds["n3"] != "unknown" {
|
||||
t.Errorf("n3 is one hop out of n1 and must be unknown, got %q", kinds["n3"])
|
||||
}
|
||||
if kinds["n4"] != "unknown" {
|
||||
t.Errorf("n4 (the boss) is one hop out of n2 and must be unknown, got %q", kinds["n4"])
|
||||
}
|
||||
|
||||
// The map must never leak a room the player has not reached a door to.
|
||||
// n4 is a real boss node, but reachable only as frontier — its kind stays
|
||||
// hidden. A node past a frontier door (there is none deeper here) must not
|
||||
// appear at all; assert exactly four nodes.
|
||||
if len(m.Nodes) != 4 {
|
||||
t.Fatalf("want 4 nodes (2 visited + 2 frontier), got %d: %+v", len(m.Nodes), m.Nodes)
|
||||
}
|
||||
|
||||
// Edges out of visited nodes only. n3->n4 must NOT appear: n3 is frontier,
|
||||
// not visited, so walking its doors would leak structure past the fog.
|
||||
var edgeKeys []string
|
||||
for _, e := range m.Edges {
|
||||
edgeKeys = append(edgeKeys, e.From+"->"+e.To+":"+e.Lock)
|
||||
}
|
||||
want := map[string]bool{
|
||||
"n1->n2:": true, // LockNone dropped to ""
|
||||
"n1->n3:perception_check": true,
|
||||
"n2->n4:key_required": true,
|
||||
}
|
||||
if len(edgeKeys) != len(want) {
|
||||
t.Fatalf("want %d edges, got %v", len(want), edgeKeys)
|
||||
}
|
||||
for _, k := range edgeKeys {
|
||||
if !want[k] {
|
||||
t.Errorf("unexpected edge %q (n3->n4 would be a fog leak)", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRosterMap_EmptyRunIsNil(t *testing.T) {
|
||||
zid := mapFixtureGraph(t)
|
||||
// A run that has visited nothing yet has no map to show.
|
||||
if m := buildRosterMap(&DungeonRun{ZoneID: zid}); m != nil {
|
||||
t.Errorf("empty VisitedNodes should yield nil, got %+v", m)
|
||||
}
|
||||
// An unknown zone (no graph, no legacy fallback row) yields nil, not a panic.
|
||||
if m := buildRosterMap(&DungeonRun{ZoneID: "no_such_zone", VisitedNodes: []string{"x"}}); m != nil {
|
||||
t.Errorf("unknown zone should yield nil, got %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRosterMap_BacktrackDedup(t *testing.T) {
|
||||
zid := mapFixtureGraph(t)
|
||||
// VisitedNodes is an ordered set that repeats on backtrack: n1,n2,n1.
|
||||
run := &DungeonRun{
|
||||
ZoneID: zid,
|
||||
CurrentNode: "n1",
|
||||
VisitedNodes: []string{"n1", "n2", "n1"},
|
||||
}
|
||||
m := buildRosterMap(run)
|
||||
seen := map[string]int{}
|
||||
for _, n := range m.Nodes {
|
||||
seen[n.ID]++
|
||||
}
|
||||
if seen["n1"] != 1 {
|
||||
t.Errorf("backtracked node n1 emitted %d times, want 1", seen["n1"])
|
||||
}
|
||||
if len(m.Visited) != 2 {
|
||||
t.Errorf("Visited should dedup to [n1 n2], got %v", m.Visited)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -125,3 +126,101 @@ func TestRosterTokenIsNotAnEventToken(t *testing.T) {
|
||||
t.Error("board token not stable — the row would churn identity every snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
// pickMagicItem returns a registry item matching want, so these tests read the
|
||||
// real registry rather than pinning an item ID that a later SRD dump could drop.
|
||||
func pickMagicItem(t *testing.T, want func(MagicItem) bool) MagicItem {
|
||||
t.Helper()
|
||||
var ids []string
|
||||
for id := range magicItemRegistry {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids) // map order is random; a flaky pick is a flaky test
|
||||
for _, id := range ids {
|
||||
if mi := magicItemRegistry[id]; want(mi) {
|
||||
return mi
|
||||
}
|
||||
}
|
||||
t.Skip("no registry item matches this shape")
|
||||
return MagicItem{}
|
||||
}
|
||||
|
||||
// TestItemViewsKeepsTheRegistryPointerHome is the leak guard. SkillSource is two
|
||||
// different things depending on the row: a player-facing skill name on
|
||||
// masterwork gear ("mining"), and the internal "magic_item:<id>" pointer that
|
||||
// resolves an inventory row back to the registry. Only the first is a fact about
|
||||
// the item. Sending the second would put gogobee's internal IDs on a page, and
|
||||
// Pete would have no way to tell them apart to filter them out.
|
||||
func TestItemViewsKeepsTheRegistryPointerHome(t *testing.T) {
|
||||
newBoredomTestDB(t)
|
||||
mi := pickMagicItem(t, func(m MagicItem) bool { return m.Desc != "" && m.Slot != "" })
|
||||
|
||||
views := itemViews([]AdvItem{
|
||||
{Name: mi.Name, Type: "magic_item", Tier: 3, Value: 100,
|
||||
SkillSource: "magic_item:" + mi.ID},
|
||||
{Name: "Miner's Pick", Type: "MasterworkGear", Tier: 3, Value: 300,
|
||||
Slot: SlotWeapon, SkillSource: "mining"},
|
||||
})
|
||||
|
||||
if views[0].SkillSource != "" {
|
||||
t.Errorf("the magic_item registry pointer went out on the wire: %q", views[0].SkillSource)
|
||||
}
|
||||
if views[0].Desc != mi.Desc {
|
||||
t.Errorf("desc = %q, want the registry's %q", views[0].Desc, mi.Desc)
|
||||
}
|
||||
if views[0].Effect == "" {
|
||||
t.Error("a magic item should carry the engine's own effect summary")
|
||||
}
|
||||
if views[1].SkillSource != "mining" {
|
||||
t.Errorf("masterwork skill source = %q, want it kept", views[1].SkillSource)
|
||||
}
|
||||
}
|
||||
|
||||
// TestItemViewsNeverClaimABackpackBond: equipping *moves* the row out of
|
||||
// adventure_inventory into magic_item_equipped, so nothing in a backpack can
|
||||
// hold a bond. Attuned must stay false here whatever the item wants, or the
|
||||
// panel tells a player an unworn item is working for them.
|
||||
func TestItemViewsNeverClaimABackpackBond(t *testing.T) {
|
||||
newBoredomTestDB(t)
|
||||
mi := pickMagicItem(t, func(m MagicItem) bool { return m.Attunement && m.Slot != "" })
|
||||
|
||||
v := itemViews([]AdvItem{{Name: mi.Name, Type: "magic_item", Tier: 3,
|
||||
SkillSource: "magic_item:" + mi.ID}})[0]
|
||||
|
||||
if !v.Attunement {
|
||||
t.Error("an attunement item should say it wants a bond")
|
||||
}
|
||||
if v.Attuned {
|
||||
t.Error("a backpack item claimed a bond it cannot hold")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEquippedViewsCarryBondState: the worn set is the only place Attuned means
|
||||
// anything, and the only way the page can show that a worn item is sitting inert
|
||||
// against the cap of three.
|
||||
func TestEquippedViewsCarryBondState(t *testing.T) {
|
||||
newBoredomTestDB(t)
|
||||
uid := id.UserID("@josie:example.org")
|
||||
mi := pickMagicItem(t, func(m MagicItem) bool { return m.Attunement && m.Slot != "" })
|
||||
|
||||
if err := equipMagicItem(uid, mi.Slot, mi.ID, false, 0); err != nil {
|
||||
t.Fatalf("equip: %v", err)
|
||||
}
|
||||
views := equippedViews(uid)
|
||||
if len(views) != 1 {
|
||||
t.Fatalf("equipped views = %d, want 1", len(views))
|
||||
}
|
||||
if views[0].Attuned {
|
||||
t.Error("an inert worn item was reported as bonded")
|
||||
}
|
||||
if views[0].Slot != string(mi.Slot) {
|
||||
t.Errorf("slot = %q, want %q", views[0].Slot, mi.Slot)
|
||||
}
|
||||
|
||||
if err := equipMagicItem(uid, mi.Slot, mi.ID, true, 0); err != nil {
|
||||
t.Fatalf("re-equip bonded: %v", err)
|
||||
}
|
||||
if !equippedViews(uid)[0].Attuned {
|
||||
t.Error("a bonded worn item was reported as inert")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package plugin
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -193,6 +194,71 @@ func TestEmitZoneClearTaxonomy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitTreasureFound: a story-grade find carries the item name and rarity,
|
||||
// and the realm's first finder of a given treasure is billed a PRIORITY hoard
|
||||
// while a later finder of the same item is a BULLETIN — the same first/repeat
|
||||
// split zone_first uses, but keyed on the treasure across the whole realm.
|
||||
func TestEmitTreasureFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
enablePeteSeam(t)
|
||||
|
||||
db.Exec("seed finder", `INSERT INTO player_meta (user_id, display_name) VALUES (?, ?)`, "@zapp:x", "Zapp")
|
||||
db.Exec("seed second finder", `INSERT INTO player_meta (user_id, display_name) VALUES (?, ?)`, "@kif:x", "Kif")
|
||||
|
||||
def := &AdvTreasureDef{Key: "thunderfury", Name: "Thunderfury, Blessed Blade of the Windseeker",
|
||||
Tier: 5, RoomAnnounce: "x got Thunderfury."}
|
||||
loc := &AdvLocation{Name: "The Abyssal Maw"}
|
||||
|
||||
emitTreasureFound(id.UserID("@zapp:x"), def, loc) // realm-first
|
||||
emitTreasureFound(id.UserID("@kif:x"), def, loc) // same item, later finder
|
||||
|
||||
if got := queuedCount(t, "treasure_found:%"); got != 2 {
|
||||
t.Fatalf("treasure_found queued = %d, want 2", got)
|
||||
}
|
||||
|
||||
// Pull both payloads and check the taxonomy split plus the carried fields.
|
||||
rows, err := db.Get().Query(`SELECT payload FROM pete_emit_queue WHERE guid LIKE 'treasure_found:%'`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
tiers := map[string]int{}
|
||||
for rows.Next() {
|
||||
var payload string
|
||||
if err := rows.Scan(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var f map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tiers[f["tier"].(string)]++
|
||||
if f["stakes"] != def.Name {
|
||||
t.Errorf("stakes = %v, want the item name", f["stakes"])
|
||||
}
|
||||
if f["zone"] != "The Abyssal Maw" {
|
||||
t.Errorf("zone = %v, want the location", f["zone"])
|
||||
}
|
||||
if f["outcome"] != "legendary" {
|
||||
t.Errorf("outcome = %v, want legendary for a tier-5 find", f["outcome"])
|
||||
}
|
||||
}
|
||||
if tiers["priority"] != 1 || tiers["bulletin"] != 1 {
|
||||
t.Errorf("tier split = %v, want one priority (realm-first) and one bulletin (repeat)", tiers)
|
||||
}
|
||||
|
||||
// The ledger is seeded, so a later live find of the same treasure won't
|
||||
// mis-announce as the first-ever.
|
||||
if claimRealmFirst("treasure", "thunderfury") {
|
||||
t.Error("realm-first ledger not seeded for the treasure")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewsEmissionKillSwitch: the runtime flag defaults on and persists a flip.
|
||||
func TestNewsEmissionKillSwitch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -167,9 +167,9 @@ var _ = func() bool {
|
||||
},
|
||||
"boss_seamstress": {
|
||||
ID: "boss_seamstress", Name: "The Seamstress",
|
||||
CR: 27, HP: 385, AC: 21, Attack: 39, AttackBonus: 12, Speed: 14,
|
||||
CR: 27, HP: 460, AC: 21, Attack: 45, AttackBonus: 12, Speed: 14,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Needle Rain", Phase: "decisive", ProcChance: 0.40, Effect: "aoe"},
|
||||
Ability: &MonsterAbility{Name: "Needle Rain", Phase: "decisive", ProcChance: 0.45, Effect: "aoe"},
|
||||
XPValue: 100000,
|
||||
Notes: "Unplace boss. A corrupted celestial sewing herself into the tear; half of her is on the other side. Phase 2 below 35% HP. (Layer 2: Inversion Stitch — healing and damage swap direction on her in telegraphed pulses.)",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
package plugin
|
||||
|
||||
// Tier-6 postgame "Layer-2" boss mechanics (Phase P8).
|
||||
//
|
||||
// Layer 1 (shipped) is everything the stock engine expresses: a single
|
||||
// MonsterAbility rider plus HP/AC/Attack/PhaseTwoAt on the stat block. Layer 2
|
||||
// is the bespoke, per-boss stuff the plan promised — mechanics that read the
|
||||
// *run* the player took to reach the boss, not just the fight in front of them.
|
||||
//
|
||||
// It holds BOTH halves of that seam:
|
||||
//
|
||||
// - PRE-COMBAT (applyBossRunModifiers / seedBossRunStatuses): adjustments
|
||||
// derived once from run state and folded into the boss's live Combatant (or
|
||||
// the fresh session) before the fight resolves. applyBossRunModifiers must be
|
||||
// a PURE, IDEMPOTENT function of run state, because the turn engine rebuilds
|
||||
// the enemy from the bestiary every single round (partyCombatantsForSession)
|
||||
// — the boss's numbers are re-derived on every !attack. That is fine as long
|
||||
// as the inputs are frozen for the fight, which they are: the boss room is
|
||||
// terminal, so no more nodes are walked once the fight begins.
|
||||
//
|
||||
// - IN-COMBAT (applyBossInCombatRoundEnd): round-boundary mechanics that read
|
||||
// and mutate the live combatState — HP snapshots, once-only rewinds, escalating
|
||||
// timers. Called from the turn engine's stepRoundEnd after the round's damage
|
||||
// has settled. Any state it spends round-trips through CombatStatuses so a
|
||||
// suspend/resume can't replay it.
|
||||
//
|
||||
// The remaining planned in-combat mechanics (Inversion Stitch, Two Hearts) are
|
||||
// still a separate seam — a new MonsterAbility.Effect case in applyAbility — and
|
||||
// are not in this file yet.
|
||||
|
||||
// applyBossRunModifiers folds any pre-combat Layer-2 mechanic for bossID into
|
||||
// the freshly-built enemy Combatant, reading the run the player took to get
|
||||
// here. It is dispatched by bestiary ID and is a no-op for every enemy that
|
||||
// isn't a hooked T6 boss, so the two build seams can call it unconditionally on
|
||||
// whatever they just built. A nil enemy or nil run is a no-op.
|
||||
func applyBossRunModifiers(bossID string, enemy *Combatant, run *DungeonRun) {
|
||||
if enemy == nil || run == nil {
|
||||
return
|
||||
}
|
||||
switch bossID {
|
||||
case "boss_aurvandryx":
|
||||
applyGreedTax(enemy, run)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Greed Tax — Aurvandryx, the Ember Before Fire (First Hoard) ─────────────
|
||||
//
|
||||
// The First Hoard carries the game's richest LootBias nodes on purpose (the
|
||||
// gilded veins at 2.5–3.0; nowhere else in the game exceeds 1.0). Aurvandryx —
|
||||
// the hoard's true owner, not its guard dog — takes the interest out of your
|
||||
// hide: her Attack rises with how much of the gilded route you walked to reach
|
||||
// her cradle. Strip the veins and meet a furious wyrm; take the vow-of-poverty
|
||||
// line through the zone and fight her lean.
|
||||
//
|
||||
// Signal: the summed excess LootBias (above the 1.0 baseline) of every node the
|
||||
// run walked — NOT run.LootCollected, which only tracks the boss-only signature
|
||||
// manifest and is empty at the boss. A full-explore route through the First
|
||||
// Hoard accrues ~3.5 richness (+7 Attack); the cap covers a maximal line.
|
||||
//
|
||||
// Calibration (P8 sim sweep, L20 party+Pete+pets): the full-explore route lands
|
||||
// first_hoard at ~47% clear (mid-band 40–55), down from the taxless 56%. A lean
|
||||
// prod route pays no tax and fights her at the taxless rate; a maximal route
|
||||
// hits the cap. No base-stat retune was needed — the tax corrects the prior
|
||||
// slight overshoot into the band. NOTE: the sim's autopilot explores the whole
|
||||
// graph, so the sweep only exercises the full-route point; the lean/greedy
|
||||
// spread is a prod-only player-agency lever the headless sim cannot walk.
|
||||
const (
|
||||
// greedTaxRichnessMult converts summed route richness into Attack points.
|
||||
greedTaxRichnessMult = 2.0
|
||||
|
||||
// greedTaxMaxAttack caps the tax so a maximal hoard-run meets a very hard
|
||||
// wyrm, never a literally-unbeatable one.
|
||||
greedTaxMaxAttack = 12
|
||||
)
|
||||
|
||||
// greedRouteRichness sums the excess LootBias (above the 1.0 baseline) of every
|
||||
// node the run has walked. Extracted for a deterministic unit test.
|
||||
func greedRouteRichness(run *DungeonRun) float64 {
|
||||
g, ok := loadZoneGraph(run.ZoneID)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
var rich float64
|
||||
for _, id := range run.VisitedNodes {
|
||||
if n, exists := g.Nodes[id]; exists && n.Content.LootBias > 1.0 {
|
||||
rich += n.Content.LootBias - 1.0
|
||||
}
|
||||
}
|
||||
return rich
|
||||
}
|
||||
|
||||
// greedTaxAttack is the Attack surcharge for a route of the given richness.
|
||||
func greedTaxAttack(richness float64) int {
|
||||
tax := int(richness * greedTaxRichnessMult)
|
||||
if tax > greedTaxMaxAttack {
|
||||
tax = greedTaxMaxAttack
|
||||
}
|
||||
if tax < 0 {
|
||||
tax = 0
|
||||
}
|
||||
return tax
|
||||
}
|
||||
|
||||
func applyGreedTax(enemy *Combatant, run *DungeonRun) {
|
||||
enemy.Stats.Attack += greedTaxAttack(greedRouteRichness(run))
|
||||
}
|
||||
|
||||
// ── Phylactery Verses — Valdris, At Last (The Ossuary Ascendant) ────────────
|
||||
//
|
||||
// The plan Valdris has been running since he "died" in the T1 Crypt: the
|
||||
// phylactery shard players looted for years was bait, and he has rebuilt as a
|
||||
// true lich. His rebirths are bound into three Verses hidden in the cathedral,
|
||||
// each a NodeKindSecret behind a Perception gate. Every Verse a player finds and
|
||||
// walks before the fight UNBINDS one rebirth; every Verse they skip leaves it
|
||||
// armed. Full-clear explorers strip all three and fight a mortal lich;
|
||||
// speedrunners who blow past the secrets fight a god who will not stay down.
|
||||
//
|
||||
// This is the mirror-image of the Greed Tax's design axis: the Tax punishes
|
||||
// greedy exploration, the Verses reward thorough exploration. Both are prod
|
||||
// player-agency levers the headless sim only samples at whatever route its
|
||||
// autopilot happens to walk.
|
||||
//
|
||||
// Unlike the Greed Tax (a pure per-round Attack recompute), a rebirth is spent
|
||||
// mid-fight, so it is stateful: seedBossRunStatuses freezes the charge count
|
||||
// onto the session ONCE at fight start, and the turn engine round-trips the
|
||||
// live count through CombatStatuses. It must NOT be re-derived on the per-round
|
||||
// enemy rebuild, or spent rebirths would come back every round.
|
||||
const (
|
||||
// phylacteryReviveFrac is the fraction of the boss's (party-scaled) max HP
|
||||
// each unbound rebirth restores him to — a real second wind, not the 1-HP
|
||||
// stay of survive_at_1.
|
||||
phylacteryReviveFrac = 4 // 1/4 == 25%
|
||||
)
|
||||
|
||||
// phylacteryReviveCharges is the number of rebirths still armed on Valdris: one
|
||||
// per zone Verse (NodeKindSecret) the run has NOT visited. The Ossuary's only
|
||||
// secret nodes are the three Verses (the shared builder stamps none by default),
|
||||
// so counting unvisited secrets is exactly "unbound rebirths". Zero for any
|
||||
// non-Valdris boss or a nil run. A pure function of frozen run state.
|
||||
func phylacteryReviveCharges(bossID string, run *DungeonRun) int {
|
||||
if bossID != "boss_valdris_ascendant" || run == nil {
|
||||
return 0
|
||||
}
|
||||
g, ok := loadZoneGraph(run.ZoneID)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
visited := make(map[string]bool, len(run.VisitedNodes))
|
||||
for _, id := range run.VisitedNodes {
|
||||
visited[id] = true
|
||||
}
|
||||
charges := 0
|
||||
for id, n := range g.Nodes {
|
||||
if n.Kind == NodeKindSecret && !visited[id] {
|
||||
charges++
|
||||
}
|
||||
}
|
||||
return charges
|
||||
}
|
||||
|
||||
// seedBossRunStatuses folds any ONCE-AT-FIGHT-START Layer-2 boss state into the
|
||||
// freshly-created session, reading the run the player took to get here. It is
|
||||
// the stateful counterpart to applyBossRunModifiers (which is a per-round pure
|
||||
// recompute): whatever it seeds here is mutated by the fight and round-tripped,
|
||||
// never re-derived. enemyMaxHP is the party-scaled pool already persisted onto
|
||||
// the session. Returns whether it changed anything (so the caller can skip a
|
||||
// redundant save). No-op — false — for every non-hooked boss.
|
||||
func seedBossRunStatuses(sess *CombatSession, bossID string, enemyMaxHP int, run *DungeonRun) bool {
|
||||
if sess == nil {
|
||||
return false
|
||||
}
|
||||
if charges := phylacteryReviveCharges(bossID, run); charges > 0 {
|
||||
sess.Statuses.EnemyReviveCharges = charges
|
||||
sess.Statuses.EnemyReviveHP = max(1, enemyMaxHP/phylacteryReviveFrac)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Amendment — The Custodian of the Last Hour (The Last Meridian) ───────────
|
||||
//
|
||||
// The Custodian has concluded its contract is complete and is dismantling the
|
||||
// hours on its way out — including, once, the last few minutes of its own fight.
|
||||
// Its true durability is HIDDEN in the opening rounds: it snapshots its HP at the
|
||||
// end of round 3, and the first time the party knocks it into phase 2 it *rewinds
|
||||
// itself* to that snapshot — once. Front-loaded burst is partially refunded (the
|
||||
// damage past the snapshot is undone), so sustained builds that can grind through
|
||||
// the extra pool shine over glass-cannon alpha strikes. A soft "midnight timer"
|
||||
// then leans on stalls: every round past round 20 the Custodian's Attack climbs,
|
||||
// so a fight that can't close still resolves before the clock runs out.
|
||||
//
|
||||
// This is the first IN-COMBAT Layer-2 mechanic. Unlike the pre-combat hooks it
|
||||
// reads/mutates the live combatState at round end, and its once-only state
|
||||
// (EnemyRewindHP snapshot + EnemyRewindUsed) round-trips through CombatStatuses so
|
||||
// a suspend/resume can't hand the boss a second rewind. Dispatched by bestiary ID,
|
||||
// so it is a no-op for every other enemy.
|
||||
const (
|
||||
// custodianSnapshotRound is the round whose end HP the Custodian rewinds to.
|
||||
// Snapshotting late enough that a normal party has committed real damage, but
|
||||
// early enough that the refund still matters, is the whole point — the fight's
|
||||
// true length is concealed until the rewind fires.
|
||||
custodianSnapshotRound = 3
|
||||
|
||||
// custodianPhaseTwoFrac must track The Custodian's PhaseTwoAt in
|
||||
// postgame_zone_defs.go (0.45): the rewind is meant to fire exactly as the
|
||||
// party crosses the boss into phase 2. Kept as a local constant because the
|
||||
// turn engine resolves off the bestiary template + session, not the
|
||||
// ZoneDefinition, so the threshold isn't otherwise in reach at round end.
|
||||
custodianPhaseTwoFrac = 0.45
|
||||
|
||||
// midnightAtkStep / midnightAtkCap: the soft closing-time timer. Every round
|
||||
// past midnightTimerAfter adds midnightAtkStep to the boss's Attack (via the
|
||||
// shared EnemyAtkBuff, which enemyAttackStat already folds in), capped so a
|
||||
// truly stalled fight still ends without the number becoming meaningless.
|
||||
midnightTimerAfter = 20
|
||||
midnightAtkStep = 2
|
||||
midnightAtkCap = 40
|
||||
)
|
||||
|
||||
// applyBossInCombatRoundEnd resolves the round-boundary in-combat Layer-2
|
||||
// mechanics for the enemy the turn engine is fighting. It runs after the round's
|
||||
// damage has settled and only while the enemy still stands. Dispatched by
|
||||
// bestiary ID; a no-op for every enemy that isn't a hooked T6 boss.
|
||||
func applyBossInCombatRoundEnd(st *combatState, bossID string, enemyMaxHP int) {
|
||||
if st == nil {
|
||||
return
|
||||
}
|
||||
switch bossID {
|
||||
case "boss_custodian":
|
||||
applyAmendment(st, enemyMaxHP)
|
||||
case "boss_seamstress":
|
||||
applyInversionStitch(st, enemyMaxHP)
|
||||
}
|
||||
}
|
||||
|
||||
// applyAmendment resolves the Custodian's Amendment (round-3 snapshot + once-only
|
||||
// phase-2 rewind) and its soft midnight timer, given the round that just finished
|
||||
// (st.round, pre-increment) and the boss's party-scaled MaxHP.
|
||||
func applyAmendment(st *combatState, enemyMaxHP int) {
|
||||
// Snapshot the boss's HP at the end of round 3 — the concealed "true length".
|
||||
if st.round == custodianSnapshotRound && st.enemyRewindHP == 0 {
|
||||
st.enemyRewindHP = st.enemyHP
|
||||
}
|
||||
// The first time the party crosses the boss into phase 2, rewind to the
|
||||
// snapshot — once. Guarded on snapshot > current so a party that never got
|
||||
// below the round-3 HP (and a fight bursted into phase 2 before the snapshot
|
||||
// was even taken, EnemyRewindHP == 0) can't be handed a free heal.
|
||||
phaseTwo := int(custodianPhaseTwoFrac * float64(enemyMaxHP))
|
||||
if !st.enemyRewindUsed && st.enemyRewindHP > st.enemyHP && st.enemyHP <= phaseTwo {
|
||||
st.enemyHP = min(enemyMaxHP, st.enemyRewindHP)
|
||||
st.enemyRewindUsed = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "amendment_rewind",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
// Soft midnight timer: closing time leans on a stalled fight.
|
||||
if st.round >= midnightTimerAfter && st.enemyAtkBuff < midnightAtkCap {
|
||||
st.enemyAtkBuff = min(midnightAtkCap, st.enemyAtkBuff+midnightAtkStep)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "midnight_toll",
|
||||
Damage: midnightAtkStep, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inversion Stitch — The Seamstress (The Unplace) ──────────────────────────
|
||||
//
|
||||
// The Seamstress is sewing the tear shut from the inside, and once she's far
|
||||
// enough into the work (phase 2) she starts turning the room inside-out around
|
||||
// the party: for a two-round pulse, healing runs the wrong direction — every
|
||||
// cure lands as a wound (gated in stepPlayerActionEffect, floored at 1 HP so a
|
||||
// player is never killed by their own healer, only softened for the Seamstress's
|
||||
// own blows to finish). The catch is that she can't invert the room without a
|
||||
// visible tell: each pulse is telegraphed one full round ahead, so a player who
|
||||
// reads it holds their heals and waits it out. The autopilot that drives the sim
|
||||
// does not read tells — so the headless sweep sees the mechanic at its harshest,
|
||||
// the way an undisciplined party would, and a real player who respects the
|
||||
// warning fares better. That gap IS the difficulty lever, the same way the Greed
|
||||
// Tax and the Phylactery Verses are prod-only player-agency levers.
|
||||
//
|
||||
// It is the second in-combat Layer-2 mechanic, so it rides the same round-end
|
||||
// seam Amendment opened. Its state (inversionActive countdown + inversionTelegraph
|
||||
// warning) round-trips through CombatStatuses so a suspend/resume can't drop or
|
||||
// double a pulse. A no-op outside the Seamstress's phase 2.
|
||||
const (
|
||||
// seamstressPhaseTwoFrac must track The Seamstress's PhaseTwoAt in
|
||||
// postgame_zone_defs.go (0.35): the inversion only starts once she is sewn
|
||||
// deep enough into the tear. Kept local for the same reason as
|
||||
// custodianPhaseTwoFrac — the round-end hook resolves off the session, not the
|
||||
// ZoneDefinition, so the threshold isn't otherwise in reach here.
|
||||
seamstressPhaseTwoFrac = 0.35
|
||||
|
||||
// inversionPulseRounds is how many rounds each inside-out pulse lasts once it
|
||||
// activates (heals sting for this many player-rounds).
|
||||
inversionPulseRounds = 2
|
||||
)
|
||||
|
||||
// applyInversionStitch drives the Seamstress's phase-2 inversion cadence at round
|
||||
// end: telegraph a pulse one round ahead, activate it for inversionPulseRounds,
|
||||
// then re-telegraph the next — giving a repeating warn(1) → sting(2) rhythm the
|
||||
// player can play around. No-op until the boss is in phase 2.
|
||||
func applyInversionStitch(st *combatState, enemyMaxHP int) {
|
||||
// Layer-1 fight until the Seamstress is sewn into her own phase 2.
|
||||
phaseTwo := int(seamstressPhaseTwoFrac * float64(enemyMaxHP))
|
||||
if st.enemyHP > phaseTwo {
|
||||
return
|
||||
}
|
||||
// An active pulse counts down one round per round end. While it stays above
|
||||
// zero the next round's heals still sting; when it lapses this round, fall
|
||||
// through so a fresh telegraph is scheduled immediately.
|
||||
if st.inversionActive > 0 {
|
||||
st.inversionActive--
|
||||
if st.inversionActive > 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
// A telegraphed pulse activates: the room turns inside-out for the pulse.
|
||||
if st.inversionTelegraph {
|
||||
st.inversionActive = inversionPulseRounds
|
||||
st.inversionTelegraph = false
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "inversion_stitch",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return
|
||||
}
|
||||
// Otherwise warn: the next round's pulse is one round out.
|
||||
st.inversionTelegraph = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "inversion_telegraph",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGreedTaxAttack(t *testing.T) {
|
||||
cases := []struct {
|
||||
rich float64
|
||||
want int
|
||||
}{
|
||||
{rich: -1, want: 0},
|
||||
{rich: 0, want: 0},
|
||||
{rich: 0.4, want: 0}, // 0.4*2 = 0.8, floors to 0
|
||||
{rich: 0.5, want: 1}, // one point per 0.5 richness
|
||||
{rich: 3.5, want: 7}, // the sim's full-explore route
|
||||
{rich: 5.25, want: 10},
|
||||
{rich: 6, want: 12}, // exactly the cap
|
||||
{rich: 100, want: 12}, // capped
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := greedTaxAttack(c.rich); got != c.want {
|
||||
t.Errorf("greedTaxAttack(%.2f) = %d, want %d", c.rich, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGreedRouteRichness(t *testing.T) {
|
||||
// The First Hoard's gilded veins are the only >1.0 LootBias nodes in the
|
||||
// game: cap12@3.0, f1b2@2.75, r2b2@2.5 (see zone_graph_first_hoard.go). The
|
||||
// graph builder prefixes every node id with the zone id + ".".
|
||||
const pre = "first_hoard."
|
||||
|
||||
// The full gilded route sums every vein's excess bias.
|
||||
all := &DungeonRun{ZoneID: ZoneFirstHoard, VisitedNodes: []string{pre + "entry", pre + "cap12", pre + "f1b2", pre + "r2b2", pre + "boss"}}
|
||||
if got := greedRouteRichness(all); !approxEq(got, 5.25) {
|
||||
t.Errorf("full route richness = %.2f, want 5.25", got)
|
||||
}
|
||||
|
||||
// A single vein contributes only its own excess.
|
||||
one := &DungeonRun{ZoneID: ZoneFirstHoard, VisitedNodes: []string{pre + "entry", pre + "cap12", pre + "boss"}}
|
||||
if got := greedRouteRichness(one); !approxEq(got, 2.0) {
|
||||
t.Errorf("one-vein (cap12@3.0) richness = %.2f, want 2.0", got)
|
||||
}
|
||||
|
||||
// A lean line that never touches a vein pays nothing.
|
||||
lean := &DungeonRun{ZoneID: ZoneFirstHoard, VisitedNodes: []string{pre + "entry", pre + "p1", pre + "p2", pre + "boss"}}
|
||||
if got := greedRouteRichness(lean); !approxEq(got, 0) {
|
||||
t.Errorf("lean route richness = %.2f, want 0", got)
|
||||
}
|
||||
|
||||
// An unknown zone has no graph and no richness.
|
||||
if got := greedRouteRichness(&DungeonRun{ZoneID: "nope", VisitedNodes: []string{"x"}}); got != 0 {
|
||||
t.Errorf("unknown-zone richness = %.2f, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBossRunModifiers_GreedTax(t *testing.T) {
|
||||
richRun := &DungeonRun{ZoneID: ZoneFirstHoard, VisitedNodes: []string{"first_hoard.cap12", "first_hoard.f1b2", "first_hoard.r2b2"}} // 5.25 rich → +10 Attack
|
||||
leanRun := &DungeonRun{ZoneID: ZoneFirstHoard, VisitedNodes: []string{"first_hoard.entry", "first_hoard.boss"}} // 0 rich → +0
|
||||
|
||||
// Aurvandryx's Attack rises with the gilded route walked into her cradle.
|
||||
enemy := &Combatant{Stats: CombatStats{Attack: 45}}
|
||||
applyBossRunModifiers("boss_aurvandryx", enemy, richRun)
|
||||
if enemy.Stats.Attack != 45+10 {
|
||||
t.Errorf("greedy-route Aurvandryx Attack = %d, want %d", enemy.Stats.Attack, 45+10)
|
||||
}
|
||||
|
||||
// A lean run leaves her at her base Attack.
|
||||
lean := &Combatant{Stats: CombatStats{Attack: 45}}
|
||||
applyBossRunModifiers("boss_aurvandryx", lean, leanRun)
|
||||
if lean.Stats.Attack != 45 {
|
||||
t.Errorf("lean-run Aurvandryx Attack = %d, want 45", lean.Stats.Attack)
|
||||
}
|
||||
|
||||
// Every non-hooked enemy is untouched, even on the richest route.
|
||||
other := &Combatant{Stats: CombatStats{Attack: 45}}
|
||||
applyBossRunModifiers("boss_seamstress", other, richRun)
|
||||
if other.Stats.Attack != 45 {
|
||||
t.Errorf("non-hooked boss Attack = %d, want 45 (no-op)", other.Stats.Attack)
|
||||
}
|
||||
|
||||
// A nil run is a no-op, not a panic.
|
||||
nilRun := &Combatant{Stats: CombatStats{Attack: 45}}
|
||||
applyBossRunModifiers("boss_aurvandryx", nilRun, nil)
|
||||
if nilRun.Stats.Attack != 45 {
|
||||
t.Errorf("nil-run Attack = %d, want 45 (no-op)", nilRun.Stats.Attack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhylacteryReviveCharges(t *testing.T) {
|
||||
// The Ossuary's three Verses are its only secret nodes: f1b2, f2b2, cap32
|
||||
// (see zone_graph_ossuary_ascendant.go). Node ids are zone-prefixed.
|
||||
const pre = "ossuary_ascendant."
|
||||
|
||||
// Skip every Verse → all three rebirths stay armed (fight a god).
|
||||
skip := &DungeonRun{ZoneID: ZoneOssuaryAscendant, VisitedNodes: []string{pre + "entry", pre + "p1", pre + "boss"}}
|
||||
if got := phylacteryReviveCharges("boss_valdris_ascendant", skip); got != 3 {
|
||||
t.Errorf("skip-route charges = %d, want 3", got)
|
||||
}
|
||||
|
||||
// Find one Verse → one rebirth unbound, two remain.
|
||||
one := &DungeonRun{ZoneID: ZoneOssuaryAscendant, VisitedNodes: []string{pre + "entry", pre + "f1b2", pre + "boss"}}
|
||||
if got := phylacteryReviveCharges("boss_valdris_ascendant", one); got != 2 {
|
||||
t.Errorf("one-Verse charges = %d, want 2", got)
|
||||
}
|
||||
|
||||
// Full-clear explorer finds all three → 0 rebirths, a mortal lich.
|
||||
full := &DungeonRun{ZoneID: ZoneOssuaryAscendant, VisitedNodes: []string{pre + "entry", pre + "f1b2", pre + "f2b2", pre + "cap32", pre + "boss"}}
|
||||
if got := phylacteryReviveCharges("boss_valdris_ascendant", full); got != 0 {
|
||||
t.Errorf("full-clear charges = %d, want 0", got)
|
||||
}
|
||||
|
||||
// Only Valdris carries the Verses; a nil run and any other boss are 0.
|
||||
if got := phylacteryReviveCharges("boss_aurvandryx", skip); got != 0 {
|
||||
t.Errorf("non-Valdris boss charges = %d, want 0", got)
|
||||
}
|
||||
if got := phylacteryReviveCharges("boss_valdris_ascendant", nil); got != 0 {
|
||||
t.Errorf("nil-run charges = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedBossRunStatuses_Phylactery(t *testing.T) {
|
||||
const pre = "ossuary_ascendant."
|
||||
skip := &DungeonRun{ZoneID: ZoneOssuaryAscendant, VisitedNodes: []string{pre + "entry", pre + "boss"}} // 3 unfound
|
||||
|
||||
// A skip-route seeds all three rebirths and the 25%-of-max revive pool.
|
||||
sess := &CombatSession{}
|
||||
if !seedBossRunStatuses(sess, "boss_valdris_ascendant", 560, skip) {
|
||||
t.Fatal("skip-route seed returned false, want true (charges seeded)")
|
||||
}
|
||||
if sess.Statuses.EnemyReviveCharges != 3 {
|
||||
t.Errorf("seeded charges = %d, want 3", sess.Statuses.EnemyReviveCharges)
|
||||
}
|
||||
if sess.Statuses.EnemyReviveHP != 140 { // 560/4
|
||||
t.Errorf("seeded revive HP = %d, want 140", sess.Statuses.EnemyReviveHP)
|
||||
}
|
||||
|
||||
// A full-clear seeds nothing and reports no change.
|
||||
full := &DungeonRun{ZoneID: ZoneOssuaryAscendant, VisitedNodes: []string{pre + "f1b2", pre + "f2b2", pre + "cap32"}}
|
||||
clean := &CombatSession{}
|
||||
if seedBossRunStatuses(clean, "boss_valdris_ascendant", 560, full) {
|
||||
t.Error("full-clear seed returned true, want false (no rebirths)")
|
||||
}
|
||||
if clean.Statuses.EnemyReviveCharges != 0 {
|
||||
t.Errorf("full-clear seeded charges = %d, want 0", clean.Statuses.EnemyReviveCharges)
|
||||
}
|
||||
|
||||
// Non-hooked boss: no-op even on a skip route.
|
||||
other := &CombatSession{}
|
||||
if seedBossRunStatuses(other, "boss_seamstress", 560, skip) {
|
||||
t.Error("non-hooked boss seed returned true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnemyDown_PhylacteryRebirth exercises the engine primitive: each seeded
|
||||
// charge revives the boss to the revive pool once, and the fight ends only when
|
||||
// the last charge is spent.
|
||||
func TestEnemyDown_PhylacteryRebirth(t *testing.T) {
|
||||
player := &Combatant{Name: "Hero", IsPlayer: true, Stats: CombatStats{MaxHP: 100, AC: 15, Attack: 10}}
|
||||
seat0 := newActor(player)
|
||||
st := &combatState{actor: seat0, actors: []*actor{seat0}, enemyReviveCharges: 2, enemyReviveHP: 140}
|
||||
|
||||
// First lethal blow: a charge spends, boss revives to the pool, not down.
|
||||
st.enemyHP = 0
|
||||
if enemyDown(st, "test") {
|
||||
t.Fatal("first killing blow reported down, want rebirth")
|
||||
}
|
||||
if st.enemyHP != 140 || st.enemyReviveCharges != 1 {
|
||||
t.Errorf("after 1st rebirth: HP=%d charges=%d, want 140/1", st.enemyHP, st.enemyReviveCharges)
|
||||
}
|
||||
|
||||
// Second lethal blow: last charge spends, revives again.
|
||||
st.enemyHP = -5
|
||||
if enemyDown(st, "test") {
|
||||
t.Fatal("second killing blow reported down, want rebirth")
|
||||
}
|
||||
if st.enemyHP != 140 || st.enemyReviveCharges != 0 {
|
||||
t.Errorf("after 2nd rebirth: HP=%d charges=%d, want 140/0", st.enemyHP, st.enemyReviveCharges)
|
||||
}
|
||||
|
||||
// Third lethal blow: no charges left, the lich stays dead.
|
||||
st.enemyHP = 0
|
||||
if !enemyDown(st, "test") {
|
||||
t.Error("charge-less killing blow reported alive, want down")
|
||||
}
|
||||
|
||||
// A rebirth event was emitted per revival (two), for the narrator.
|
||||
rebirths := 0
|
||||
for _, e := range st.events {
|
||||
if e.Action == "phylactery_rebirth" {
|
||||
rebirths++
|
||||
}
|
||||
}
|
||||
if rebirths != 2 {
|
||||
t.Errorf("phylactery_rebirth events = %d, want 2", rebirths)
|
||||
}
|
||||
}
|
||||
|
||||
func approxEq(a, b float64) bool {
|
||||
d := a - b
|
||||
return d < 1e-9 && d > -1e-9
|
||||
}
|
||||
|
||||
// custodianState builds a live combatState seated against a Custodian-shaped
|
||||
// enemy (MaxHP given), at the given round and current enemy HP, for direct
|
||||
// applyAmendment tests. Uses the real turn engine so the embedded actor/roster
|
||||
// are fully formed.
|
||||
func custodianState(t *testing.T, round, enemyHP, enemyMaxHP int) *combatState {
|
||||
t.Helper()
|
||||
sess := turnSession(CombatPhaseRoundEnd, 10000, enemyHP)
|
||||
sess.Round = round
|
||||
sess.EnemyID = "boss_custodian"
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = enemyMaxHP
|
||||
te := resumeTurnEngine(sess, []*Combatant{&p}, &e, combatSessionStepRNG(sess, enemySeat))
|
||||
te.st.enemyHP = enemyHP
|
||||
return te.st
|
||||
}
|
||||
|
||||
func countEvents(events []CombatEvent, action string) int {
|
||||
n := 0
|
||||
for _, ev := range events {
|
||||
if ev.Action == action {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestApplyAmendment_SnapshotAtRoundThree(t *testing.T) {
|
||||
// Before round 3: no snapshot.
|
||||
st := custodianState(t, 2, 400, 500)
|
||||
applyAmendment(st, 500)
|
||||
if st.enemyRewindHP != 0 {
|
||||
t.Errorf("round 2 snapshot = %d, want 0 (not yet)", st.enemyRewindHP)
|
||||
}
|
||||
// End of round 3: snapshot the current HP.
|
||||
st = custodianState(t, 3, 380, 500)
|
||||
applyAmendment(st, 500)
|
||||
if st.enemyRewindHP != 380 {
|
||||
t.Errorf("round 3 snapshot = %d, want 380", st.enemyRewindHP)
|
||||
}
|
||||
// A later round does not re-snapshot over the captured value.
|
||||
st.round = 5
|
||||
st.enemyHP = 200
|
||||
applyAmendment(st, 500)
|
||||
if st.enemyRewindHP != 380 {
|
||||
t.Errorf("post-capture snapshot = %d, want it frozen at 380", st.enemyRewindHP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAmendment_RewindOnceAtPhaseTwo(t *testing.T) {
|
||||
// Snapshot 400; boss now at 200, below the 0.45*500=225 phase-two line.
|
||||
st := custodianState(t, 6, 200, 500)
|
||||
st.enemyRewindHP = 400
|
||||
applyAmendment(st, 500)
|
||||
if st.enemyHP != 400 {
|
||||
t.Errorf("post-rewind HP = %d, want restored to snapshot 400", st.enemyHP)
|
||||
}
|
||||
if !st.enemyRewindUsed {
|
||||
t.Error("rewind did not mark itself used")
|
||||
}
|
||||
if countEvents(st.events, "amendment_rewind") != 1 {
|
||||
t.Errorf("amendment_rewind events = %d, want 1", countEvents(st.events, "amendment_rewind"))
|
||||
}
|
||||
// A second crossing does not rewind again.
|
||||
st.enemyHP = 150
|
||||
mark := len(st.events)
|
||||
applyAmendment(st, 500)
|
||||
if st.enemyHP != 150 {
|
||||
t.Errorf("second-crossing HP = %d, want left at 150 (rewind spent)", st.enemyHP)
|
||||
}
|
||||
if len(st.events) != mark {
|
||||
t.Error("a spent rewind emitted another event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAmendment_NoRewindAbovePhaseTwo(t *testing.T) {
|
||||
// Boss above the phase-two line: no rewind even with a snapshot in hand.
|
||||
st := custodianState(t, 6, 300, 500) // 300 > 225
|
||||
st.enemyRewindHP = 450
|
||||
applyAmendment(st, 500)
|
||||
if st.enemyHP != 300 || st.enemyRewindUsed {
|
||||
t.Errorf("HP=%d used=%v; want no rewind above phase two", st.enemyHP, st.enemyRewindUsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAmendment_NoRewindWithoutSnapshot(t *testing.T) {
|
||||
// Bursted into phase two before round 3: no snapshot, so no free heal.
|
||||
st := custodianState(t, 2, 100, 500)
|
||||
applyAmendment(st, 500)
|
||||
if st.enemyHP != 100 || st.enemyRewindUsed {
|
||||
t.Errorf("HP=%d used=%v; want no rewind without a snapshot", st.enemyHP, st.enemyRewindUsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAmendment_MidnightTimer(t *testing.T) {
|
||||
// Before round 20: no attack climb.
|
||||
st := custodianState(t, 19, 300, 500)
|
||||
applyAmendment(st, 500)
|
||||
if st.enemyAtkBuff != 0 {
|
||||
t.Errorf("round 19 atk buff = %d, want 0", st.enemyAtkBuff)
|
||||
}
|
||||
// Round 20+: +2 per round, capped.
|
||||
st = custodianState(t, 20, 300, 500)
|
||||
applyAmendment(st, 500)
|
||||
if st.enemyAtkBuff != midnightAtkStep {
|
||||
t.Errorf("round 20 atk buff = %d, want %d", st.enemyAtkBuff, midnightAtkStep)
|
||||
}
|
||||
if countEvents(st.events, "midnight_toll") != 1 {
|
||||
t.Errorf("midnight_toll events = %d, want 1", countEvents(st.events, "midnight_toll"))
|
||||
}
|
||||
// The cap holds against a truly endless stall.
|
||||
st.enemyAtkBuff = midnightAtkCap
|
||||
mark := len(st.events)
|
||||
applyAmendment(st, 500)
|
||||
if st.enemyAtkBuff != midnightAtkCap {
|
||||
t.Errorf("capped atk buff = %d, want %d", st.enemyAtkBuff, midnightAtkCap)
|
||||
}
|
||||
if len(st.events) != mark {
|
||||
t.Error("midnight timer emitted a toll after hitting the cap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBossInCombatRoundEnd_DispatchesOnlyCustodian(t *testing.T) {
|
||||
// A non-Custodian enemy is untouched even at a rewind-eligible HP.
|
||||
st := custodianState(t, 6, 200, 500)
|
||||
st.enemyRewindHP = 400
|
||||
applyBossInCombatRoundEnd(st, "boss_seraphel", 500)
|
||||
if st.enemyHP != 200 || st.enemyRewindUsed {
|
||||
t.Errorf("HP=%d used=%v; want no-op for a non-Custodian boss", st.enemyHP, st.enemyRewindUsed)
|
||||
}
|
||||
// The Custodian id does resolve the hook.
|
||||
applyBossInCombatRoundEnd(st, "boss_custodian", 500)
|
||||
if st.enemyHP != 400 || !st.enemyRewindUsed {
|
||||
t.Errorf("HP=%d used=%v; want the rewind for the Custodian", st.enemyHP, st.enemyRewindUsed)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inversion Stitch (Seamstress) ────────────────────────────────────────────
|
||||
|
||||
// seamstressState builds a fully-formed combatState (embedded actor + roster) so
|
||||
// the event helpers that read st.playerHP resolve — a bare struct-literal state
|
||||
// has a nil actor cursor.
|
||||
func seamstressState(t *testing.T, enemyHP, enemyMaxHP int) *combatState {
|
||||
t.Helper()
|
||||
sess := turnSession(CombatPhaseRoundEnd, 10000, enemyHP)
|
||||
sess.EnemyID = "boss_seamstress"
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = enemyMaxHP
|
||||
te := resumeTurnEngine(sess, []*Combatant{&p}, &e, combatSessionStepRNG(sess, enemySeat))
|
||||
te.st.enemyHP = enemyHP
|
||||
return te.st
|
||||
}
|
||||
|
||||
func TestApplyInversionStitch_NoOpAbovePhaseTwo(t *testing.T) {
|
||||
// Above the 0.35*500=175 phase-two line the room stays right-side-out.
|
||||
st := seamstressState(t, 300, 500)
|
||||
applyInversionStitch(st, 500)
|
||||
if st.inversionTelegraph || st.inversionActive != 0 || len(st.events) != 0 {
|
||||
t.Errorf("above phase two: telegraph=%v active=%d events=%d, want all zero",
|
||||
st.inversionTelegraph, st.inversionActive, len(st.events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyInversionStitch_Cadence(t *testing.T) {
|
||||
// Below the phase-two line the room cycles warn(1) → sting(pulse) → warn(1)…
|
||||
st := seamstressState(t, 150, 500) // 150 < 0.35*500 = 175
|
||||
|
||||
// Round end 1: no pulse yet, so telegraph the first one.
|
||||
applyInversionStitch(st, 500)
|
||||
if !st.inversionTelegraph || st.inversionActive != 0 {
|
||||
t.Fatalf("after warn: telegraph=%v active=%d, want telegraph=true active=0",
|
||||
st.inversionTelegraph, st.inversionActive)
|
||||
}
|
||||
if countEvents(st.events, "inversion_telegraph") != 1 {
|
||||
t.Fatalf("telegraph events = %d, want 1", countEvents(st.events, "inversion_telegraph"))
|
||||
}
|
||||
|
||||
// Round end 2: the telegraphed pulse activates for its full duration.
|
||||
applyInversionStitch(st, 500)
|
||||
if st.inversionActive != inversionPulseRounds || st.inversionTelegraph {
|
||||
t.Fatalf("after activate: active=%d telegraph=%v, want active=%d telegraph=false",
|
||||
st.inversionActive, st.inversionTelegraph, inversionPulseRounds)
|
||||
}
|
||||
if countEvents(st.events, "inversion_stitch") != 1 {
|
||||
t.Fatalf("stitch events = %d, want 1", countEvents(st.events, "inversion_stitch"))
|
||||
}
|
||||
|
||||
// The pulse counts down one round per round end, staying active until it lapses.
|
||||
for r := inversionPulseRounds - 1; r >= 1; r-- {
|
||||
applyInversionStitch(st, 500)
|
||||
if st.inversionActive != r {
|
||||
t.Fatalf("mid-pulse active = %d, want %d", st.inversionActive, r)
|
||||
}
|
||||
}
|
||||
|
||||
// The round the pulse lapses (active 1→0) a fresh warn is scheduled in the
|
||||
// same round end — the repeating rhythm, never two silent rounds in a row.
|
||||
applyInversionStitch(st, 500)
|
||||
if st.inversionActive != 0 || !st.inversionTelegraph {
|
||||
t.Fatalf("after pulse: active=%d telegraph=%v, want active=0 telegraph=true",
|
||||
st.inversionActive, st.inversionTelegraph)
|
||||
}
|
||||
if countEvents(st.events, "inversion_telegraph") != 2 {
|
||||
t.Fatalf("telegraph events = %d, want 2 (the next pulse warned)",
|
||||
countEvents(st.events, "inversion_telegraph"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBossInCombatRoundEnd_DispatchesSeamstress(t *testing.T) {
|
||||
// A non-Seamstress boss at a phase-two HP is untouched.
|
||||
st := seamstressState(t, 150, 500)
|
||||
applyBossInCombatRoundEnd(st, "boss_aurvandryx", 500)
|
||||
if st.inversionTelegraph || len(st.events) != 0 {
|
||||
t.Errorf("non-Seamstress boss triggered inversion (telegraph=%v events=%d)",
|
||||
st.inversionTelegraph, len(st.events))
|
||||
}
|
||||
// The Seamstress id resolves the hook.
|
||||
applyBossInCombatRoundEnd(st, "boss_seamstress", 500)
|
||||
if !st.inversionTelegraph {
|
||||
t.Error("boss_seamstress did not schedule the inversion telegraph")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInversionStitch_HealsSting drives a real round with an active pulse seeded
|
||||
// and confirms both the self-heal and the ally-heal land as wounds, floored at 1.
|
||||
func TestInversionStitch_HealsSting(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
p := &AdventurePlugin{}
|
||||
|
||||
t.Run("ally heal stings the friend", func(t *testing.T) {
|
||||
sess := startAllyHealFight(t, p, 50) // friend hurt to 50/100
|
||||
sess.Statuses.InversionActive = 1 // room is inside-out this round
|
||||
healer, friend := basePlayer(), basePlayer()
|
||||
ct := &combatTurn{sess: sess, players: []*Combatant{&healer, &friend},
|
||||
enemy: &Combatant{Name: "dummy", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}},
|
||||
seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))}
|
||||
before := sess.seatHP(1)
|
||||
|
||||
events, err := p.driveCombatRound(ct, PlayerAction{Kind: ActionCast,
|
||||
Effect: &turnActionEffect{Label: "Cure", Action: "spell_cast", AllyHeal: 30, AllySeat: 1}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := sess.seatHP(1); got >= before {
|
||||
t.Errorf("friend HP = %d (was %d) — an inverted heal must wound, not mend", got, before)
|
||||
}
|
||||
if countEvents(events, "heal_inverted") != 1 {
|
||||
t.Errorf("heal_inverted events = %d, want 1", countEvents(events, "heal_inverted"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("self heal stings the acting seat, floored at 1", func(t *testing.T) {
|
||||
// A self-heal (PlayerHeal, no AllySeat) lands on whichever seat is acting;
|
||||
// assert the inversion fired (heal_inverted) and no seat was driven to 0 —
|
||||
// the sting denies sustain, it never kills.
|
||||
sess := startAllyHealFight(t, p, 100)
|
||||
sess.Statuses.InversionActive = 1
|
||||
healer, friend := basePlayer(), basePlayer()
|
||||
ct := &combatTurn{sess: sess, players: []*Combatant{&healer, &friend},
|
||||
enemy: &Combatant{Name: "dummy", Stats: CombatStats{MaxHP: 800, AC: 10, Attack: 1, AttackBonus: 1}},
|
||||
seat: 0, uid: healerID(strings.ReplaceAll(t.Name(), "/", "_"))}
|
||||
|
||||
events, err := p.driveCombatRound(ct, PlayerAction{Kind: ActionCast,
|
||||
Effect: &turnActionEffect{Label: "Cure Self", Action: "spell_cast", PlayerHeal: 30}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if countEvents(events, "heal_inverted") != 1 {
|
||||
t.Errorf("heal_inverted events = %d, want 1 — the self-heal did not invert", countEvents(events, "heal_inverted"))
|
||||
}
|
||||
for seat := 0; seat < 2; seat++ {
|
||||
if got := sess.seatHP(seat); got < 1 {
|
||||
t.Errorf("seat %d HP = %d — the sting must floor at 1, never kill", seat, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTurnEngine_AmendmentRoundTrips(t *testing.T) {
|
||||
// Drive a real Custodian round-end and confirm the once-only rewind is
|
||||
// captured through CombatStatuses (a suspend/resume can't replay it).
|
||||
sess := turnSession(CombatPhaseRoundEnd, 10000, 200)
|
||||
sess.Round = 6
|
||||
sess.EnemyID = "boss_custodian"
|
||||
sess.EnemyHPMax = 500
|
||||
sess.Statuses.EnemyRewindHP = 400 // snapshot taken earlier in the fight
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 500
|
||||
te := resumeTurnEngine(sess, []*Combatant{&p}, &e, combatSessionStepRNG(sess, enemySeat))
|
||||
if _, err := te.step(PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
te.commit()
|
||||
|
||||
if sess.EnemyHP != 400 {
|
||||
t.Errorf("committed EnemyHP = %d, want the 400 rewind pool", sess.EnemyHP)
|
||||
}
|
||||
if !sess.Statuses.EnemyRewindUsed {
|
||||
t.Error("EnemyRewindUsed did not persist through commit")
|
||||
}
|
||||
if countEvents(sess.TurnLog, "amendment_rewind") != 1 {
|
||||
t.Errorf("amendment_rewind events = %d, want 1", countEvents(sess.TurnLog, "amendment_rewind"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnEngine_InversionRoundTrips(t *testing.T) {
|
||||
// Drive a real Seamstress round-end in phase 2 and confirm the scheduled
|
||||
// telegraph persists through CombatStatuses (a suspend/resume keeps the cadence).
|
||||
sess := turnSession(CombatPhaseRoundEnd, 10000, 150) // 150 < 0.35*500 = 175
|
||||
sess.EnemyID = "boss_seamstress"
|
||||
sess.EnemyHPMax = 500
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 500
|
||||
te := resumeTurnEngine(sess, []*Combatant{&p}, &e, combatSessionStepRNG(sess, enemySeat))
|
||||
if _, err := te.step(PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
te.commit()
|
||||
|
||||
if !sess.Statuses.InversionTelegraph {
|
||||
t.Error("InversionTelegraph did not persist through commit")
|
||||
}
|
||||
if countEvents(sess.TurnLog, "inversion_telegraph") != 1 {
|
||||
t.Errorf("inversion_telegraph events = %d, want 1", countEvents(sess.TurnLog, "inversion_telegraph"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user