1 Commits

Author SHA1 Message Date
dependabot[bot]
1b4e6833b0 Bump modernc.org/sqlite from 1.50.1 to 1.53.0
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.50.1 to 1.53.0.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.50.1...v1.53.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-10 14:24:35 +00:00
71 changed files with 508 additions and 4715 deletions

4
go.mod
View File

@@ -15,7 +15,7 @@ require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/image v0.40.0 golang.org/x/image v0.40.0
maunium.net/go/mautrix v0.28.1 maunium.net/go/mautrix v0.28.1
modernc.org/sqlite v1.50.1 modernc.org/sqlite v1.53.0
) )
require ( require (
@@ -43,7 +43,7 @@ require (
golang.org/x/sys v0.46.0 // indirect golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect golang.org/x/text v0.38.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.72.3 // indirect modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
) )

20
go.sum
View File

@@ -166,20 +166,20 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.28.1 h1:Hic3oDMPbLbQu1fhboTRAKZcORMjzzkjxsa+SGk60b0= maunium.net/go/mautrix v0.28.1 h1:Hic3oDMPbLbQu1fhboTRAKZcORMjzzkjxsa+SGk60b0=
maunium.net/go/mautrix v0.28.1/go.mod h1:mWXQNmOlrq4VTDU9f1HO03BSIswdUIyyY4wUKHqwzzY= maunium.net/go/mautrix v0.28.1/go.mod h1:mWXQNmOlrq4VTDU9f1HO03BSIswdUIyyY4wUKHqwzzY=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -188,8 +188,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -357,27 +357,6 @@ func runMigrations(d *sql.DB) error {
// row and no bootstrap backfill is needed. The column is a read guard: // row and no bootstrap backfill is needed. The column is a read guard:
// loadCombatParticipants is skipped entirely when it reads 1. // loadCombatParticipants is skipped entirely when it reads 1.
`ALTER TABLE combat_session ADD COLUMN roster_size INTEGER NOT NULL DEFAULT 1`, `ALTER TABLE combat_session ADD COLUMN roster_size INTEGER NOT NULL DEFAULT 1`,
// N4/E1 housing vault (gogobee_engagement_plan.md §E1). A Tier-4 Estate
// unlocks a 10-slot vault that shelters items from sale/use. Rather than a
// parallel table, a stowed item keeps its identity (id, temper, everything)
// and flips in_vault=1; loadAdvInventory filters it back out, so a vaulted
// item drops out of sell/craft/combat readers as one flag change. DEFAULT 0
// = "in play", correct for every pre-existing row, so no bootstrap backfill.
`ALTER TABLE adventure_inventory ADD COLUMN in_vault INTEGER NOT NULL DEFAULT 0`,
// N4/E1 second pet slot (gogobee_engagement_plan.md §E1). A Tier-4 Estate
// unlocks a second companion. It lives in a parallel pet2_* column set
// rather than a rows table so the single-pet path (and its combat golden)
// is untouched: absent pet2_type == "no second pet", DEFAULT '' is correct
// for every pre-existing row, so no bootstrap backfill. Pet 2 carries only
// what it needs — identity, level, barding — and deliberately skips the
// supply-shop unlock (a pet-1 mechanic) and morning-defense flag.
`ALTER TABLE player_meta ADD COLUMN pet2_type TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE player_meta ADD COLUMN pet2_name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE player_meta ADD COLUMN pet2_xp INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE player_meta ADD COLUMN pet2_level INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE player_meta ADD COLUMN pet2_armor_tier INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE player_meta ADD COLUMN pet2_flags_json TEXT NOT NULL DEFAULT '{}'`,
`ALTER TABLE player_meta ADD COLUMN pet2_level_10_date TEXT NOT NULL DEFAULT ''`,
} }
for _, stmt := range columnMigrations { for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil { if _, err := d.Exec(stmt); err != nil {
@@ -1397,21 +1376,6 @@ CREATE TABLE IF NOT EXISTS adventure_activity_log (
); );
CREATE INDEX IF NOT EXISTS idx_adv_log_user ON adventure_activity_log(user_id, logged_at); CREATE INDEX IF NOT EXISTS idx_adv_log_user ON adventure_activity_log(user_id, logged_at);
-- N4/E2 item gifting. One row per gift; the sender's row count for the current
-- UTC day enforces the daily cap (twink-funnel guard), and the full log is an
-- audit trail. gift_day is the UTC date string the cap counts against, kept
-- alongside given_at so the count is a plain equality match, not a range scan.
CREATE TABLE IF NOT EXISTS adventure_gift_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender TEXT NOT NULL,
recipient TEXT NOT NULL,
item_name TEXT NOT NULL,
value INTEGER NOT NULL DEFAULT 0,
gift_day TEXT NOT NULL,
given_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_adv_gift_sender_day ON adventure_gift_log(sender, gift_day);
CREATE TABLE IF NOT EXISTS adventure_treasures ( CREATE TABLE IF NOT EXISTS adventure_treasures (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,

View File

@@ -179,10 +179,6 @@ func (p *AdventurePlugin) Commands() []CommandDef {
{Name: "sell", Description: "Sell your hauled materials, fish, and items to Thom Krooke after an expedition (a smooth pitch can land a better price)", Usage: "!sell [list|all|<item>]", Category: "Games"}, {Name: "sell", Description: "Sell your hauled materials, fish, and items to Thom Krooke after an expedition (a smooth pitch can land a better price)", Usage: "!sell [list|all|<item>]", Category: "Games"},
{Name: "craft", Description: "Craft a discovered recipe at Thom Krooke (consumes ingredients)", Usage: "!craft [list|<recipe>]", Category: "Games"}, {Name: "craft", Description: "Craft a discovered recipe at Thom Krooke (consumes ingredients)", Usage: "!craft [list|<recipe>]", Category: "Games"},
{Name: "lore", Description: "Dig through Thom Krooke's lore stacks for a new recipe (sharp minds turn up more)", Usage: "!lore", Category: "Games"}, {Name: "lore", Description: "Dig through Thom Krooke's lore stacks for a new recipe (sharp minds turn up more)", Usage: "!lore", Category: "Games"},
{Name: "give", Description: "Gift a consumable or non-magic item to another adventurer (5% handling fee to the community pot; 3/day)", Usage: "!give <item> @user", Category: "Games"},
{Name: "town", Description: "Town registry — civic pride, housing street, and the pet showcase", Usage: "!town", Category: "Games"},
{Name: "graveyard", Description: "Recent deaths across the guild", Usage: "!graveyard", Category: "Games"},
{Name: "rivals", Description: "Your rival duel record, or `!rivals board` for room-wide standings", Usage: "!rivals [board]", Category: "Games"},
} }
} }
@@ -274,13 +270,9 @@ func (p *AdventurePlugin) Init() error {
go p.expeditionRecapTicker() go p.expeditionRecapTicker()
go p.expeditionAmbientTicker() go p.expeditionAmbientTicker()
go p.expeditionAutoRunTicker() go p.expeditionAutoRunTicker()
go p.expeditionExtractionSweepTicker()
// Auto-cashout any arena runs left in 'awaiting' from a prior restart // Auto-cashout any arena runs left in 'awaiting' from a prior restart
p.arenaCleanupStaleRuns() p.arenaCleanupStaleRuns()
// Extractions that lapsed while the bot was down: the roster they hold is
// blocking those members right now, not an hour from now.
p.sweepLapsedExtractions(time.Now().UTC())
return nil return nil
} }
@@ -420,20 +412,6 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "lore") { if p.IsCommand(ctx.Body, "lore") {
return p.handleLoreCmd(ctx) return p.handleLoreCmd(ctx)
} }
if p.IsCommand(ctx.Body, "give") {
return p.handleGiveCmd(ctx)
}
// 0b. Town registries (E3) — read-only social boards (rooms and DMs)
if p.IsCommand(ctx.Body, "town") {
return p.handleTownCmd(ctx)
}
if p.IsCommand(ctx.Body, "graveyard") {
return p.handleGraveyardCmd(ctx)
}
if p.IsCommand(ctx.Body, "rivals") {
return p.handleRivalsTopCmd(ctx, p.GetArgs(ctx.Body, "rivals"))
}
// 1. Arena commands (work in rooms and DMs) // 1. Arena commands (work in rooms and DMs)
if p.IsCommand(ctx.Body, "bail") { if p.IsCommand(ctx.Body, "bail") {
@@ -531,8 +509,6 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
return p.handleMasteryCmd(ctx) return p.handleMasteryCmd(ctx)
case lower == "treasures" || strings.HasPrefix(lower, "treasures "): case lower == "treasures" || strings.HasPrefix(lower, "treasures "):
return p.handleTreasuresCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "treasures"))) return p.handleTreasuresCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "treasures")))
case lower == "vault" || strings.HasPrefix(lower, "vault "):
return p.handleVaultCmd(ctx, strings.TrimSpace(args[len("vault"):]))
} }
return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.") return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.")
@@ -550,7 +526,6 @@ const advHelpText = `**Adventure Commands**
` + "`!adventure equip-magic`" + ` — Equip magic items (curios) into your D&D slots ` + "`!adventure equip-magic`" + ` — Equip magic items (curios) into your D&D slots
` + "`!adventure sell <item>`" + ` — Sell an inventory item (or ` + "`sell all`" + `) ` + "`!adventure sell <item>`" + ` — Sell an inventory item (or ` + "`sell all`" + `)
` + "`!adventure inventory`" + ` — View your inventory ` + "`!adventure inventory`" + ` — View your inventory
` + "`!adventure vault`" + ` — Store items safely (Tier-4 Established home; ` + "`vault store/take <item>`" + `)
` + "`!adventure leaderboard`" + ` — View the leaderboard ` + "`!adventure leaderboard`" + ` — View the leaderboard
` + "`!adventure respond`" + ` — Respond to a mid-day event ` + "`!adventure respond`" + ` — Respond to a mid-day event
` + "`!adventure rivals`" + ` — View rival duel records ` + "`!adventure rivals`" + ` — View rival duel records
@@ -915,9 +890,9 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
case "npc_encounter": case "npc_encounter":
return p.resolveNPCEncounter(ctx, interaction) return p.resolveNPCEncounter(ctx, interaction)
case "pet_arrival": case "pet_arrival":
return p.resolvePetArrival(ctx, interaction) return p.resolvePetArrival(ctx)
case "pet_type": case "pet_type":
return p.resolvePetType(ctx, interaction) return p.resolvePetType(ctx)
case "pet_name": case "pet_name":
return p.resolvePetName(ctx, interaction) return p.resolvePetName(ctx, interaction)
} }
@@ -1147,7 +1122,7 @@ func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCha
return return
} }
if count < maxTreasuresForTier(char.HouseTier) { if count < advMaxTreasures {
// Directly save // Directly save
if err := advSaveTreasure(userID, drop.Def); err != nil { if err := advSaveTreasure(userID, drop.Def); err != nil {
slog.Error("adventure: failed to save treasure", "user", userID, "err", err) slog.Error("adventure: failed to save treasure", "user", userID, "err", err)
@@ -1532,7 +1507,7 @@ func (p *AdventurePlugin) handleRecipesCmd(ctx MessageContext) error {
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.") return p.SendDM(ctx.Sender, "Failed to load your character.")
} }
return p.SendDM(ctx.Sender, renderRecipesKnown(char.ForagingSkill, homeWorkshopCraftBonus(char.HouseTier))) return p.SendDM(ctx.Sender, renderRecipesKnown(char.ForagingSkill))
} }
func (p *AdventurePlugin) handleTreasuresCmd(ctx MessageContext, arg string) error { func (p *AdventurePlugin) handleTreasuresCmd(ctx MessageContext, arg string) error {

View File

@@ -36,15 +36,22 @@ func (p *AdventurePlugin) runBabysitDailyTrickle(char *AdventureCharacter) {
if !char.BabysitActive { if !char.BabysitActive {
return return
} }
// Both companions share the sitter's attention and gain the flat trickle.
// (Combat only ever reads their *averaged* procs, so leveling both is not a
// power spike.)
leveled := false leveled := false
if char.HasPet() { if char.HasPet() && char.PetLevel < 10 {
leveled = advancePetLevelsFromXP(&char.PetXP, &char.PetLevel, &char.PetLevel10Date, petXPPerBabysitDay*100) || leveled // Bypass petGrantXP's per-action constant — we want a flat trickle.
char.PetXP += petXPPerBabysitDay * 100
for char.PetLevel < 10 {
needed := petXPToNextLevel(char.PetLevel) * 100
if char.PetXP < needed {
break
}
char.PetXP -= needed
char.PetLevel++
leveled = true
}
if char.PetLevel >= 10 && char.PetLevel10Date == "" {
char.PetLevel10Date = time.Now().UTC().Format("2006-01-02")
} }
if char.HasPet2() {
leveled = advancePetLevelsFromXP(&char.Pet2XP, &char.Pet2Level, &char.Pet2Level10Date, petXPPerBabysitDay*100) || leveled
} }
outcome := "pet_care" outcome := "pet_care"
if leveled { if leveled {

View File

@@ -92,18 +92,6 @@ type AdventureCharacter struct {
PetSupplyShopUnlocked bool PetSupplyShopUnlocked bool
PetLevel10Date string PetLevel10Date string
PetMorningDefense bool PetMorningDefense bool
// Second pet (N4/E1, Tier-4 Estate). A parallel slot, not a rewrite of the
// pet-1 fields above — see HasPet2. Carries only identity/level/barding; the
// morning-defense, ditch-recovery and supply-shop mechanics stay pet-1-only.
Pet2Type string
Pet2Name string
Pet2XP int
Pet2Level int
Pet2ArmorTier int
Pet2ChasedAway bool
Pet2Reactivated bool
Pet2Arrived bool
Pet2Level10Date string
AutoBabysit bool AutoBabysit bool
AutoBabysitFocus string // mining|fishing|foraging — preferred skill for auto-babysit; "" defaults to weakest AutoBabysitFocus string // mining|fishing|foraging — preferred skill for auto-babysit; "" defaults to weakest
TreasuresLocked bool // when true, treasure drops at cap are refused instead of auto-swapping TreasuresLocked bool // when true, treasure drops at cap are refused instead of auto-swapping
@@ -282,11 +270,6 @@ func (c *AdventureCharacter) HasPet() bool {
return c.PetType != "" && c.PetArrived && !c.PetChasedAway return c.PetType != "" && c.PetArrived && !c.PetChasedAway
} }
// HasPet2 returns true if the player has an active second pet.
func (c *AdventureCharacter) HasPet2() bool {
return c.Pet2Type != "" && c.Pet2Arrived && !c.Pet2ChasedAway
}
// HasHouse returns true if the player has purchased at least a base house. // HasHouse returns true if the player has purchased at least a base house.
func (c *AdventureCharacter) HasHouse() bool { func (c *AdventureCharacter) HasHouse() bool {
return c.HouseTier > 0 || c.HouseLoanBalance > 0 return c.HouseTier > 0 || c.HouseLoanBalance > 0
@@ -579,7 +562,7 @@ func loadAdvInventory(userID id.UserID) ([]AdvItem, error) {
d := db.Get() d := db.Get()
rows, err := d.Query(` rows, err := d.Query(`
SELECT id, name, item_type, tier, value, slot, skill_source, temper SELECT id, name, item_type, tier, value, slot, skill_source, temper
FROM adventure_inventory WHERE user_id = ? AND in_vault = 0 FROM adventure_inventory WHERE user_id = ?
ORDER BY tier DESC, value DESC`, string(userID)) ORDER BY tier DESC, value DESC`, string(userID))
if err != nil { if err != nil {
return nil, err return nil, err
@@ -630,10 +613,7 @@ func clearAdvInventory(userID id.UserID) ([]AdvItem, error) {
return nil, err return nil, err
} }
d := db.Get() d := db.Get()
// Delete only what loadAdvInventory returned — vaulted rows are out of play _, err = d.Exec(`DELETE FROM adventure_inventory WHERE user_id = ?`, string(userID))
// and must survive a bulk clear, or the vault's whole promise (safe from
// !sell all) breaks the moment any caller routes sell-all through here.
_, err = d.Exec(`DELETE FROM adventure_inventory WHERE user_id = ? AND in_vault = 0`, string(userID))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -643,62 +623,10 @@ func clearAdvInventory(userID id.UserID) ([]AdvItem, error) {
func advInventoryCount(userID id.UserID) int { func advInventoryCount(userID id.UserID) int {
d := db.Get() d := db.Get()
var count int var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM adventure_inventory WHERE user_id = ? AND in_vault = 0`, string(userID)).Scan(&count) _ = d.QueryRow(`SELECT COUNT(*) FROM adventure_inventory WHERE user_id = ?`, string(userID)).Scan(&count)
return count return count
} }
// loadAdvVault returns the items the player has stowed in their housing vault
// (N4/E1). These are excluded from loadAdvInventory — a vaulted item is out of
// play (unsellable, uncraftable, unusable in combat) until it is taken back.
func loadAdvVault(userID id.UserID) ([]AdvItem, error) {
d := db.Get()
rows, err := d.Query(`
SELECT id, name, item_type, tier, value, slot, skill_source, temper
FROM adventure_inventory WHERE user_id = ? AND in_vault = 1
ORDER BY tier DESC, value DESC`, string(userID))
if err != nil {
return nil, err
}
defer rows.Close()
var items []AdvItem
for rows.Next() {
var it AdvItem
var slot string
if err := rows.Scan(&it.ID, &it.Name, &it.Type, &it.Tier, &it.Value, &slot, &it.SkillSource, &it.Temper); err != nil {
return nil, err
}
it.Slot = EquipmentSlot(slot)
items = append(items, it)
}
return items, rows.Err()
}
// advVaultCount returns how many of a user's items are stowed in the vault.
func advVaultCount(userID id.UserID) int {
d := db.Get()
var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM adventure_inventory WHERE user_id = ? AND in_vault = 1`, string(userID)).Scan(&count)
return count
}
// setItemVaulted flips a single inventory row's vault flag. Scoped to the
// owning user so a mistyped id can never move another player's item.
func setItemVaulted(userID id.UserID, itemID int64, vaulted bool) (bool, error) {
v := 0
if vaulted {
v = 1
}
res, err := db.Get().Exec(
`UPDATE adventure_inventory SET in_vault = ? WHERE id = ? AND user_id = ?`,
v, itemID, string(userID))
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
func loadAllAdvCharacters() ([]AdventureCharacter, error) { func loadAllAdvCharacters() ([]AdventureCharacter, error) {
d := db.Get() d := db.Get()
rows, err := d.Query(`SELECT user_id FROM player_meta`) rows, err := d.Query(`SELECT user_id FROM player_meta`)

View File

@@ -346,34 +346,15 @@ var craftingRecipes = []CraftingRecipe{
{Result: "Voidstone Shard", Ingredients: []string{"Voidstone", "Mythril Ore"}, MinForaging: 30, Tier: 5}, {Result: "Voidstone Shard", Ingredients: []string{"Voidstone", "Mythril Ore"}, MinForaging: 30, Tier: 5},
} }
// houseTierWorkshop is the T3 "Comfortable" house that unlocks the workshop — // craftingSuccessRate returns the success chance for a recipe given foraging level.
// a flat craft-success bonus while crafting at home. // Base rate is 50% at minimum level, +3% per 5 levels above minimum, capped at 95%.
const houseTierWorkshop = 3 func craftingSuccessRate(foragingLevel, minForaging int) float64 {
// homeWorkshopCraftBonus returns the additive craft-success bonus from a T3+
// home workshop. Tier 1/2 and no house grant none.
func homeWorkshopCraftBonus(houseTier int) float64 {
if houseTier >= houseTierWorkshop {
return 0.05
}
return 0
}
// craftingSuccessRate returns the success chance for a recipe given foraging
// level. Base rate is 50% at minimum level, +3% per 5 levels above minimum,
// capped at 95%. A T3+ home workshop adds workshopBonus on top and lifts the
// cap to 98%, so a maxed forager still gains a little from the bench.
func craftingSuccessRate(foragingLevel, minForaging int, workshopBonus float64) float64 {
base := 0.50 base := 0.50
levelsAbove := foragingLevel - minForaging levelsAbove := foragingLevel - minForaging
bonus := float64(levelsAbove) / 5.0 * 0.03 bonus := float64(levelsAbove) / 5.0 * 0.03
rate := base + bonus + workshopBonus rate := base + bonus
cap := 0.95 if rate > 0.95 {
if workshopBonus > 0 { return 0.95
cap = 0.98
}
if rate > cap {
return cap
} }
return rate return rate
} }
@@ -388,7 +369,7 @@ type CraftResult struct {
// their current foraging level, plus a teaser line for the next unlock // their current foraging level, plus a teaser line for the next unlock
// threshold. Hides exact ingredient lists for recipes the player hasn't // threshold. Hides exact ingredient lists for recipes the player hasn't
// unlocked — only the count of locked recipes is shown. // unlocked — only the count of locked recipes is shown.
func renderRecipesKnown(foragingLevel int, workshopBonus float64) string { func renderRecipesKnown(foragingLevel int) string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(fmt.Sprintf("🧪 **Crafting Recipes** — Foraging Lv.%d\n\n", foragingLevel)) sb.WriteString(fmt.Sprintf("🧪 **Crafting Recipes** — Foraging Lv.%d\n\n", foragingLevel))
@@ -396,9 +377,6 @@ func renderRecipesKnown(foragingLevel int, workshopBonus float64) string {
sb.WriteString("_Auto-crafting unlocks at Foraging Lv.10. Keep gathering._") sb.WriteString("_Auto-crafting unlocks at Foraging Lv.10. Keep gathering._")
return sb.String() return sb.String()
} }
if workshopBonus > 0 {
sb.WriteString(fmt.Sprintf("_🔨 Home workshop: +%.0f%% craft success._\n\n", workshopBonus*100))
}
// Group recipes by tier, list those known. // Group recipes by tier, list those known.
known := map[int][]CraftingRecipe{} known := map[int][]CraftingRecipe{}
@@ -424,7 +402,7 @@ func renderRecipesKnown(foragingLevel int, workshopBonus float64) string {
} }
sb.WriteString(fmt.Sprintf("**Tier %d** (Foraging %d+)\n", tier, recipes[0].MinForaging)) sb.WriteString(fmt.Sprintf("**Tier %d** (Foraging %d+)\n", tier, recipes[0].MinForaging))
for _, r := range recipes { for _, r := range recipes {
rate := craftingSuccessRate(foragingLevel, r.MinForaging, workshopBonus) * 100 rate := craftingSuccessRate(foragingLevel, r.MinForaging) * 100
sb.WriteString(fmt.Sprintf(" • %s — %s (%.0f%% success)\n", sb.WriteString(fmt.Sprintf(" • %s — %s (%.0f%% success)\n",
r.Result, strings.Join(r.Ingredients, " + "), rate)) r.Result, strings.Join(r.Ingredients, " + "), rate))
} }
@@ -455,7 +433,7 @@ var craftXPFailure = map[int]int{1: 3, 2: 5, 3: 8, 4: 12, 5: 18}
// //
// Side effects: grants foraging XP per attempt (success > failure) and bumps // Side effects: grants foraging XP per attempt (success > failure) and bumps
// the player's CraftsSucceeded counter on each success. // the player's CraftsSucceeded counter on each success.
func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int, workshopBonus float64) ([]CraftResult, []AdvItem) { func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int) ([]CraftResult, []AdvItem) {
if foragingLevel < 10 { if foragingLevel < 10 {
return nil, items return nil, items
} }
@@ -474,7 +452,7 @@ func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int,
break break
} }
rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging, workshopBonus) rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging)
success := rand.Float64() < rate success := rand.Float64() < rate
if success { if success {

View File

@@ -32,23 +32,10 @@ type advActiveEvent struct {
// //
// The per-anchor chances below put a player who hits all three at ~1 event // The per-anchor chances below put a player who hits all three at ~1 event
// per week; see TestAnchoredEventWeeklyRate. // per week; see TestAnchoredEventWeeklyRate.
//
// K — the three social/economic anchors miss the pure grind loop: a player who
// only mines/forages/fishes (now automatic, done by walking) and clears
// single-day dungeons, but never sells, never enters the arena, and never runs
// a multi-day expedition to a Night camp, would roll for zero events. The
// fourth anchor, ZoneClear, fires on a foreground single-day zone clear — the
// climax DM that player *is* reading — so they get the same rough cadence.
// Autopilot walks and mid-zone region clears deliberately do not fire it (see
// zoneCmdAdvance / the full-clear branch of advanceOnceWithOpts). The per-day
// slot still caps everyone at one event, so a player who happens to hit two
// anchors in a day can't double up. This rate is the tuning knob for item K —
// bump it if telemetry shows the grind-loop player still sees too few.
const ( const (
advEventChanceDigest = 0.08 advEventChanceDigest = 0.08
advEventChanceSell = 0.05 advEventChanceSell = 0.05
advEventChanceArena = 0.05 advEventChanceArena = 0.05
advEventChanceZoneClear = 0.08
) )
var ( var (

View File

@@ -44,43 +44,6 @@ func TestAnchoredEventWeeklyRate(t *testing.T) {
} }
} }
// TestZoneClearAnchorRate pins item K's fourth anchor for the population the
// other three miss: a grind-loop player who never sells, never enters the
// arena, and never runs a multi-day expedition, but clears single-day zones.
// Modelled at two foreground clears per active day (a plausible dungeon
// session) they should land in the same ~1 event/week band as the fully-
// engaged player — that is the whole point of adding the anchor. The clears/day
// assumption and advEventChanceZoneClear are the two tuning knobs; see the K
// note in adventure_events.go.
func TestZoneClearAnchorRate(t *testing.T) {
const clearsPerDay = 2
const weeks = 20000
rng := rand.New(rand.NewPCG(0x5EED, 0x2C)) // "ZC"
events := 0
for range weeks {
for range 7 {
firedToday := false
for range clearsPerDay {
if firedToday {
break // one event per player per UTC day
}
if rng.Float64() < advEventChanceZoneClear {
firedToday = true
}
}
if firedToday {
events++
}
}
}
perWeek := float64(events) / weeks
if perWeek < 0.8 || perWeek > 1.5 {
t.Errorf("zone-clear anchor = %.3f/week, want ~1 (0.81.5)", perWeek)
}
}
// TestClaimDailyEventSlot_OnePerDay guards the cap the rate test assumes. // TestClaimDailyEventSlot_OnePerDay guards the cap the rate test assumes.
func TestClaimDailyEventSlot_OnePerDay(t *testing.T) { func TestClaimDailyEventSlot_OnePerDay(t *testing.T) {
uid := id.UserID("@events-slot:example") uid := id.UserID("@events-slot:example")

View File

@@ -1,200 +0,0 @@
package plugin
import (
"fmt"
"math"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// N4/E2 item gifting. `!give <item> @user` hands a consumable or non-magic gear
// item to another adventurer. The sender pays a 5% handling fee (of item value)
// to the community pot — the same civic tax every payout pays — and is capped at
// a few gifts a day so the feature can't become a twink-funnel for a fresh alt.
// Magic items are deliberately non-giftable: attunement and the BiS economy stay
// personal (gogobee_engagement_plan.md §E2).
const (
giftDailyCap = 3
giftTaxRate = 0.05
)
// giftableTypes is the allowlist of AdvItem.Type values a player may gift.
// Everything in adventure_inventory is unequipped by definition (equipped gear
// lives in adventure_equipment / magic_item_equipped), so "unequipped" needs no
// separate check. Magic items ("magic_item") and raw materials are excluded.
var giftableTypes = map[string]bool{
"consumable": true,
"MasterworkGear": true,
"ArenaGear": true,
}
// isGiftableItem reports whether an inventory item may be gifted, and a reason
// when it may not.
func isGiftableItem(it AdvItem) (bool, string) {
if it.Type == "magic_item" {
return false, "Magic items stay personal — attunement doesn't transfer. Try a consumable or a piece of gear."
}
if !giftableTypes[it.Type] {
return false, "You can only gift consumables and non-magic gear. Raw materials and treasures aren't giftable."
}
return true, ""
}
// pickGiftableItem resolves the item a `!give` query names, preferring a giftable
// match so a non-giftable item (a magic item, raw materials) that also matches
// can't shadow a giftable one and block the gift — the way plain findInventoryMatch
// would, since it returns the first match across the whole inventory. Returns
// (item, "") on success; (nil, "") when nothing matches at all; and (nil, reason)
// when only non-giftable items match, so the caller can explain why.
func pickGiftableItem(inv []AdvItem, query string) (*AdvItem, string) {
match := findInventoryMatch(inv, query)
if match == nil {
return nil, ""
}
if ok, _ := isGiftableItem(*match); ok {
return match, ""
}
giftables := make([]AdvItem, 0, len(inv))
for _, it := range inv {
if ok, _ := isGiftableItem(it); ok {
giftables = append(giftables, it)
}
}
if alt := findInventoryMatch(giftables, query); alt != nil {
return alt, ""
}
_, reason := isGiftableItem(*match)
return nil, reason
}
// giftCountToday returns how many gifts the sender has already sent on the given
// UTC day. The cap is enforced against the persisted log, so a restart can't
// reset someone's daily allowance.
func giftCountToday(sender id.UserID, day string) int {
var n int
_ = db.Get().QueryRow(
`SELECT COUNT(*) FROM adventure_gift_log WHERE sender = ? AND gift_day = ?`,
string(sender), day).Scan(&n)
return n
}
// logGift records a completed gift for the daily cap and the audit trail.
func logGift(sender, recipient id.UserID, itemName string, value int64, day string) error {
_, err := db.Get().Exec(
`INSERT INTO adventure_gift_log (sender, recipient, item_name, value, gift_day)
VALUES (?, ?, ?, ?, ?)`,
string(sender), string(recipient), itemName, value, day)
return err
}
// transferInventoryItem moves one inventory row from one owner to another. Scoped
// to the current owner so a stale id can't move a row that has already changed
// hands, and forces in_vault=0 so a gift always lands in the recipient's active
// pack rather than a phantom vault slot.
func transferInventoryItem(itemID int64, from, to id.UserID) (bool, error) {
res, err := db.Get().Exec(
`UPDATE adventure_inventory SET user_id = ?, in_vault = 0 WHERE id = ? AND user_id = ?`,
string(to), itemID, string(from))
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// handleGiveCmd routes `!give <item> @user`.
func (p *AdventurePlugin) handleGiveCmd(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "give"))
// Serialize a sender's own gifts so two near-simultaneous !give commands
// can't both clear the daily cap or double-spend the same item row.
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` to create one first.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, "You're dead. Your things stay where they are for now.")
}
// The target is the trailing token; everything before it is the item name.
fields := strings.Fields(args)
if len(fields) < 2 {
return p.SendDM(ctx.Sender, "Usage: `!give <item> @user` — e.g. `!give Health Potion @alex`.")
}
targetRaw := fields[len(fields)-1]
itemQuery := strings.TrimSpace(strings.TrimSuffix(args, targetRaw))
target, ok := p.ResolveUser(targetRaw, ctx.RoomID)
if !ok {
return p.SendDM(ctx.Sender, fmt.Sprintf("Couldn't find a user matching %q. Mention them or use their exact name.", targetRaw))
}
if target == ctx.Sender {
return p.SendDM(ctx.Sender, "Regifting to yourself? That's just moving things around.")
}
// Recipient must have completed !setup.
rc, rerr := LoadDnDCharacter(target)
if rerr != nil || rc == nil || rc.PendingSetup {
name := p.DisplayName(target)
return p.SendDM(ctx.Sender, fmt.Sprintf("%s hasn't set up an adventurer yet — they need to run `!setup` before they can receive gifts.", name))
}
day := time.Now().UTC().Format("2006-01-02")
if sent := giftCountToday(ctx.Sender, day); sent >= giftDailyCap {
return p.SendDM(ctx.Sender, fmt.Sprintf("You've already sent %d gifts today (the daily limit). Try again tomorrow.", sent))
}
inv, err := loadAdvInventory(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't reach your inventory. Try again in a moment.")
}
match, reason := pickGiftableItem(inv, itemQuery)
if match == nil {
if reason == "" {
return p.SendDM(ctx.Sender, fmt.Sprintf("No inventory item matches %q. Check `!adventure inventory`.", itemQuery))
}
return p.SendDM(ctx.Sender, reason)
}
tax := int(math.Round(float64(match.Value) * giftTaxRate))
if tax > 0 {
if bal := p.euro.GetBalance(ctx.Sender); bal < float64(tax) {
return p.SendDM(ctx.Sender, fmt.Sprintf("Gifting **%s** carries a €%d handling fee (5%% of its value), and you only have €%.0f.", match.Name, tax, bal))
}
if !p.euro.Debit(ctx.Sender, float64(tax), "adventure_gift_tax") {
return p.SendDM(ctx.Sender, "Transaction failed. The economy is having a moment.")
}
}
moved, err := transferInventoryItem(match.ID, ctx.Sender, target)
if err != nil || !moved {
if tax > 0 {
p.euro.Credit(ctx.Sender, float64(tax), "adventure_gift_refund")
}
return p.SendDM(ctx.Sender, "Couldn't hand the item over. Nothing changed — try again in a moment.")
}
if tax > 0 {
communityPotAdd(tax)
trackTaxPaid(ctx.Sender, tax)
}
_ = logGift(ctx.Sender, target, match.Name, match.Value, day)
senderName := p.DisplayName(ctx.Sender)
recipName := p.DisplayName(target)
_ = p.SendDM(target, fmt.Sprintf("🎁 **%s** sent you **%s**! It's in your inventory now — `!adventure inventory` to take a look.", senderName, match.Name))
remaining := giftDailyCap - giftCountToday(ctx.Sender, day)
feeNote := ""
if tax > 0 {
feeNote = fmt.Sprintf(" (€%d handling fee to the community pot)", tax)
}
return p.SendDM(ctx.Sender, fmt.Sprintf("🎁 Sent **%s** to %s%s. %d gift(s) left today.", match.Name, recipName, feeNote, remaining))
}

View File

@@ -1,145 +0,0 @@
package plugin
import (
"testing"
"maunium.net/go/mautrix/id"
)
func TestIsGiftableItem(t *testing.T) {
cases := []struct {
typ string
want bool
}{
{"consumable", true},
{"MasterworkGear", true},
{"ArenaGear", true},
{"magic_item", false},
{"treasure", false},
{"ore", false},
{"card", false},
}
for _, c := range cases {
got, reason := isGiftableItem(AdvItem{Type: c.typ, Name: "X"})
if got != c.want {
t.Errorf("isGiftableItem(%q) = %v, want %v (reason %q)", c.typ, got, c.want, reason)
}
if !got && reason == "" {
t.Errorf("isGiftableItem(%q) rejected with no reason", c.typ)
}
}
}
// TestPickGiftableItem: a non-giftable item must not shadow a giftable one that
// also matches the query.
func TestPickGiftableItem(t *testing.T) {
inv := []AdvItem{
{ID: 1, Name: "Dragon Scale", Type: "material", Tier: 5, Value: 9000},
{ID: 2, Name: "Dragon Draught", Type: "consumable", Tier: 1, Value: 200},
}
// "dragon" matches the Scale first (higher tier), but the Draught is the
// giftable one — pick it, don't refuse.
got, reason := pickGiftableItem(inv, "dragon")
if got == nil {
t.Fatalf("pickGiftableItem returned nil (reason %q), want the giftable Draught", reason)
}
if got.Name != "Dragon Draught" {
t.Errorf("picked %q, want Dragon Draught", got.Name)
}
// Only a non-giftable match → refuse with a reason.
onlyMagic := []AdvItem{{ID: 3, Name: "Ring of Power", Type: "magic_item", Value: 5000}}
got, reason = pickGiftableItem(onlyMagic, "ring")
if got != nil || reason == "" {
t.Errorf("magic-only match: got=%v reason=%q, want nil with a refusal reason", got, reason)
}
// No match at all → nil, empty reason.
got, reason = pickGiftableItem(inv, "nonexistent")
if got != nil || reason != "" {
t.Errorf("no match: got=%v reason=%q, want nil/empty", got, reason)
}
}
// TestTransferInventoryItem: an item moves owners exactly, and a wrong-owner
// transfer is a no-op.
func TestTransferInventoryItem(t *testing.T) {
vaultTestDB(t)
alice := id.UserID("@alice:test.invalid")
bob := id.UserID("@bob:test.invalid")
if err := addAdvInventoryItem(alice, AdvItem{Name: "Health Potion", Type: "consumable", Tier: 1, Value: 200}); err != nil {
t.Fatal(err)
}
inv, _ := loadAdvInventory(alice)
if len(inv) != 1 {
t.Fatalf("setup: alice has %d items, want 1", len(inv))
}
itemID := inv[0].ID
// Wrong owner can't move it.
if moved, err := transferInventoryItem(itemID, bob, alice); err != nil || moved {
t.Fatalf("wrong-owner transfer moved=%v err=%v, want no-op", moved, err)
}
// Real owner moves it to bob.
moved, err := transferInventoryItem(itemID, alice, bob)
if err != nil || !moved {
t.Fatalf("transfer moved=%v err=%v, want success", moved, err)
}
if a, _ := loadAdvInventory(alice); len(a) != 0 {
t.Errorf("alice still has %d items after gifting", len(a))
}
b, _ := loadAdvInventory(bob)
if len(b) != 1 || b[0].Name != "Health Potion" {
t.Errorf("bob's inventory = %+v, want the potion", b)
}
}
// TestTransferUnvaultsItem: gifting a currently-vaulted item lands it in the
// recipient's active pack, not a phantom vault slot.
func TestTransferUnvaultsItem(t *testing.T) {
vaultTestDB(t)
alice := id.UserID("@alice2:test.invalid")
bob := id.UserID("@bob2:test.invalid")
if err := addAdvInventoryItem(alice, AdvItem{Name: "Elixir", Type: "consumable", Tier: 1, Value: 100}); err != nil {
t.Fatal(err)
}
inv, _ := loadAdvInventory(alice)
if _, err := setItemVaulted(alice, inv[0].ID, true); err != nil {
t.Fatal(err)
}
if _, err := transferInventoryItem(inv[0].ID, alice, bob); err != nil {
t.Fatal(err)
}
// Bob sees it in active inventory (not vault).
if b, _ := loadAdvInventory(bob); len(b) != 1 {
t.Errorf("bob active inventory = %d, want 1 (item un-vaulted on gift)", len(b))
}
if bv, _ := loadAdvVault(bob); len(bv) != 0 {
t.Errorf("bob vault = %d, want 0", len(bv))
}
}
// TestGiftDailyCapCounting: logGift increments the day's count and the count is
// day-scoped.
func TestGiftDailyCapCounting(t *testing.T) {
vaultTestDB(t)
sender := id.UserID("@giver:test.invalid")
recip := id.UserID("@taker:test.invalid")
if n := giftCountToday(sender, "2026-07-10"); n != 0 {
t.Fatalf("initial count = %d, want 0", n)
}
for i := 0; i < giftDailyCap; i++ {
if err := logGift(sender, recip, "Potion", 100, "2026-07-10"); err != nil {
t.Fatal(err)
}
}
if n := giftCountToday(sender, "2026-07-10"); n != giftDailyCap {
t.Errorf("count = %d, want %d", n, giftDailyCap)
}
// A different day is a fresh allowance.
if n := giftCountToday(sender, "2026-07-11"); n != 0 {
t.Errorf("next-day count = %d, want 0", n)
}
}

View File

@@ -99,16 +99,12 @@ type advPendingHouseDownPayment struct {
type advPendingHouseAutopay struct{} type advPendingHouseAutopay struct{}
// Slot is the pet slot the arrival conversation is filling: 1 (the first pet) type advPendingPetArrival struct{}
// or 2 (the Tier-4 Estate's second companion). Zero-value 1 keeps every
// existing single-pet interaction unchanged.
type advPendingPetArrival struct{ Slot int }
type advPendingPetType struct{ Slot int } type advPendingPetType struct{}
type advPendingPetName struct { type advPendingPetName struct {
PetType string PetType string
Slot int
} }
// ── Thom Krooke Greeting ─────────────────────────────────────────────────── // ── Thom Krooke Greeting ───────────────────────────────────────────────────
@@ -225,19 +221,11 @@ func thomShopView(char *AdventureCharacter, balance float64) string {
} }
} }
// Pet supply shop (unlocks 1 week after pet level 10). The unlock is a // Pet supply shop (unlocks 1 week after pet level 10)
// pet-1 mechanic but covers barding for both companions. Each pet's block is if char.PetSupplyShopUnlocked && char.HasPet() {
// gated on its own presence so a chased-away pet-1 doesn't hide pet-2's shop.
if char.PetSupplyShopUnlocked && (char.HasPet() || char.HasPet2()) {
sb.WriteString("\n---\n") sb.WriteString("\n---\n")
if char.HasPet() {
sb.WriteString(fmt.Sprintf("🐾 **Pet Supplies** — for %s\n\n", char.PetName)) sb.WriteString(fmt.Sprintf("🐾 **Pet Supplies** — for %s\n\n", char.PetName))
sb.WriteString(petArmorShopView(char.PetType, char.PetArmorTier, "petbuy")) sb.WriteString(petArmorShopView(char))
}
if char.HasPet2() {
sb.WriteString(fmt.Sprintf("\n🐾 **Pet Supplies** — for %s\n\n", char.Pet2Name))
sb.WriteString(petArmorShopView(char.Pet2Type, char.Pet2ArmorTier, "pet2buy"))
}
} }
return sb.String() return sb.String()
@@ -281,11 +269,8 @@ func (p *AdventurePlugin) handleThomCmd(ctx MessageContext) error {
case lower == "buy" || strings.HasPrefix(lower, "buy "): case lower == "buy" || strings.HasPrefix(lower, "buy "):
return p.handleThomBuy(ctx, char, balance, strings.TrimSpace(strings.TrimPrefix(lower, "buy"))) return p.handleThomBuy(ctx, char, balance, strings.TrimSpace(strings.TrimPrefix(lower, "buy")))
case strings.HasPrefix(lower, "pet2buy "):
return p.petArmorBuyForSlot(ctx, strings.TrimSpace(args[8:]), 2)
case strings.HasPrefix(lower, "petbuy "): case strings.HasPrefix(lower, "petbuy "):
return p.petArmorBuyForSlot(ctx, strings.TrimSpace(args[7:]), 1) return p.handlePetArmorBuy(ctx, char, strings.TrimSpace(args[7:]))
default: default:
return p.SendDM(ctx.Sender, "Unknown command. Type `!thom` to visit Krooke Realty.") return p.SendDM(ctx.Sender, "Unknown command. Type `!thom` to visit Krooke Realty.")
@@ -688,18 +673,18 @@ func petArmorDefs(petType string) []PetArmorDef {
return petCatArmor return petCatArmor
} }
func petArmorShopView(petType string, armorTier int, buyCmd string) string { func petArmorShopView(char *AdventureCharacter) string {
var sb strings.Builder var sb strings.Builder
defs := petArmorDefs(petType) defs := petArmorDefs(char.PetType)
if armorTier >= 5 { if char.PetArmorTier >= 5 {
sb.WriteString("✨ Max pet armor tier reached. There is nothing left to buy.\n") sb.WriteString("✨ Max pet armor tier reached. There is nothing left to buy.\n")
return sb.String() return sb.String()
} }
for _, def := range defs { for _, def := range defs {
if def.Tier <= armorTier { if def.Tier <= char.PetArmorTier {
if def.Tier == armorTier { if def.Tier == char.PetArmorTier {
sb.WriteString(fmt.Sprintf("🟢 %s (T%d) — Currently equipped\n", def.Name, def.Tier)) sb.WriteString(fmt.Sprintf("🟢 %s (T%d) — Currently equipped\n", def.Name, def.Tier))
} }
continue continue
@@ -707,14 +692,11 @@ func petArmorShopView(petType string, armorTier int, buyCmd string) string {
indicator := "⬆️" indicator := "⬆️"
sb.WriteString(fmt.Sprintf("%s %s (T%d) — €%d — +%.1f%% deflect\n", indicator, def.Name, def.Tier, def.Price, def.DeflectBonus*100)) sb.WriteString(fmt.Sprintf("%s %s (T%d) — €%d — +%.1f%% deflect\n", indicator, def.Name, def.Tier, def.Price, def.DeflectBonus*100))
} }
sb.WriteString(fmt.Sprintf("\nTo buy: `!thom %s <tier>` (e.g., `!thom %s %d`)", buyCmd, buyCmd, armorTier+1)) sb.WriteString(fmt.Sprintf("\nTo buy: `!thom petbuy <tier>` (e.g., `!thom petbuy %d`)", char.PetArmorTier+1))
return sb.String() return sb.String()
} }
// petArmorBuyForSlot buys the next barding tier for the given pet slot. The func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureCharacter, tierStr string) error {
// supply-shop unlock is shared (a pet-1 mechanic), but each pet's armor tier is
// tracked and purchased independently.
func (p *AdventurePlugin) petArmorBuyForSlot(ctx MessageContext, tierStr string, slot int) error {
userMu := p.advUserLock(ctx.Sender) userMu := p.advUserLock(ctx.Sender)
userMu.Lock() userMu.Lock()
defer userMu.Unlock() defer userMu.Unlock()
@@ -724,16 +706,8 @@ func (p *AdventurePlugin) petArmorBuyForSlot(ctx MessageContext, tierStr string,
return p.SendDM(ctx.Sender, "Failed to load your character.") return p.SendDM(ctx.Sender, "Failed to load your character.")
} }
// Slot accessors keep the pet-1 and pet-2 field sets isolated. if !char.HasPet() {
hasPet, petType, petName, curTier := char.HasPet(), char.PetType, char.PetName, char.PetArmorTier return p.SendDM(ctx.Sender, "You don't have a pet.")
buyCmd := "petbuy"
if slot == 2 {
hasPet, petType, petName, curTier = char.HasPet2(), char.Pet2Type, char.Pet2Name, char.Pet2ArmorTier
buyCmd = "pet2buy"
}
if !hasPet {
return p.SendDM(ctx.Sender, "You don't have that pet.")
} }
if !char.PetSupplyShopUnlocked { if !char.PetSupplyShopUnlocked {
return p.SendDM(ctx.Sender, "Pet supplies aren't available yet.") return p.SendDM(ctx.Sender, "Pet supplies aren't available yet.")
@@ -742,19 +716,19 @@ func (p *AdventurePlugin) petArmorBuyForSlot(ctx MessageContext, tierStr string,
tier := 0 tier := 0
for _, c := range tierStr { for _, c := range tierStr {
if c < '0' || c > '9' { if c < '0' || c > '9' {
return p.SendDM(ctx.Sender, fmt.Sprintf("Usage: `!thom %s <tier>`", buyCmd)) return p.SendDM(ctx.Sender, "Usage: `!thom petbuy <tier>`")
} }
tier = tier*10 + int(c-'0') tier = tier*10 + int(c-'0')
} }
if tier != curTier+1 { if tier != char.PetArmorTier+1 {
if tier <= curTier { if tier <= char.PetArmorTier {
return p.SendDM(ctx.Sender, "That's not an upgrade.") return p.SendDM(ctx.Sender, "That's not an upgrade.")
} }
return p.SendDM(ctx.Sender, fmt.Sprintf("You need to buy tier %d first.", curTier+1)) return p.SendDM(ctx.Sender, fmt.Sprintf("You need to buy tier %d first.", char.PetArmorTier+1))
} }
defs := petArmorDefs(petType) defs := petArmorDefs(char.PetType)
var def *PetArmorDef var def *PetArmorDef
for i := range defs { for i := range defs {
if defs[i].Tier == tier { if defs[i].Tier == tier {
@@ -768,26 +742,18 @@ func (p *AdventurePlugin) petArmorBuyForSlot(ctx MessageContext, tierStr string,
balance := p.euro.GetBalance(ctx.Sender) balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(def.Price) { if balance < float64(def.Price) {
return p.SendDM(ctx.Sender, fmt.Sprintf("%s deserves better, but you can't afford €%d. Balance: €%.0f.", petName, def.Price, balance)) return p.SendDM(ctx.Sender, fmt.Sprintf("%s deserves better, but you can't afford €%d. Balance: €%.0f.", char.PetName, def.Price, balance))
} }
reason := "pet_armor_" + tierStr if !p.euro.Debit(ctx.Sender, float64(def.Price), "pet_armor_"+tierStr) {
if slot == 2 {
reason = "pet2_armor_" + tierStr
}
if !p.euro.Debit(ctx.Sender, float64(def.Price), reason) {
return p.SendDM(ctx.Sender, "Transaction failed.") return p.SendDM(ctx.Sender, "Transaction failed.")
} }
if slot == 2 {
char.Pet2ArmorTier = tier
} else {
char.PetArmorTier = tier char.PetArmorTier = tier
}
if err := saveAdvCharacter(char); err != nil { if err := saveAdvCharacter(char); err != nil {
p.euro.Credit(ctx.Sender, float64(def.Price), "pet_armor_refund") p.euro.Credit(ctx.Sender, float64(def.Price), "pet_armor_refund")
return p.SendDM(ctx.Sender, "Failed to save. Refunded.") return p.SendDM(ctx.Sender, "Failed to save. Refunded.")
} }
return p.SendDM(ctx.Sender, fmt.Sprintf("🐾 **%s** equipped on %s.\n\n+%.1f%% deflect bonus. %s looks... objectively better than you.", def.Name, petName, def.DeflectBonus*100, petName)) return p.SendDM(ctx.Sender, fmt.Sprintf("🐾 **%s** equipped on %s.\n\n+%.1f%% deflect bonus. %s looks... objectively better than you.", def.Name, char.PetName, def.DeflectBonus*100, char.PetName))
} }

View File

@@ -285,7 +285,8 @@ func TestPetArmorDefs_BothTypes(t *testing.T) {
} }
func TestPetArmorShopView_HasUpgrades(t *testing.T) { func TestPetArmorShopView_HasUpgrades(t *testing.T) {
text := petArmorShopView("dog", 0, "petbuy") char := &AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true, PetArmorTier: 0}
text := petArmorShopView(char)
if !strings.Contains(text, "⬆️") { if !strings.Contains(text, "⬆️") {
t.Error("should show upgrade indicators") t.Error("should show upgrade indicators")
@@ -296,7 +297,8 @@ func TestPetArmorShopView_HasUpgrades(t *testing.T) {
} }
func TestPetArmorShopView_MaxTier(t *testing.T) { func TestPetArmorShopView_MaxTier(t *testing.T) {
text := petArmorShopView("cat", 5, "petbuy") char := &AdventureCharacter{PetType: "cat", PetName: "Luna", PetArrived: true, PetArmorTier: 5}
text := petArmorShopView(char)
if !strings.Contains(text, "Max pet armor") { if !strings.Contains(text, "Max pet armor") {
t.Error("should show max tier message") t.Error("should show max tier message")

View File

@@ -435,8 +435,18 @@ func (p *AdventurePlugin) handleMasterworkEquipReply(ctx MessageContext, interac
return p.SendDM(ctx.Sender, "Equip cancelled.") return p.SendDM(ctx.Sender, "Equip cancelled.")
} }
idx, ok := parseMenuIndex(reply, len(data.Items)) // Parse number
if !ok { idx := 0
for _, c := range reply {
if c >= '0' && c <= '9' {
idx = idx*10 + int(c-'0')
} else {
break
}
}
idx-- // 1-indexed to 0-indexed
if idx < 0 || idx >= len(data.Items) {
return p.SendDM(ctx.Sender, "Invalid selection. Reply with a number from the list, or \"cancel\".") return p.SendDM(ctx.Sender, "Invalid selection. Reply with a number from the list, or \"cancel\".")
} }
@@ -492,33 +502,8 @@ func (p *AdventurePlugin) handleMasterworkEquipConfirm(ctx MessageContext, inter
return p.SendDM(ctx.Sender, "Equip cancelled.") return p.SendDM(ctx.Sender, "Equip cancelled.")
} }
// Serialize against the sender's own !give: MasterworkGear/ArenaGear are
// giftable (N4/E2), so the item captured at prompt time may have changed
// hands before this confirmation. The lock makes gift-vs-confirm atomic,
// and the re-load below refuses an item that is no longer ours — otherwise
// equipping the captured copy would mint a duplicate the gift already
// handed to the recipient.
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
selected := data.Item selected := data.Item
inv, err := loadAdvInventory(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your inventory.")
}
stillOwned := false
for _, it := range inv {
if it.ID == selected.ID {
stillOwned = true
break
}
}
if !stillOwned {
return p.SendDM(ctx.Sender, fmt.Sprintf("**%s** isn't in your inventory anymore. Equip cancelled.", selected.Name))
}
equip, err := loadAdvEquipment(ctx.Sender) equip, err := loadAdvEquipment(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Failed to load equipment.") return p.SendDM(ctx.Sender, "Failed to load equipment.")

View File

@@ -1,180 +0,0 @@
package plugin
import (
"strings"
"testing"
"maunium.net/go/mautrix/id"
)
// TestDerivePlayerStats_OnePetIsByteIdentical pins that a single pet still
// produces exactly the pre-two-pet mod values (the averaging code must reduce
// to identity over one element, or the combat golden moves).
func TestDerivePlayerStats_OnePetIsByteIdentical(t *testing.T) {
char := testChar(10)
char.PetType = "dog"
char.PetName = "Rex"
char.PetArrived = true
char.PetLevel = 6
char.PetArmorTier = 2
_, stats := DerivePlayerStats(char, testEquip(0), zeroBonuses(), 0, 0, false)
if stats.PetAttackProc != petAttackChance(6) {
t.Errorf("PetAttackProc = %v, want %v (identity over one pet)", stats.PetAttackProc, petAttackChance(6))
}
if stats.PetDeflectProc != petDeflectChance(6, 2) {
t.Errorf("PetDeflectProc = %v, want %v", stats.PetDeflectProc, petDeflectChance(6, 2))
}
if want := 0.01 + 6*0.005; stats.PetWhiffProc != want {
t.Errorf("PetWhiffProc = %v, want %v", stats.PetWhiffProc, want)
}
if stats.PetAttackDmg != 3+6 {
t.Errorf("PetAttackDmg = %d, want %d", stats.PetAttackDmg, 3+6)
}
}
// TestDerivePlayerStats_TwoEqualPetsMatchOne: two identical pets average to the
// same procs as one — a pair is not a stat spike.
func TestDerivePlayerStats_TwoEqualPetsMatchOne(t *testing.T) {
char := testChar(10)
char.PetType, char.PetName, char.PetArrived, char.PetLevel, char.PetArmorTier = "dog", "Rex", true, 8, 3
char.Pet2Type, char.Pet2Name, char.Pet2Arrived, char.Pet2Level, char.Pet2ArmorTier = "cat", "Luna", true, 8, 3
_, stats := DerivePlayerStats(char, testEquip(0), zeroBonuses(), 0, 0, false)
if stats.PetAttackProc != petAttackChance(8) {
t.Errorf("two L8 pets PetAttackProc = %v, want one-pet %v", stats.PetAttackProc, petAttackChance(8))
}
if stats.PetAttackDmg != 3+8 {
t.Errorf("two L8 pets PetAttackDmg = %d, want %d", stats.PetAttackDmg, 3+8)
}
}
// TestDerivePlayerStats_TwoUnequalPetsAverage: mixed levels average, never
// exceeding the stronger pet's solo contribution.
func TestDerivePlayerStats_TwoUnequalPetsAverage(t *testing.T) {
char := testChar(10)
char.PetType, char.PetArrived, char.PetLevel, char.PetArmorTier = "dog", true, 10, 0
char.Pet2Type, char.Pet2Arrived, char.Pet2Level, char.Pet2ArmorTier = "cat", true, 2, 0
_, stats := DerivePlayerStats(char, testEquip(0), zeroBonuses(), 0, 0, false)
wantAtk := (petAttackChance(10) + petAttackChance(2)) / 2
if stats.PetAttackProc != wantAtk {
t.Errorf("PetAttackProc = %v, want avg %v", stats.PetAttackProc, wantAtk)
}
// Averaged proc must sit strictly between the two pets' solo values.
if !(stats.PetAttackProc < petAttackChance(10) && stats.PetAttackProc > petAttackChance(2)) {
t.Errorf("averaged proc %v not between %v and %v", stats.PetAttackProc, petAttackChance(2), petAttackChance(10))
}
if want := 3 + (10+2+1)/2; stats.PetAttackDmg != want { // rounded avg level = 6
t.Errorf("PetAttackDmg = %d, want %d", stats.PetAttackDmg, want)
}
}
// TestDerivePlayerStats_Pet2OnlyStillContributes: if the first pet is chased
// away but the second remains, combat still sees the second pet.
func TestDerivePlayerStats_Pet2OnlyStillContributes(t *testing.T) {
char := testChar(10)
char.PetType, char.PetArrived, char.PetChasedAway, char.PetLevel = "dog", true, true, 5
char.Pet2Type, char.Pet2Arrived, char.Pet2Level = "cat", true, 4
_, stats := DerivePlayerStats(char, testEquip(0), zeroBonuses(), 0, 0, false)
if !char.HasPet() && stats.PetAttackProc != petAttackChance(4) {
t.Errorf("pet2-only PetAttackProc = %v, want %v", stats.PetAttackProc, petAttackChance(4))
}
}
// TestDerivePlayerStats_NoPetsZero: the golden's own case — no pets, no procs.
func TestDerivePlayerStats_NoPetsZero(t *testing.T) {
_, stats := DerivePlayerStats(testChar(10), testEquip(0), zeroBonuses(), 0, 0, false)
if stats.PetAttackProc != 0 || stats.PetDeflectProc != 0 || stats.PetWhiffProc != 0 || stats.PetAttackDmg != 0 {
t.Errorf("petless character got pet mods: %+v", stats)
}
}
func TestPetShouldArrive2_Gating(t *testing.T) {
pet1 := PetState{Type: "dog", Arrived: true}
tier4 := HouseState{Tier: 4}
tier3 := HouseState{Tier: 3}
if petShouldArrive2(pet1, PetState{}, tier3) {
t.Error("second pet must not arrive below Tier 4")
}
if petShouldArrive2(PetState{}, PetState{}, tier4) {
t.Error("second pet needs an established first pet")
}
if petShouldArrive2(pet1, PetState{Arrived: true}, tier4) {
t.Error("already-arrived second pet must not re-roll")
}
if petShouldArrive2(pet1, PetState{ChasedAway: true}, tier4) {
t.Error("chased-away second pet does not re-arrive")
}
}
// TestPet2StoreRoundTrip exercises the pet2_* columns and the AdvChar mirror.
func TestPet2StoreRoundTrip(t *testing.T) {
townTestDB(t)
user := id.UserID("@pet2:test.invalid")
char := &AdventureCharacter{UserID: user}
char.PetType, char.PetName, char.PetArrived, char.PetLevel = "dog", "Rex", true, 3
char.Pet2Type, char.Pet2Name, char.Pet2Arrived, char.Pet2Level, char.Pet2ArmorTier = "cat", "Luna", true, 5, 2
if err := saveAdvCharacter(char); err != nil {
t.Fatal(err)
}
got, err := loadAdvCharacter(user)
if err != nil || got == nil {
t.Fatalf("reload: %v", err)
}
if !got.HasPet2() || got.Pet2Name != "Luna" || got.Pet2Level != 5 || got.Pet2ArmorTier != 2 || got.Pet2Type != "cat" {
t.Fatalf("pet2 did not round-trip: %+v", got)
}
if !got.HasPet() || got.PetName != "Rex" {
t.Fatalf("pet1 clobbered by pet2 write: %+v", got)
}
// Direct slot loader agrees.
p2, _ := loadPet2State(user)
if p2.Name != "Luna" || p2.Level != 5 {
t.Fatalf("loadPet2State mismatch: %+v", p2)
}
}
// TestPetShowcase_IncludesBothSlots verifies the town showcase surfaces second
// pets and hides a chased-away one.
func TestPetShowcase_IncludesBothSlots(t *testing.T) {
townTestDB(t)
a := &AdventureCharacter{UserID: id.UserID("@a:test.invalid")}
a.PetType, a.PetName, a.PetArrived, a.PetLevel = "dog", "Rex", true, 4
a.Pet2Type, a.Pet2Name, a.Pet2Arrived, a.Pet2Level = "cat", "Luna", true, 9
if err := saveAdvCharacter(a); err != nil {
t.Fatal(err)
}
b := &AdventureCharacter{UserID: id.UserID("@b:test.invalid")}
b.Pet2Type, b.Pet2Name, b.Pet2Arrived, b.Pet2Level, b.Pet2ChasedAway = "dog", "Ghost", true, 7, true
if err := saveAdvCharacter(b); err != nil {
t.Fatal(err)
}
pets, err := loadPetShowcase(townListCap)
if err != nil {
t.Fatal(err)
}
if len(pets) != 2 {
t.Fatalf("want 2 active pets (Rex + Luna), got %d: %+v", len(pets), pets)
}
// Sorted by level desc → Luna (9) before Rex (4).
if !strings.Contains(pets[0].Name, "Luna") || !strings.Contains(pets[1].Name, "Rex") {
t.Fatalf("showcase not level-ordered across slots: %+v", pets)
}
for _, p := range pets {
if strings.Contains(p.Name, "Ghost") {
t.Errorf("chased-away second pet should be hidden: %+v", p)
}
}
}

View File

@@ -31,37 +31,28 @@ func petXPToNextLevel(level int) int {
} }
} }
// petGrantXP adds a per-action XP grant to the pet and handles level-ups. // petGrantXP adds XP to the pet and handles level-ups. Returns true if leveled up.
// Returns true if leveled up. Shares the level-up loop with the babysit trickle
// via advancePetLevelsFromXP.
func petGrantXP(pet *PetState) bool { func petGrantXP(pet *PetState) bool {
if !pet.HasPet() { if !pet.HasPet() || pet.Level >= 10 {
return false return false
} }
return advancePetLevelsFromXP(&pet.XP, &pet.Level, &pet.Level10Date, int(petXPPerAction*100))
}
// advancePetLevelsFromXP adds centi-XP to a pet and applies any level-ups, up pet.XP += int(petXPPerAction * 100) // store as centixp for precision
// to the level-10 cap, stamping the level-10 date on first reaching it. Shared
// by both pet slots (the babysit trickle). Returns true if the pet leveled.
func advancePetLevelsFromXP(xp, level *int, level10Date *string, addCentiXP int) bool {
if *level >= 10 {
return false
}
*xp += addCentiXP
leveled := false leveled := false
for *level < 10 { for pet.Level < 10 {
needed := petXPToNextLevel(*level) * 100 needed := petXPToNextLevel(pet.Level) * 100
if *xp < needed { if pet.XP < needed {
break break
} }
*xp -= needed pet.XP -= needed
*level++ pet.Level++
leveled = true leveled = true
} }
if *level >= 10 && *level10Date == "" {
*level10Date = time.Now().UTC().Format("2006-01-02") if pet.Level >= 10 && pet.Level10Date == "" {
pet.Level10Date = time.Now().UTC().Format("2006-01-02")
} }
return leveled return leveled
} }
@@ -215,23 +206,6 @@ func petShouldArrive(pet PetState, house HouseState) bool {
return rand.Float64() < 0.30 return rand.Float64() < 0.30
} }
// petShouldArrive2 gates the SECOND companion (N4/E1). It needs a Tier-4 Estate
// and an established first pet — the second animal wanders in to join the first,
// not an empty house. A chased-away second pet does not re-arrive (Misty's
// reactivation is a pet-1 mechanic).
func petShouldArrive2(pet1, pet2 PetState, house HouseState) bool {
if pet2.Arrived || pet2.ChasedAway {
return false
}
if house.Tier < 4 {
return false
}
if !pet1.HasPet() {
return false
}
return rand.Float64() < 0.30
}
// maybeRollPetArrivalOnEmerge rolls the pet-arrival check when a player // maybeRollPetArrivalOnEmerge rolls the pet-arrival check when a player
// surfaces from an expedition (voluntary extract, abandon, or a survived // surfaces from an expedition (voluntary extract, abandon, or a survived
// forced extraction) or revives after death. The arrival roll lives on the // forced extraction) or revives after death. The arrival roll lives on the
@@ -247,52 +221,32 @@ func (p *AdventurePlugin) maybeRollPetArrivalOnEmerge(userID id.UserID) {
pet, _ := loadPetState(userID) pet, _ := loadPetState(userID)
house, _ := loadHouseState(userID) house, _ := loadHouseState(userID)
if petShouldArrive(pet, house) { if petShouldArrive(pet, house) {
p.petArrivalDM(userID, 1) p.petArrivalDM(userID)
return
}
// A Tier-4 Estate can draw a second companion once the first is settled.
pet2, _ := loadPet2State(userID)
if petShouldArrive2(pet, pet2, house) {
p.petArrivalDM(userID, 2)
} }
} }
// petArrivalDM sends the initial "there's an animal in your house" DM for the // petArrivalDM sends the initial "there's an animal in your house" DM.
// given slot (1 or 2). func (p *AdventurePlugin) petArrivalDM(userID id.UserID) {
func (p *AdventurePlugin) petArrivalDM(userID id.UserID, slot int) {
// Don't overwrite an existing pending interaction // Don't overwrite an existing pending interaction
if _, exists := p.pending.Load(string(userID)); exists { if _, exists := p.pending.Load(string(userID)); exists {
return return
} }
intro := "There's an animal in your house. It looks like a..." text := "There's an animal in your house. It looks like a...\n\n" +
if slot == 2 {
intro = "There's *another* animal in your house. Your first pet seems unbothered. It looks like a..."
}
text := intro + "\n\n" +
"🚪 Chase it away\n" + "🚪 Chase it away\n" +
"🍖 Feed it\n\n" + "🍖 Feed it\n\n" +
"Reply with `chase` or `feed`." "Reply with `chase` or `feed`."
p.pending.Store(string(userID), &advPendingInteraction{ p.pending.Store(string(userID), &advPendingInteraction{
Type: "pet_arrival", Type: "pet_arrival",
Data: &advPendingPetArrival{Slot: slot}, Data: &advPendingPetArrival{},
ExpiresAt: time.Now().Add(advDMResponseWindowLow), ExpiresAt: time.Now().Add(advDMResponseWindowLow),
}) })
_ = p.SendDM(userID, text) _ = p.SendDM(userID, text)
} }
// petSlotFromData reads a Slot off any pending-pet payload, defaulting to slot 1
// for the zero value so older/first-pet interactions are unaffected.
func petSlotFromData(slot int) int {
if slot == 2 {
return 2
}
return 1
}
// resolvePetArrival handles the chase/feed response. // resolvePetArrival handles the chase/feed response.
func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext, interaction *advPendingInteraction) error { func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender) userMu := p.advUserLock(ctx.Sender)
userMu.Lock() userMu.Lock()
defer userMu.Unlock() defer userMu.Unlock()
@@ -302,18 +256,13 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext, interaction *adv
return p.SendDM(ctx.Sender, "Failed to load your character.") return p.SendDM(ctx.Sender, "Failed to load your character.")
} }
slot := petSlotFromData(interaction.Data.(*advPendingPetArrival).Slot)
reply := strings.ToLower(strings.TrimSpace(ctx.Body)) reply := strings.ToLower(strings.TrimSpace(ctx.Body))
if reply == "chase" || reply == "🚪" { if reply == "chase" || reply == "🚪" {
if slot == 2 {
char.Pet2ChasedAway = true
char.Pet2Reactivated = false
} else {
char.PetChasedAway = true char.PetChasedAway = true
char.PetReactivated = false char.PetReactivated = false
}
_ = saveAdvCharacter(char) _ = saveAdvCharacter(char)
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char))
return p.SendDM(ctx.Sender, "You chased it away. It disappeared around the corner and didn't come back.\n\nThe house is quiet again.") return p.SendDM(ctx.Sender, "You chased it away. It disappeared around the corner and didn't come back.\n\nThe house is quiet again.")
} }
@@ -327,7 +276,7 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext, interaction *adv
p.pending.Store(string(ctx.Sender), &advPendingInteraction{ p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "pet_type", Type: "pet_type",
Data: &advPendingPetType{Slot: slot}, Data: &advPendingPetType{},
ExpiresAt: time.Now().Add(advDMResponseWindowLow), ExpiresAt: time.Now().Add(advDMResponseWindowLow),
}) })
return p.SendDM(ctx.Sender, text) return p.SendDM(ctx.Sender, text)
@@ -336,19 +285,18 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext, interaction *adv
// Invalid response — re-prompt // Invalid response — re-prompt
p.pending.Store(string(ctx.Sender), &advPendingInteraction{ p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "pet_arrival", Type: "pet_arrival",
Data: &advPendingPetArrival{Slot: slot}, Data: &advPendingPetArrival{},
ExpiresAt: time.Now().Add(advDMResponseWindowLow), ExpiresAt: time.Now().Add(advDMResponseWindowLow),
}) })
return p.SendDM(ctx.Sender, "Reply with `chase` or `feed`.") return p.SendDM(ctx.Sender, "Reply with `chase` or `feed`.")
} }
// resolvePetType handles dog/cat selection. // resolvePetType handles dog/cat selection.
func (p *AdventurePlugin) resolvePetType(ctx MessageContext, interaction *advPendingInteraction) error { func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender) userMu := p.advUserLock(ctx.Sender)
userMu.Lock() userMu.Lock()
defer userMu.Unlock() defer userMu.Unlock()
slot := petSlotFromData(interaction.Data.(*advPendingPetType).Slot)
reply := strings.ToLower(strings.TrimSpace(ctx.Body)) reply := strings.ToLower(strings.TrimSpace(ctx.Body))
petType := "" petType := ""
@@ -361,7 +309,7 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext, interaction *advPen
if petType == "" { if petType == "" {
p.pending.Store(string(ctx.Sender), &advPendingInteraction{ p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "pet_type", Type: "pet_type",
Data: &advPendingPetType{Slot: slot}, Data: &advPendingPetType{},
ExpiresAt: time.Now().Add(advDMResponseWindowLow), ExpiresAt: time.Now().Add(advDMResponseWindowLow),
}) })
return p.SendDM(ctx.Sender, "Reply with `dog` or `cat`.") return p.SendDM(ctx.Sender, "Reply with `dog` or `cat`.")
@@ -369,7 +317,7 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext, interaction *advPen
p.pending.Store(string(ctx.Sender), &advPendingInteraction{ p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "pet_name", Type: "pet_name",
Data: &advPendingPetName{PetType: petType, Slot: slot}, Data: &advPendingPetName{PetType: petType},
ExpiresAt: time.Now().Add(advDMResponseWindowLow), ExpiresAt: time.Now().Add(advDMResponseWindowLow),
}) })
@@ -407,36 +355,24 @@ func (p *AdventurePlugin) resolvePetName(ctx MessageContext, interaction *advPen
return p.SendDM(ctx.Sender, "Name can only contain letters, numbers, spaces, hyphens, and apostrophes. Try again.") return p.SendDM(ctx.Sender, "Name can only contain letters, numbers, spaces, hyphens, and apostrophes. Try again.")
} }
if petSlotFromData(data.Slot) == 2 {
char.Pet2Type = data.PetType
char.Pet2Name = name
char.Pet2Arrived = true
char.Pet2ChasedAway = false
char.Pet2Level = 1
char.Pet2XP = 0
} else {
char.PetType = data.PetType char.PetType = data.PetType
char.PetName = name char.PetName = name
char.PetArrived = true char.PetArrived = true
char.PetChasedAway = false char.PetChasedAway = false
char.PetLevel = 1 char.PetLevel = 1
char.PetXP = 0 char.PetXP = 0
}
if err := saveAdvCharacter(char); err != nil { if err := saveAdvCharacter(char); err != nil {
return p.SendDM(ctx.Sender, "Failed to save.") return p.SendDM(ctx.Sender, "Failed to save.")
} }
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char))
emoji := "🐶" emoji := "🐶"
if data.PetType == "cat" { if data.PetType == "cat" {
emoji = "🐱" emoji = "🐱"
} }
tail := fmt.Sprintf("%s will join you in combat. %s will not explain their decisions.", name, name) return p.SendDM(ctx.Sender, fmt.Sprintf("%s **%s** has moved in.\n\nBreed: Massive %s.\nLevel: 1\n\n%s will join you in combat. %s will not explain their decisions.",
if petSlotFromData(data.Slot) == 2 { emoji, name, titleCase(data.PetType), name, name))
tail = fmt.Sprintf("%s fights alongside your first companion — the two share the spotlight, so together they're about as much help in a scrap as one seasoned pet. %s will not explain their decisions.", name, name)
}
return p.SendDM(ctx.Sender, fmt.Sprintf("%s **%s** has moved in.\n\nBreed: Massive %s.\nLevel: 1\n\n%s",
emoji, name, titleCase(data.PetType), tail))
} }
// ── Morning Pet Events ───────────────────────────────────────────────────── // ── Morning Pet Events ─────────────────────────────────────────────────────

View File

@@ -1037,17 +1037,8 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
// ── Treasure Discard Prompt ────────────────────────────────────────────────── // ── Treasure Discard Prompt ──────────────────────────────────────────────────
func renderAdvTreasureDiscardPrompt(newTreasure *AdvTreasureDef, existing []AdvTreasureDef) string { func renderAdvTreasureDiscardPrompt(newTreasure *AdvTreasureDef, existing []AdvTreasureDef) string {
// The flavor templates below are written for the base 3-slot set. With a if len(TreasureInventoryCap) == 0 {
// T3 trophy room the set can hold 4, so fall back to a plain dynamic list return "You found a treasure but your inventory is full. Reply 1, 2, or 3 to discard, or `keep`."
// whenever it isn't exactly 3 — every slot has to be visible to choose it.
if len(TreasureInventoryCap) == 0 || len(existing) != 3 {
var b strings.Builder
fmt.Fprintf(&b, "You found **%s** but your treasure slots are full. Discard one to make room:\n", newTreasure.Name)
for i, ex := range existing {
fmt.Fprintf(&b, "%d. %s — %s\n", i+1, ex.Name, ex.InventoryDesc)
}
fmt.Fprintf(&b, "\nReply with the number to discard, or `keep` to leave the new one behind.")
return b.String()
} }
// Build substitution map with existing treasure info // Build substitution map with existing treasure info

View File

@@ -140,12 +140,8 @@ func findTemperMaterial(items []AdvItem) (AdvItem, bool) {
return AdvItem{}, false return AdvItem{}, false
} }
// parseMenuIndex reads a leading 1-indexed number off a menu reply and returns // parseTemperIndex reads a leading 1-indexed number and bounds-checks it.
// the bounds-checked 0-indexed position. The single parser behind every "reply func parseTemperIndex(reply string, n int) (int, bool) {
// with a number from the list" prompt — temper, magic-item equip, masterwork
// equip. Leading whitespace and any trailing text after the digits are ignored;
// a reply with no leading digit, or one outside [1, n], returns ok=false.
func parseMenuIndex(reply string, n int) (int, bool) {
idx, parsed := 0, false idx, parsed := 0, false
for _, c := range strings.TrimSpace(reply) { for _, c := range strings.TrimSpace(reply) {
if c < '0' || c > '9' { if c < '0' || c > '9' {
@@ -218,7 +214,7 @@ func (p *AdventurePlugin) resolveTemperPick(ctx MessageContext, interaction *adv
if strings.EqualFold(reply, "cancel") { if strings.EqualFold(reply, "cancel") {
return p.SendDM(ctx.Sender, "_The forge keeps burning._ Come back when you've decided.") return p.SendDM(ctx.Sender, "_The forge keeps burning._ Come back when you've decided.")
} }
idx, ok := parseMenuIndex(reply, len(data.Targets)) idx, ok := parseTemperIndex(reply, len(data.Targets))
if !ok { if !ok {
p.pending.Store(string(ctx.Sender), interaction) p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, "Reply with a number from the list, or \"cancel\".") return p.SendDM(ctx.Sender, "Reply with a number from the list, or \"cancel\".")

View File

@@ -346,9 +346,9 @@ func TestParseTemperIndex(t *testing.T) {
{"cancel", 3, 0, false}, {"cancel", 3, 0, false},
} }
for _, tc := range tests { for _, tc := range tests {
got, ok := parseMenuIndex(tc.in, tc.n) got, ok := parseTemperIndex(tc.in, tc.n)
if ok != tc.wantOK || (ok && got != tc.want) { if ok != tc.wantOK || (ok && got != tc.want) {
t.Errorf("parseMenuIndex(%q, %d) = (%d, %v), want (%d, %v)", t.Errorf("parseTemperIndex(%q, %d) = (%d, %v), want (%d, %v)",
tc.in, tc.n, got, ok, tc.want, tc.wantOK) tc.in, tc.n, got, ok, tc.want, tc.wantOK)
} }
} }

View File

@@ -1,487 +0,0 @@
package plugin
import (
"database/sql"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"gogobee/internal/db"
)
// E3 — Surfacing the buried social data. Three read-only registries that
// render data the game already stores: !town (civic pride + housing + pets),
// !graveyard (recent deaths), and !rivals board (room-wide duel standings).
//
// Leak-check (gogobee_engagement_plan.md §E3): the civic-pride board ranks
// tax_ledger.total_paid, which is dominated by gambling/shop/arena rake and
// carries ZERO Misty/Arina donation signal — those debits never touch
// tax_ledger (adventure_npcs.go routes them through euro.Debit with their own
// reason strings, counters live in player_meta.misty_*/arina_* columns). So no
// board here can let a player correlate donations with the hidden NPC buffs.
const (
// townCivicBoardLimit caps the civic-pride board.
townCivicBoardLimit = 10
// townListCap caps the housing/pet showcase lists.
townListCap = 15
// graveyardLimit caps the graveyard.
graveyardLimit = 12
// rivalsBoardLimit caps the room-wide rivalry standings.
rivalsBoardLimit = 10
)
// ── Civic pride ──────────────────────────────────────────────────────────────
type civicEntry struct {
Name string
TotalPaid int64
}
// loadCivicPrideBoard returns the top contributors to the community pot by
// lifetime tax paid. Safe to rank — see the leak-check note above.
func loadCivicPrideBoard(limit int) ([]civicEntry, error) {
rows, err := db.Get().Query(
`SELECT COALESCE(NULLIF(c.display_name, ''), t.user_id), t.total_paid
FROM tax_ledger t
LEFT JOIN player_meta c ON c.user_id = t.user_id
WHERE t.total_paid > 0
ORDER BY t.total_paid DESC
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []civicEntry
for rows.Next() {
var e civicEntry
if err := rows.Scan(&e.Name, &e.TotalPaid); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// ── Housing street ───────────────────────────────────────────────────────────
type housingEntry struct {
Name string
Tier int
}
// loadHousingStreet returns everyone who owns a house, best homes first.
func loadHousingStreet(limit int) ([]housingEntry, error) {
rows, err := db.Get().Query(
`SELECT COALESCE(NULLIF(display_name, ''), user_id), house_tier
FROM player_meta
WHERE house_tier > 0
ORDER BY house_tier DESC, display_name
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []housingEntry
for rows.Next() {
var e housingEntry
if err := rows.Scan(&e.Name, &e.Tier); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// ── Pet showcase ─────────────────────────────────────────────────────────────
type petShowcaseEntry struct {
Name string
Type string
Level int
ArmorName string
}
// loadPetShowcase returns every active pet — both slots — across all players,
// highest level first. The arrived/chased flags live in the *_flags_json
// columns, so they're decoded in Go rather than in SQL.
func loadPetShowcase(limit int) ([]petShowcaseEntry, error) {
rows, err := db.Get().Query(
`SELECT COALESCE(NULLIF(display_name, ''), user_id),
pet_name, pet_type, pet_level, pet_armor_tier, pet_flags_json,
pet2_name, pet2_type, pet2_level, pet2_armor_tier, pet2_flags_json
FROM player_meta
WHERE pet_type != '' OR pet2_type != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []petShowcaseEntry
for rows.Next() {
var (
owner string
petName, petType, flagsRaw string
level, armorTier int
pet2Name, pet2Type, flags2Raw string
pet2Level, pet2Armor int
)
if err := rows.Scan(&owner, &petName, &petType, &level, &armorTier, &flagsRaw,
&pet2Name, &pet2Type, &pet2Level, &pet2Armor, &flags2Raw); err != nil {
return nil, err
}
if e, ok := showcaseEntryFor(owner, petName, petType, level, armorTier, flagsRaw); ok {
out = append(out, e)
}
if e, ok := showcaseEntryFor(owner, pet2Name, pet2Type, pet2Level, pet2Armor, flags2Raw); ok {
out = append(out, e)
}
}
if err := rows.Err(); err != nil {
return nil, err
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Level != out[j].Level {
return out[i].Level > out[j].Level
}
return out[i].Name < out[j].Name
})
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
// showcaseEntryFor builds a showcase entry for one pet slot, returning ok=false
// if the slot is empty or its pet was chased away.
func showcaseEntryFor(owner, petName, petType string, level, armorTier int, flagsRaw string) (petShowcaseEntry, bool) {
if petType == "" {
return petShowcaseEntry{}, false
}
var flags petFlagsJSON
if flagsRaw != "" {
_ = json.Unmarshal([]byte(flagsRaw), &flags)
}
if !flags.Arrived || flags.ChasedAway {
return petShowcaseEntry{}, false
}
return petShowcaseEntry{
Name: firstNonEmpty(petName, "an unnamed companion") + " (" + owner + ")",
Type: petType,
Level: level,
ArmorName: petArmorName(petType, armorTier),
}, true
}
// petArmorName maps a pet's armor tier to its barding name, or "" for none.
func petArmorName(petType string, tier int) string {
if tier <= 0 {
return ""
}
for _, a := range petArmorDefs(petType) {
if a.Tier == tier {
return a.Name
}
}
return ""
}
// ── Graveyard ────────────────────────────────────────────────────────────────
type graveEntry struct {
Name string
Source string // "adventure" | "arena" | ""
Location string
LastDeathDate string // "2006-01-02"
StillDown bool // alive == 0 (inside the 6h respawn window)
}
// loadGraveyard returns the most recent deaths across all players. Deaths are
// day-granularity (last_death_date), so ordering falls back to dead_until for
// the still-in-the-ground; a revived player keeps their last_death_date but
// clears dead_until.
func loadGraveyard(limit int) ([]graveEntry, error) {
rows, err := db.Get().Query(
`SELECT COALESCE(NULLIF(display_name, ''), user_id),
alive, death_source, death_location, last_death_date, dead_until
FROM player_meta
WHERE last_death_date != ''
ORDER BY last_death_date DESC, dead_until DESC
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []graveEntry
for rows.Next() {
var (
e graveEntry
aliveInt int
deadUntil sql.NullTime
)
if err := rows.Scan(&e.Name, &aliveInt, &e.Source, &e.Location, &e.LastDeathDate, &deadUntil); err != nil {
return nil, err
}
e.StillDown = aliveInt == 0
out = append(out, e)
}
return out, rows.Err()
}
// ── Rivals board ─────────────────────────────────────────────────────────────
type rivalBoardEntry struct {
Name string
Wins int
Losses int
LastDuelAt *time.Time
}
// loadRivalsBoard aggregates adventure_rival_records into room-wide standings.
// The table is a directed per-pair ledger written from both duellists' sides,
// so summing a user_id's rows gives that player's total record.
func loadRivalsBoard(limit int) ([]rivalBoardEntry, error) {
rows, err := db.Get().Query(
`SELECT COALESCE(NULLIF(c.display_name, ''), r.user_id),
SUM(r.wins), SUM(r.losses), MAX(r.last_duel_at)
FROM adventure_rival_records r
LEFT JOIN player_meta c ON c.user_id = r.user_id
GROUP BY r.user_id
ORDER BY SUM(r.wins) DESC, SUM(r.losses) ASC
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []rivalBoardEntry
for rows.Next() {
var (
e rivalBoardEntry
lastDuel sql.NullString
)
if err := rows.Scan(&e.Name, &e.Wins, &e.Losses, &lastDuel); err != nil {
return nil, err
}
// MAX() over a DATETIME column loses SQLite's type affinity and comes
// back as text, so parse it by hand rather than scanning a NullTime.
if lastDuel.Valid {
if t, ok := parseSQLiteTime(lastDuel.String); ok {
e.LastDuelAt = &t
}
}
out = append(out, e)
}
return out, rows.Err()
}
// ── Rendering (client-free, unit-testable) ───────────────────────────────────
func renderTownRegistry(civic []civicEntry, houses []housingEntry, pets []petShowcaseEntry) string {
var sb strings.Builder
sb.WriteString("🏛️ **The Town Registry**\n\n")
sb.WriteString("**Civic Pride** — the guild's most generous coffers-fillers\n")
if len(civic) == 0 {
sb.WriteString(" _The collection plate is empty. Tragic._\n")
} else {
for i, e := range civic {
sb.WriteString(fmt.Sprintf(" %2d. %-18s %s\n", i+1, truncName(e.Name, 18), fmtEuro(e.TotalPaid)))
}
}
sb.WriteString("\n")
sb.WriteString("**Housing Street** — who's put down roots\n")
if len(houses) == 0 {
sb.WriteString(" _Not a single deed on file. A town of drifters._\n")
} else {
for _, e := range houses {
tierName := "a house"
if def := houseTierByTier(e.Tier); def != nil {
tierName = def.Name
}
sb.WriteString(fmt.Sprintf(" %-18s %s\n", truncName(e.Name, 18), tierName))
}
}
sb.WriteString("\n")
sb.WriteString("**Pet Showcase** — the good companions\n")
if len(pets) == 0 {
sb.WriteString(" _No pets in town. Somebody adopt something._\n")
} else {
for _, e := range pets {
line := fmt.Sprintf(" %-30s L%d %s", truncName(e.Name, 30), e.Level, e.Type)
if e.ArmorName != "" {
line += " · " + e.ArmorName
}
sb.WriteString(line + "\n")
}
}
return strings.TrimRight(sb.String(), "\n")
}
func renderGraveyard(deaths []graveEntry, now time.Time) string {
var sb strings.Builder
sb.WriteString("⚰️ **St. Guildmore's Memorial Garden**\n")
sb.WriteString("_The groundskeeper tips his hat. \"They gave their all. Well — they gave enough.\"_\n\n")
if len(deaths) == 0 {
sb.WriteString("_Nobody's died lately. The garden's quiet. Enjoy it while it lasts._")
return sb.String()
}
for _, e := range deaths {
place := e.Location
if place == "" {
place = "parts unknown"
}
verb := "fell"
if e.Source == "arena" {
verb = "fell in the arena"
}
when := humanizeDeathDate(e.LastDeathDate, now)
line := fmt.Sprintf(" ⚰️ **%s** — %s at %s (%s)", truncName(e.Name, 24), verb, place, when)
if e.StillDown {
line += " · _still resting_"
} else {
line += " · _back on their feet_"
}
sb.WriteString(line + "\n")
}
return strings.TrimRight(sb.String(), "\n")
}
func renderRivalsBoard(board []rivalBoardEntry, now time.Time) string {
var sb strings.Builder
sb.WriteString("⚔️ **Rivalry Standings** — duels across the guild\n\n")
if len(board) == 0 {
sb.WriteString("_No duels on record. A suspiciously peaceful town._")
return sb.String()
}
for i, e := range board {
last := "—"
if e.LastDuelAt != nil {
last = humanizeDaysAgo(*e.LastDuelAt, now)
}
sb.WriteString(fmt.Sprintf(" %2d. %-18s %dW - %dL last duel: %s\n",
i+1, truncName(e.Name, 18), e.Wins, e.Losses, last))
}
return strings.TrimRight(sb.String(), "\n")
}
// ── Small render helpers ─────────────────────────────────────────────────────
func truncName(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
if max <= 1 {
return string(r[:max])
}
return string(r[:max-1]) + "…"
}
func firstNonEmpty(s, fallback string) string {
if strings.TrimSpace(s) == "" {
return fallback
}
return s
}
// parseSQLiteTime parses a SQLite CURRENT_TIMESTAMP string ("2006-01-02
// 15:04:05", UTC), tolerating an RFC3339 variant. Aggregates like MAX() strip
// the column's type affinity so the driver hands back text.
func parseSQLiteTime(s string) (time.Time, bool) {
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC(), true
}
}
return time.Time{}, false
}
// humanizeDaysAgo renders a duel/event time as "today" / "1 day ago" / "N days
// ago", matching the inline style in handleRivalsCmd.
func humanizeDaysAgo(t, now time.Time) string {
days := int(now.Sub(t).Hours() / 24)
switch {
case days <= 0:
return "today"
case days == 1:
return "1 day ago"
default:
return fmt.Sprintf("%d days ago", days)
}
}
// humanizeDeathDate renders a "2006-01-02" death date relative to now. Falls
// back to the raw string if it doesn't parse.
func humanizeDeathDate(dateStr string, now time.Time) string {
t, err := time.Parse("2006-01-02", dateStr)
if err != nil {
if dateStr == "" {
return "some time ago"
}
return dateStr
}
days := int(now.UTC().Truncate(24*time.Hour).Sub(t).Hours() / 24)
switch {
case days <= 0:
return "today"
case days == 1:
return "yesterday"
default:
return fmt.Sprintf("%d days ago", days)
}
}
// ── Handlers ─────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleTownCmd(ctx MessageContext) error {
civic, err := loadCivicPrideBoard(townCivicBoardLimit)
if err != nil {
return err
}
houses, err := loadHousingStreet(townListCap)
if err != nil {
return err
}
pets, err := loadPetShowcase(townListCap)
if err != nil {
return err
}
return p.SendReply(ctx.RoomID, ctx.EventID, renderTownRegistry(civic, houses, pets))
}
func (p *AdventurePlugin) handleGraveyardCmd(ctx MessageContext) error {
deaths, err := loadGraveyard(graveyardLimit)
if err != nil {
return err
}
return p.SendReply(ctx.RoomID, ctx.EventID, renderGraveyard(deaths, time.Now().UTC()))
}
// handleRivalsTopCmd routes the top-level !rivals command: "!rivals board" is
// the room-wide standings; anything else defers to the per-user record view.
func (p *AdventurePlugin) handleRivalsTopCmd(ctx MessageContext, args string) error {
if strings.EqualFold(strings.TrimSpace(args), "board") {
board, err := loadRivalsBoard(rivalsBoardLimit)
if err != nil {
return err
}
return p.SendReply(ctx.RoomID, ctx.EventID, renderRivalsBoard(board, time.Now().UTC()))
}
return p.handleRivalsCmd(ctx)
}

View File

@@ -1,234 +0,0 @@
package plugin
import (
"strings"
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func townTestDB(t *testing.T) {
t.Helper()
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
}
// TestTownLoaders_ExerciseRealSchema seeds each source table and runs every
// loader against a real DB, catching column typos / GROUP BY errors that the
// pure render tests can't.
func TestTownLoaders_ExerciseRealSchema(t *testing.T) {
townTestDB(t)
alice := id.UserID("@alice:test.invalid")
bob := id.UserID("@bob:test.invalid")
if err := upsertPlayerMetaDisplayName(alice, "Alice"); err != nil {
t.Fatal(err)
}
if err := upsertPlayerMetaDisplayName(bob, "Bob"); err != nil {
t.Fatal(err)
}
// Civic pride.
trackTaxPaid(alice, 5000)
trackTaxPaid(bob, 100)
civic, err := loadCivicPrideBoard(townCivicBoardLimit)
if err != nil {
t.Fatalf("loadCivicPrideBoard: %v", err)
}
if len(civic) != 2 || civic[0].Name != "Alice" || civic[0].TotalPaid != 5000 {
t.Fatalf("civic board wrong: %+v", civic)
}
// Housing street.
if err := upsertPlayerMetaHouseState(alice, HouseState{Tier: 4}); err != nil {
t.Fatal(err)
}
if err := upsertPlayerMetaHouseState(bob, HouseState{Tier: 1}); err != nil {
t.Fatal(err)
}
houses, err := loadHousingStreet(townListCap)
if err != nil {
t.Fatalf("loadHousingStreet: %v", err)
}
if len(houses) != 2 || houses[0].Tier != 4 {
t.Fatalf("housing street wrong: %+v", houses)
}
// Pet showcase — only arrived, non-chased pets appear.
if err := upsertPlayerMetaPetState(alice, PetState{Type: "dog", Name: "Rex", Level: 5, ArmorTier: 2, Arrived: true}); err != nil {
t.Fatal(err)
}
if err := upsertPlayerMetaPetState(bob, PetState{Type: "cat", Name: "Ghost", Level: 3, Arrived: true, ChasedAway: true}); err != nil {
t.Fatal(err)
}
pets, err := loadPetShowcase(townListCap)
if err != nil {
t.Fatalf("loadPetShowcase: %v", err)
}
if len(pets) != 1 || !strings.Contains(pets[0].Name, "Rex") || pets[0].ArmorName != "Chain Dog Barding" {
t.Fatalf("pet showcase wrong (chased-away pet should be hidden): %+v", pets)
}
// Graveyard.
deadUntil := time.Now().Add(6 * time.Hour).UTC()
if err := upsertPlayerMetaDeathState(alice, DeathState{
Alive: false, DeadUntil: &deadUntil, LastDeathDate: "2026-07-10",
DeathSource: "arena", DeathLocation: "the Colosseum",
}); err != nil {
t.Fatal(err)
}
graves, err := loadGraveyard(graveyardLimit)
if err != nil {
t.Fatalf("loadGraveyard: %v", err)
}
if len(graves) != 1 || graves[0].Name != "Alice" || !graves[0].StillDown || graves[0].Source != "arena" {
t.Fatalf("graveyard wrong: %+v", graves)
}
// Rivals board — directed pair ledger summed per user.
upsertRivalRecord(alice, bob, true)
upsertRivalRecord(alice, bob, true)
upsertRivalRecord(bob, alice, false)
board, err := loadRivalsBoard(rivalsBoardLimit)
if err != nil {
t.Fatalf("loadRivalsBoard: %v", err)
}
if len(board) != 2 || board[0].Name != "Alice" || board[0].Wins != 2 {
t.Fatalf("rivals board wrong: %+v", board)
}
}
func TestRenderTownRegistry_AllThreeBoards(t *testing.T) {
civic := []civicEntry{{Name: "Alice", TotalPaid: 1234567}, {Name: "Bob", TotalPaid: 500}}
houses := []housingEntry{{Name: "Alice", Tier: 4}, {Name: "Bob", Tier: 1}}
pets := []petShowcaseEntry{{Name: "Rex (Alice)", Type: "dog", Level: 7, ArmorName: "Plate Dog Barding"}}
out := renderTownRegistry(civic, houses, pets)
for _, want := range []string{
"Civic Pride", "€1,234,567", "Alice",
"Housing Street", "Established", "Base House",
"Pet Showcase", "Rex (Alice)", "L7 dog", "Plate Dog Barding",
} {
if !strings.Contains(out, want) {
t.Errorf("town registry missing %q\n---\n%s", want, out)
}
}
}
func TestRenderTownRegistry_EmptyBoardsDontPanic(t *testing.T) {
out := renderTownRegistry(nil, nil, nil)
for _, want := range []string{"Civic Pride", "Housing Street", "Pet Showcase"} {
if !strings.Contains(out, want) {
t.Errorf("empty registry missing header %q", want)
}
}
}
func TestPetArmorName(t *testing.T) {
if got := petArmorName("dog", 0); got != "" {
t.Errorf("tier 0 should be no armor, got %q", got)
}
if got := petArmorName("dog", 3); got != "Plate Dog Barding" {
t.Errorf("dog tier 3 = %q, want Plate Dog Barding", got)
}
if got := petArmorName("cat", 5); got != "Dragonscale Cat Armor" {
t.Errorf("cat tier 5 = %q, want Dragonscale Cat Armor", got)
}
if got := petArmorName("dog", 99); got != "" {
t.Errorf("out-of-range tier should be empty, got %q", got)
}
}
func TestRenderGraveyard_StillDownVsRecovered(t *testing.T) {
now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
deaths := []graveEntry{
{Name: "Alice", Source: "arena", Location: "the Colosseum", LastDeathDate: "2026-07-10", StillDown: true},
{Name: "Bob", Source: "adventure", Location: "", LastDeathDate: "2026-07-08", StillDown: false},
}
out := renderGraveyard(deaths, now)
if !strings.Contains(out, "fell in the arena") {
t.Errorf("arena death should read distinctly\n%s", out)
}
if !strings.Contains(out, "parts unknown") {
t.Errorf("blank location should fall back\n%s", out)
}
if !strings.Contains(out, "still resting") {
t.Errorf("Alice is still down\n%s", out)
}
if !strings.Contains(out, "back on their feet") {
t.Errorf("Bob has recovered\n%s", out)
}
if !strings.Contains(out, "today") || !strings.Contains(out, "2 days ago") {
t.Errorf("relative dates missing\n%s", out)
}
}
func TestRenderGraveyard_Empty(t *testing.T) {
out := renderGraveyard(nil, time.Now())
if !strings.Contains(out, "garden's quiet") {
t.Errorf("empty graveyard should be flavored\n%s", out)
}
}
func TestRenderRivalsBoard_OrdersAndFormats(t *testing.T) {
now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
yesterday := now.Add(-24 * time.Hour)
board := []rivalBoardEntry{
{Name: "Alice", Wins: 9, Losses: 2, LastDuelAt: &yesterday},
{Name: "Bob", Wins: 3, Losses: 5, LastDuelAt: nil},
}
out := renderRivalsBoard(board, now)
if !strings.Contains(out, "9W - 2L") {
t.Errorf("Alice's record missing\n%s", out)
}
if !strings.Contains(out, "1 day ago") {
t.Errorf("relative last-duel missing\n%s", out)
}
if !strings.Contains(out, "last duel: —") {
t.Errorf("Bob has no recorded duel time\n%s", out)
}
// Alice ranks above Bob.
if strings.Index(out, "Alice") > strings.Index(out, "Bob") {
t.Errorf("board should be ordered by wins\n%s", out)
}
}
func TestHumanizeDeathDate(t *testing.T) {
now := time.Date(2026, 7, 10, 6, 0, 0, 0, time.UTC)
cases := map[string]string{
"2026-07-10": "today",
"2026-07-09": "yesterday",
"2026-07-05": "5 days ago",
"": "some time ago",
"garbage": "garbage",
}
for in, want := range cases {
if got := humanizeDeathDate(in, now); got != want {
t.Errorf("humanizeDeathDate(%q) = %q, want %q", in, got, want)
}
}
}
func TestTruncName(t *testing.T) {
if got := truncName("short", 18); got != "short" {
t.Errorf("short name unchanged, got %q", got)
}
long := truncName("this-is-a-very-long-display-name", 10)
if len([]rune(long)) != 10 {
t.Errorf("truncated to %d runes, want 10: %q", len([]rune(long)), long)
}
if !strings.HasSuffix(long, "…") {
t.Errorf("truncated name should end with ellipsis: %q", long)
}
}

View File

@@ -50,26 +50,6 @@ var advTreasureDropRates = map[int]float64{
const advMaxTreasures = 3 const advMaxTreasures = 3
// houseTierTrophyRoom is the T3 "Comfortable" house that unlocks the trophy
// room — a fourth treasure slot.
const houseTierTrophyRoom = 3
// maxTreasuresForTier returns how many treasures a player may hold. The base
// cap is 3; a T3+ house adds the trophy-room slot for a 4th.
//
// The extra slot is a housing perk, but HouseTier is only ever written upward
// (purchase paths; there is no foreclosure/downgrade that writes it back down),
// so a player can only ever hold a 4th treasure while tier>=3. There is
// therefore no reachable state where a held treasure must be revoked at
// bonus-computation time, and the cap is enforced only at the save gate.
func maxTreasuresForTier(houseTier int) int {
n := advMaxTreasures
if houseTier >= houseTierTrophyRoom {
n++
}
return n
}
// ── Treasure Definitions ───────────────────────────────────────────────────── // ── Treasure Definitions ─────────────────────────────────────────────────────
var advAllTreasures = map[int][]AdvTreasureDef{ var advAllTreasures = map[int][]AdvTreasureDef{

View File

@@ -79,13 +79,3 @@ func TestAdvLocForZone(t *testing.T) {
t.Errorf("name = %q, want the zone's display name", loc.Name) t.Errorf("name = %q, want the zone's display name", loc.Name)
} }
} }
// TestMaxTreasuresForTier pins the T3 "trophy room" 4th slot: the base cap is
// 3, and only a tier-3+ home lifts it to 4.
func TestMaxTreasuresForTier(t *testing.T) {
for tier, want := range map[int]int{0: 3, 1: 3, 2: 3, 3: 4, 4: 4} {
if got := maxTreasuresForTier(tier); got != want {
t.Errorf("maxTreasuresForTier(%d) = %d, want %d", tier, got, want)
}
}
}

View File

@@ -1,133 +0,0 @@
package plugin
import (
"fmt"
"strings"
"maunium.net/go/mautrix/id"
)
// N4/E1 — the Tier-4 "Established" home unlocks a vault: a fixed pool of slots
// that shelters items from `!sell all`, crafting, and combat consumption. It is
// pure storage — a vaulted item drops out of every play surface (loadAdvInventory
// filters it) until it is taken back — and the plumbing E2 gifting builds on.
const (
vaultCapacity = 10 // slots an Established home provides
vaultMinHouseTier = 4 // Tier 4 "Established" gates the vault
)
// vaultUnlocked reports whether the character's house tier grants a vault, and a
// player-facing refusal line when it does not.
func vaultUnlocked(char *AdventureCharacter) (bool, string) {
if char.HouseTier >= vaultMinHouseTier {
return true, ""
}
return false, "🔒 A vault comes with an **Established** home (Tier 4). Upgrade your house at `!thom` to unlock " +
fmt.Sprintf("%d slots of protected storage — vaulted items are safe from `!sell all` and never spent in combat.", vaultCapacity)
}
// handleVaultCmd routes `!adventure vault [store|take] <item>`. The reply-building
// core is client-free (renderVault / vaultStoreItem / vaultTakeItem) so it is
// unit-testable without a Matrix stub; the handler only loads the character and
// sends the result.
func (p *AdventurePlugin) handleVaultCmd(ctx MessageContext, args string) error {
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` to create one first.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, "You're dead. The vault stays locked.")
}
if ok, refusal := vaultUnlocked(char); !ok {
return p.SendDM(ctx.Sender, refusal)
}
lower := strings.ToLower(strings.TrimSpace(args))
var reply string
switch {
case lower == "" || lower == "list":
reply = renderVault(ctx.Sender)
case strings.HasPrefix(lower, "store "):
// Serialize a player's own store/take so two near-simultaneous stores
// can't both pass the capacity check and overfill the vault.
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
reply = vaultStoreItem(ctx.Sender, strings.TrimSpace(args[len("store "):]))
userMu.Unlock()
case strings.HasPrefix(lower, "take "):
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
reply = vaultTakeItem(ctx.Sender, strings.TrimSpace(args[len("take "):]))
userMu.Unlock()
default:
reply = "Usage: `!adventure vault` to view · `!adventure vault store <item>` · `!adventure vault take <item>`."
}
return p.SendDM(ctx.Sender, reply)
}
// renderVault shows what is stowed and how many slots remain.
func renderVault(userID id.UserID) string {
items, err := loadAdvVault(userID)
if err != nil {
return "The vault door won't budge. Try again in a moment."
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🏛️ **Your Vault** — %d / %d slots\n\n", len(items), vaultCapacity))
if len(items) == 0 {
sb.WriteString("Empty. `!adventure vault store <item>` to shelter something from sale or use.")
return sb.String()
}
for _, it := range items {
name := it.Name
if it.Temper > 0 {
name = fmt.Sprintf("%s +%d", name, it.Temper)
}
sb.WriteString(fmt.Sprintf("• %s — €%d\n", name, it.Value))
}
sb.WriteString("\n`!adventure vault take <item>` to bring one back into play.")
return sb.String()
}
// vaultStoreItem moves one matching inventory item into the vault. Guards the
// capacity and reports the fresh slot count so the player never has to re-list.
func vaultStoreItem(userID id.UserID, query string) string {
if query == "" {
return "Store what? `!adventure vault store <item>`."
}
if advVaultCount(userID) >= vaultCapacity {
return fmt.Sprintf("The vault is full (%d / %d). Take something out first with `!adventure vault take <item>`.", vaultCapacity, vaultCapacity)
}
items, err := loadAdvInventory(userID)
if err != nil {
return "Couldn't reach your inventory. Try again in a moment."
}
match := findInventoryMatch(items, query)
if match == nil {
return fmt.Sprintf("No inventory item matches %q. Check `!adventure inventory`.", query)
}
moved, err := setItemVaulted(userID, match.ID, true)
if err != nil || !moved {
return "Couldn't stow that item. Try again in a moment."
}
return fmt.Sprintf("🏛️ Stowed **%s** in the vault. %d / %d slots used.", match.Name, advVaultCount(userID), vaultCapacity)
}
// vaultTakeItem returns one matching vaulted item to the active inventory.
func vaultTakeItem(userID id.UserID, query string) string {
if query == "" {
return "Take what? `!adventure vault take <item>`."
}
items, err := loadAdvVault(userID)
if err != nil {
return "The vault door won't budge. Try again in a moment."
}
match := findInventoryMatch(items, query)
if match == nil {
return fmt.Sprintf("Nothing in the vault matches %q. Check `!adventure vault`.", query)
}
moved, err := setItemVaulted(userID, match.ID, false)
if err != nil || !moved {
return "Couldn't retrieve that item. Try again in a moment."
}
return fmt.Sprintf("Took **%s** out of the vault and back into your pack. %d / %d slots used.", match.Name, advVaultCount(userID), vaultCapacity)
}

View File

@@ -1,138 +0,0 @@
package plugin
import (
"strings"
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func vaultTestDB(t *testing.T) {
t.Helper()
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
}
func seedVaultItem(t *testing.T, user id.UserID, name string, value int64) {
t.Helper()
if err := addAdvInventoryItem(user, AdvItem{Name: name, Type: "treasure", Tier: 3, Value: value}); err != nil {
t.Fatal(err)
}
}
// TestVaultStoreTakeRoundTrip: an item vaulted leaves the active inventory and
// joins the vault; taken back, it reverses exactly.
func TestVaultStoreTakeRoundTrip(t *testing.T) {
vaultTestDB(t)
user := id.UserID("@vault:test.invalid")
seedVaultItem(t, user, "Jeweled Crown", 5000)
if got := vaultStoreItem(user, "crown"); !strings.Contains(got, "Stowed") {
t.Fatalf("store reply = %q, want a stow confirmation", got)
}
inv, _ := loadAdvInventory(user)
if len(inv) != 0 {
t.Fatalf("active inventory = %d items, want 0 (item is vaulted)", len(inv))
}
vault, _ := loadAdvVault(user)
if len(vault) != 1 || vault[0].Name != "Jeweled Crown" {
t.Fatalf("vault = %+v, want the crown", vault)
}
if got := vaultTakeItem(user, "crown"); !strings.Contains(got, "back into your pack") {
t.Fatalf("take reply = %q, want a retrieval confirmation", got)
}
inv, _ = loadAdvInventory(user)
if len(inv) != 1 {
t.Fatalf("active inventory = %d items, want 1 after take", len(inv))
}
if n := advVaultCount(user); n != 0 {
t.Fatalf("vault count = %d, want 0", n)
}
}
// TestVaultCapacity: the 10th item stores, the 11th is refused, and the refusal
// leaves the item in the active inventory.
func TestVaultCapacity(t *testing.T) {
vaultTestDB(t)
user := id.UserID("@vaultcap:test.invalid")
for i := 0; i < vaultCapacity; i++ {
seedVaultItem(t, user, "Gem", 100)
if got := vaultStoreItem(user, "Gem"); !strings.Contains(got, "Stowed") {
t.Fatalf("store %d reply = %q, want success", i, got)
}
}
if n := advVaultCount(user); n != vaultCapacity {
t.Fatalf("vault count = %d, want %d", n, vaultCapacity)
}
seedVaultItem(t, user, "Overflow Gem", 100)
got := vaultStoreItem(user, "Overflow Gem")
if !strings.Contains(got, "full") {
t.Fatalf("11th store reply = %q, want a full-vault refusal", got)
}
// The refused item is still in play.
inv, _ := loadAdvInventory(user)
if len(inv) != 1 || inv[0].Name != "Overflow Gem" {
t.Fatalf("active inventory = %+v, want the overflow gem left in play", inv)
}
}
// TestVaultShieldsFromSellAll: `!sell all` liquidates loadAdvInventory, which a
// vaulted item is no longer part of — the whole point of the vault.
func TestVaultShieldsFromSellAll(t *testing.T) {
vaultTestDB(t)
user := id.UserID("@vaultsell:test.invalid")
seedVaultItem(t, user, "Heirloom", 9000)
seedVaultItem(t, user, "Junk", 5)
if got := vaultStoreItem(user, "Heirloom"); !strings.Contains(got, "Stowed") {
t.Fatalf("store reply = %q", got)
}
// What sell-all would consume:
sellable, _ := loadAdvInventory(user)
if len(sellable) != 1 || sellable[0].Name != "Junk" {
t.Fatalf("sellable inventory = %+v, want only Junk (heirloom sheltered)", sellable)
}
}
// TestVaultGate: below Tier 4 the vault is locked; at Tier 4 it opens.
func TestVaultGate(t *testing.T) {
locked := &AdventureCharacter{HouseTier: 3}
if ok, msg := vaultUnlocked(locked); ok || msg == "" {
t.Fatalf("tier 3: ok=%v msg=%q, want locked with a refusal", ok, msg)
}
open := &AdventureCharacter{HouseTier: 4}
if ok, msg := vaultUnlocked(open); !ok || msg != "" {
t.Fatalf("tier 4: ok=%v msg=%q, want unlocked", ok, msg)
}
}
// TestSetItemVaultedIsOwnerScoped: another user's id cannot be flipped.
func TestSetItemVaultedIsOwnerScoped(t *testing.T) {
vaultTestDB(t)
owner := id.UserID("@owner:test.invalid")
intruder := id.UserID("@intruder:test.invalid")
seedVaultItem(t, owner, "Relic", 1000)
items, _ := loadAdvInventory(owner)
if len(items) != 1 {
t.Fatalf("setup: owner inventory = %d, want 1", len(items))
}
moved, err := setItemVaulted(intruder, items[0].ID, true)
if err != nil {
t.Fatal(err)
}
if moved {
t.Fatalf("intruder moved the owner's item; want no-op")
}
if advVaultCount(owner) != 0 {
t.Fatalf("owner's item was vaulted by an intruder")
}
}

View File

@@ -1,110 +0,0 @@
package plugin
import (
"fmt"
"strings"
)
// Well-rested — a long rest taken at a home of tier 2+ grants a temporary buff
// that lasts until the next long rest. Renting a room at Thom Krooke's inn does
// not: the perk exists to give owning (and upgrading) a house a payoff the inn
// can't match, and it turns the dead T3/T4 prestige tiers into something worth
// buying.
//
// Two halves, both scaling with house tier (2 < 3 < 4):
// - wellRestedTempHP: a temporary hit-point cushion, layered above MaxHP for
// the day's fights via applyDnDHPScaling; and
// - wellRestedSlotBonus: bonus spell slots folded into the caster's pool at
// their highest available slot level.
//
// The tuning below is deliberately generous toward casters, who trail on
// spell-pool richness — the bonus slots are the substantive reward and the
// temp HP is a lighter feel-good cushion. All values are tunable.
//
// Nothing here is a secret discovery mechanic (cf. Misty/Arina), so the rest
// message surfaces exactly what was granted.
// homeRestTempHPPct maps house tier → temp-HP fraction of MaxHP.
func homeRestTempHPPct(houseTier int) float64 {
switch {
case houseTier >= 4:
return 0.16
case houseTier >= 3:
return 0.12
case houseTier >= 2:
return 0.08
default:
return 0
}
}
// homeRestBonusSlots maps house tier → number of bonus spell slots.
func homeRestBonusSlots(houseTier int) int {
switch {
case houseTier >= 4:
return 3
case houseTier >= 3:
return 2
case houseTier >= 2:
return 1
default:
return 0
}
}
// wellRestedTempHP returns the temporary HP granted by a home long rest at the
// given house tier. Tier 1 and the inn grant none. Always at least 1 when the
// tier qualifies, so a low-HP character still sees the buff.
func wellRestedTempHP(hpMax, houseTier int) int {
pct := homeRestTempHPPct(houseTier)
if pct <= 0 {
return 0
}
hp := int(float64(hpMax) * pct)
if hp < 1 {
hp = 1
}
return hp
}
// wellRestedSlotBonus returns the temporary bonus spell slots granted by a home
// long rest, placed entirely at the caster's highest available slot level (the
// most valuable, and one a caster is guaranteed to have). Tier 1, the inn, and
// non-casters (empty pool) grant none.
func wellRestedSlotBonus(houseTier int, pool map[int]int) map[int]int {
n := homeRestBonusSlots(houseTier)
if n == 0 || len(pool) == 0 {
return nil
}
highest := 0
for lvl := range pool {
if lvl > highest {
highest = lvl
}
}
if highest == 0 {
return nil
}
return map[int]int{highest: n}
}
// wellRestedSummary renders the player-facing description of a granted buff,
// e.g. "+18 temp HP, +2 level-5 spell slots". Returns "" when nothing was
// granted (tier 1, the inn, or a non-caster with no temp HP).
func wellRestedSummary(tempHP int, slotBonus map[int]int) string {
var bits []string
if tempHP > 0 {
bits = append(bits, fmt.Sprintf("+%d temp HP", tempHP))
}
for lvl, n := range slotBonus {
if n <= 0 {
continue
}
plural := ""
if n != 1 {
plural = "s"
}
bits = append(bits, fmt.Sprintf("+%d level-%d spell slot%s", n, lvl, plural))
}
return strings.Join(bits, ", ")
}

View File

@@ -1,192 +0,0 @@
package plugin
import (
"testing"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
func TestWellRestedTempHP_ByTier(t *testing.T) {
cases := []struct {
tier, hpMax, want int
}{
{0, 100, 0}, // no house
{1, 100, 0}, // tier-1 shack: nothing
{2, 100, 8}, // 8%
{3, 100, 12}, // 12%
{4, 100, 16}, // 16%
{2, 3, 1}, // rounds to <1 → floored to 1
}
for _, c := range cases {
if got := wellRestedTempHP(c.hpMax, c.tier); got != c.want {
t.Errorf("wellRestedTempHP(hpMax=%d, tier=%d) = %d, want %d", c.hpMax, c.tier, got, c.want)
}
}
}
func TestWellRestedSlotBonus_PlacesAtHighestLevel(t *testing.T) {
pool := map[int]int{1: 4, 2: 3, 3: 2} // highest available = 3
if got := wellRestedSlotBonus(1, pool); got != nil {
t.Errorf("tier 1 → %v, want nil", got)
}
if got := wellRestedSlotBonus(0, pool); got != nil {
t.Errorf("tier 0 → %v, want nil", got)
}
if got := wellRestedSlotBonus(3, nil); got != nil {
t.Errorf("non-caster (nil pool) → %v, want nil", got)
}
for tier, wantN := range map[int]int{2: 1, 3: 2, 4: 3} {
got := wellRestedSlotBonus(tier, pool)
if len(got) != 1 || got[3] != wantN {
t.Errorf("tier %d → %v, want {3: %d}", tier, got, wantN)
}
}
}
func TestApplyDnDHPScaling_TempHPCushion(t *testing.T) {
// Golden-safety: TempHP==0 must leave a full-HP character untouched
// (StartHP stays 0 = "enter at MaxHP", MaxHP unchanged).
stats := CombatStats{MaxHP: 100, HPBonus: 10}
full := &DnDCharacter{HPMax: 100, HPCurrent: 100, TempHP: 0}
applyDnDHPScaling(&stats, full)
if stats.MaxHP != 100 || stats.StartHP != 0 {
t.Fatalf("TempHP=0 full HP: MaxHP=%d StartHP=%d, want 100/0", stats.MaxHP, stats.StartHP)
}
// Full HP + cushion: MaxHP grows by TempHP, StartHP stays the sentinel so
// combat enters at the bumped max (full + cushion).
stats = CombatStats{MaxHP: 100, HPBonus: 10}
rested := &DnDCharacter{HPMax: 100, HPCurrent: 100, TempHP: 15}
applyDnDHPScaling(&stats, rested)
if stats.MaxHP != 115 || stats.StartHP != 0 {
t.Fatalf("full HP + cushion: MaxHP=%d StartHP=%d, want 115/0", stats.MaxHP, stats.StartHP)
}
// Wounded + cushion: entry HP is current + gear bonus + cushion, MaxHP grows.
stats = CombatStats{MaxHP: 100, HPBonus: 10}
wounded := &DnDCharacter{HPMax: 100, HPCurrent: 40, TempHP: 15}
applyDnDHPScaling(&stats, wounded)
if stats.MaxHP != 115 || stats.StartHP != 65 { // 40 + 10 + 15
t.Fatalf("wounded + cushion: MaxHP=%d StartHP=%d, want 115/65", stats.MaxHP, stats.StartHP)
}
}
func TestApplyLongRestSpellSlots_GrantAndExpiry(t *testing.T) {
if err := db.Init(t.TempDir()); err != nil {
t.Fatalf("db.Init: %v", err)
}
t.Cleanup(db.Close)
uid := id.UserID("@rested_caster:example")
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassCleric, Level: 5,
STR: 10, DEX: 12, CON: 14, INT: 10, WIS: 16, CHA: 10,
}
c.HPMax = computeMaxHP(c.Class, abilityModifier(c.CON), c.Level)
c.HPCurrent = c.HPMax
if err := SaveDnDCharacter(c); err != nil {
t.Fatalf("SaveDnDCharacter: %v", err)
}
if err := setSpellSlotsForCharacter(c); err != nil {
t.Fatalf("setSpellSlotsForCharacter: %v", err)
}
base, _ := getSpellSlots(uid)
if len(base) == 0 {
t.Fatal("cleric L5 should have a base slot pool")
}
highest := 0
for lvl := range base {
if lvl > highest {
highest = lvl
}
}
// Home rest at tier 4 → +3 at highest level, used reset to 0.
bonus, err := applyLongRestSpellSlots(c, 4)
if err != nil {
t.Fatalf("applyLongRestSpellSlots(tier4): %v", err)
}
if bonus[highest] != 3 {
t.Fatalf("tier-4 bonus = %v, want {%d: 3}", bonus, highest)
}
after, _ := getSpellSlots(uid)
if after[highest][0] != base[highest][0]+3 {
t.Errorf("highest total = %d, want base+3 = %d", after[highest][0], base[highest][0]+3)
}
for lvl, pair := range after {
if pair[1] != 0 {
t.Errorf("slot L%d used = %d after rest, want 0", lvl, pair[1])
}
}
// Next rest at the inn (tier 0) → bonus expires, totals back to base.
if _, err := applyLongRestSpellSlots(c, 0); err != nil {
t.Fatalf("applyLongRestSpellSlots(tier0): %v", err)
}
reset, _ := getSpellSlots(uid)
for lvl, pair := range reset {
if pair[0] != base[lvl][0] {
t.Errorf("L%d total = %d after expiry, want base %d", lvl, pair[0], base[lvl][0])
}
}
// Tier-1 house grants nothing.
if bonus, _ := applyLongRestSpellSlots(c, 1); bonus != nil {
t.Errorf("tier-1 bonus = %v, want nil", bonus)
}
}
func TestApplyLongRestSpellSlots_NonCaster(t *testing.T) {
if err := db.Init(t.TempDir()); err != nil {
t.Fatalf("db.Init: %v", err)
}
t.Cleanup(db.Close)
uid := id.UserID("@rested_fighter:example")
c := &DnDCharacter{UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5, CON: 14}
c.HPMax = computeMaxHP(c.Class, abilityModifier(c.CON), c.Level)
c.HPCurrent = c.HPMax
if err := SaveDnDCharacter(c); err != nil {
t.Fatalf("SaveDnDCharacter: %v", err)
}
bonus, err := applyLongRestSpellSlots(c, 4)
if err != nil {
t.Fatalf("applyLongRestSpellSlots: %v", err)
}
if bonus != nil {
t.Errorf("non-caster bonus = %v, want nil", bonus)
}
if slots, _ := getSpellSlots(uid); len(slots) != 0 {
t.Errorf("non-caster has %d slot rows, want 0", len(slots))
}
}
// TestHomeWorkshop pins the T3 workshop craft bonus and its effect on the
// success-rate cap.
func TestHomeWorkshop(t *testing.T) {
for tier, want := range map[int]float64{0: 0, 1: 0, 2: 0, 3: 0.05, 4: 0.05} {
if got := homeWorkshopCraftBonus(tier); got != want {
t.Errorf("homeWorkshopCraftBonus(%d) = %.2f, want %.2f", tier, got, want)
}
}
// No workshop: base cap holds at 0.95 for a maxed forager.
if got := craftingSuccessRate(100, 1, 0); got != 0.95 {
t.Errorf("maxed forager, no workshop = %.3f, want 0.95", got)
}
// Workshop lifts the cap to 0.98.
if got := craftingSuccessRate(100, 1, 0.05); got != 0.98 {
t.Errorf("maxed forager, workshop = %.3f, want 0.98", got)
}
// Mid-level forager gets the flat +5% added below the cap.
base := craftingSuccessRate(15, 10, 0)
withShop := craftingSuccessRate(15, 10, 0.05)
if diff := withShop - base; diff < 0.049 || diff > 0.051 {
t.Errorf("workshop delta = %.3f, want ~0.05", diff)
}
}

View File

@@ -1,294 +0,0 @@
package plugin
import (
"testing"
"maunium.net/go/mautrix/id"
)
// An armed ability is consumed once, at fight start, and its id is parked on the
// seat. Everything here defends that split.
//
// Before it existed, buildZoneCombatants consumed the ability itself — and the
// turn-based engine calls that builder again on every !attack. So a Berserker
// raged on round 1, the builder cleared their armed flag and saved, and round 2
// rebuilt them with no rage at all. They paid stamina for one round of a buff
// that is supposed to span the fight, and the close-out could not see the rage
// it was supposed to charge exhaustion for.
// ragingBerserker is a Fighter who has already `!arm`ed rage.
func ragingBerserker(t *testing.T, uid id.UserID) *DnDCharacter {
t.Helper()
if err := createAdvCharacter(uid, string(uid)); err != nil {
t.Fatal(err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Subclass: SubclassBerserker, Level: 5,
STR: 16, DEX: 12, CON: 14, INT: 10, WIS: 10, CHA: 10,
HPMax: 40, HPCurrent: 40, ArmorClass: 15,
ArmedAbility: "rage",
}
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
return c
}
// ── the split ────────────────────────────────────────────────────────────────
// consumeArmedAbility disarms and reports; applyAbilityByID applies and does not
// disarm. Calling the second one twice must be indistinguishable from once.
func TestApplyAbilityByID_IsPureAndRepeatable(t *testing.T) {
setupEmptyTestDB(t)
c := ragingBerserker(t, "@pure:example.org")
armed := consumeArmedAbility(c)
if armed != "rage" {
t.Fatalf("consumeArmedAbility = %q, want rage", armed)
}
if c.ArmedAbility != "" {
t.Errorf("character still armed after consume: %q", c.ArmedAbility)
}
if again := consumeArmedAbility(c); again != "" {
t.Errorf("second consume returned %q, want \"\" — the ability is spent", again)
}
// Same id, applied to two independent mod sets, yields the same rage.
for i, want := range []bool{true, true} {
var mods CombatModifiers
name, fired := applyAbilityByID(c, armed, &mods)
if !fired || name != "Rage" {
t.Fatalf("apply #%d: fired=%v name=%q", i+1, fired, name)
}
if mods.BerserkerRage != want {
t.Errorf("apply #%d: BerserkerRage = %v, want %v", i+1, mods.BerserkerRage, want)
}
if mods.RageMeleeDmg != 2 || !mods.PhysicalResistRage {
t.Errorf("apply #%d: rage did not carry its full mod set: %+v", i+1, mods)
}
}
// And it never writes back to the sheet.
got, _ := LoadDnDCharacter(c.UserID)
if got.ArmedAbility != "" {
t.Errorf("applyAbilityByID re-armed the sheet: %q", got.ArmedAbility)
}
}
func TestApplyAbilityByID_UnknownAndEmptyAreNoOps(t *testing.T) {
c := &DnDCharacter{Class: ClassFighter}
for _, id := range []string{"", "no_such_ability"} {
var mods CombatModifiers
if _, fired := applyAbilityByID(c, id, &mods); fired {
t.Errorf("applyAbilityByID(%q) fired", id)
}
if mods.BerserkerRage {
t.Errorf("applyAbilityByID(%q) set rage", id)
}
}
}
// ── the fight-start seat ─────────────────────────────────────────────────────
// The seat carries the id forward, and the sheet is disarmed exactly once.
func TestBuildFightSeats_ConsumesTheAbilityOnceAndCarriesItOnTheSeat(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@berserk:example.org")
ragingBerserker(t, uid)
seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats(
uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0)
if refusal != "" {
t.Fatalf("fight refused: %s", refusal)
}
if len(seats) != 1 {
t.Fatalf("seats = %d, want 1", len(seats))
}
if seats[0].ArmedAbility != "rage" {
t.Errorf("seat.ArmedAbility = %q, want rage", seats[0].ArmedAbility)
}
if !seats[0].Mods.BerserkerRage {
t.Error("seat built without rage in its mods")
}
got, _ := LoadDnDCharacter(uid)
if got.ArmedAbility != "" {
t.Errorf("sheet still armed after fight start: %q", got.ArmedAbility)
}
}
// The regression itself: rebuilding the combatant — what every !attack does —
// must reproduce the rage from the persisted id, not from the (now cleared)
// sheet. Two rebuilds in a row, both raging.
func TestBuildZoneCombatants_RebuildKeepsTheRageForTheWholeFight(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@rebuild:example.org")
ragingBerserker(t, uid)
p := &AdventurePlugin{}
seats, _, _, refusal := p.buildFightSeats(uid, []id.UserID{uid}, dndBestiary["goblin"], 1, 0)
if refusal != "" {
t.Fatalf("fight refused: %s", refusal)
}
armed := seats[0].ArmedAbility
for round := 2; round <= 3; round++ {
player, _, _, err := p.buildZoneCombatants(uid, dndBestiary["goblin"], 1, 0, armed)
if err != nil {
t.Fatalf("round %d rebuild: %v", round, err)
}
if !player.Mods.BerserkerRage {
t.Errorf("round %d: rage evaporated on rebuild", round)
}
if player.Mods.RageMeleeDmg != 2 {
t.Errorf("round %d: RageMeleeDmg = %d, want 2", round, player.Mods.RageMeleeDmg)
}
}
// A seat that armed nothing rebuilds without rage — the id is the only source.
player, _, _, err := p.buildZoneCombatants(uid, dndBestiary["goblin"], 1, 0, "")
if err != nil {
t.Fatal(err)
}
if player.Mods.BerserkerRage {
t.Error("unarmed rebuild raged anyway")
}
}
// A member who is down sits the fight out. They must not be charged the ability
// they had readied for it — the refusal is checked before the arm.
func TestBuildFightSeats_SatOutMemberKeepsTheirArmedAbility(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@lead:example.org")
downed := id.UserID("@downed:example.org")
fightTestChar(t, leader, 30)
ragingBerserker(t, downed)
// Drop the member to 0 HP with rage still armed.
c, _ := LoadDnDCharacter(downed)
c.HPCurrent = 0
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
seats, _, _, refusal := (&AdventurePlugin{}).buildFightSeats(
leader, []id.UserID{leader, downed}, dndBestiary["goblin"], 1, 0)
if refusal != "" {
t.Fatalf("fight refused: %s", refusal)
}
if len(seats) != 1 || seats[0].UserID != leader {
t.Fatalf("seats = %+v, want the leader alone", seats)
}
got, _ := LoadDnDCharacter(downed)
if got.ArmedAbility != "rage" {
t.Errorf("downed member was disarmed for a fight they never joined: %q", got.ArmedAbility)
}
}
// ── the close-out ────────────────────────────────────────────────────────────
func TestSeatFightStartMods_ReadsTheRageOffTheSeatsStatuses(t *testing.T) {
c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassBerserker, Level: 5}
raging := &CombatSession{}
raging.Statuses.ArmedAbility = "rage"
if !seatFightStartMods(raging, 0, c).BerserkerRage {
t.Error("seat 0 armed rage, mods say otherwise")
}
// Seat 1 reads its own statuses, not the leader's.
party := &CombatSession{Participants: []CombatParticipant{{Seat: 1}}}
party.Statuses.ArmedAbility = "rage"
if seatFightStartMods(party, 1, c).BerserkerRage {
t.Error("a member inherited the leader's rage")
}
}
func TestSeatCombatResult_ReadsWinAndNearDeathOffTheSession(t *testing.T) {
sess := &CombatSession{
Status: CombatStatusWon, Round: 4, PlayerHP: 5, PlayerHPMax: 40, EnemyHP: 0,
TurnLog: []CombatEvent{
{Action: "attack", Seat: 0},
{Action: "use_consumable", Seat: 1},
},
}
got := seatCombatResult(sess, 0)
if !got.PlayerWon {
t.Error("PlayerWon = false on a won session")
}
if !got.NearDeath {
t.Error("5/40 HP is under the 15% near-death line")
}
if len(got.Events) != 1 || got.Events[0].Action != "attack" {
t.Errorf("seat 0 got seat 1's events: %+v", got.Events)
}
sess.PlayerHP = 30
if seatCombatResult(sess, 0).NearDeath {
t.Error("30/40 HP is not near death")
}
sess.Status = CombatStatusLost
sess.PlayerHP = 0
if seatCombatResult(sess, 0).PlayerWon {
t.Error("PlayerWon = true on a lost session")
}
}
// Item A: the manual kill now costs the Berserker their exhaustion, exactly as
// the auto-resolved one always has.
func TestPostCombatBookkeepingForSeat_RageCostsExhaustionOnTheTurnBasedPath(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@exhausted:example.org")
ragingBerserker(t, uid)
sess := &CombatSession{
UserID: string(uid), Status: CombatStatusWon, Round: 3,
PlayerHP: 12, PlayerHPMax: 40,
}
sess.Statuses.ArmedAbility = "rage"
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
got, _ := LoadDnDCharacter(uid)
if got.Exhaustion != 1 {
t.Errorf("Exhaustion = %d after a raging win, want 1", got.Exhaustion)
}
}
// A fight nobody raged in leaves the sheet alone.
func TestPostCombatBookkeepingForSeat_NoRageNoExhaustion(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@calm:example.org")
fightTestChar(t, uid, 30)
sess := &CombatSession{
UserID: string(uid), Status: CombatStatusWon, Round: 2,
PlayerHP: 20, PlayerHPMax: 30,
}
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
got, _ := LoadDnDCharacter(uid)
if got.Exhaustion != 0 {
t.Errorf("Exhaustion = %d with no rage, want 0", got.Exhaustion)
}
}
// Losing while raging still exhausts you — the close-out runs on every terminal
// status, not just the win. This is the half the turn-based path used to skip.
func TestPostCombatBookkeepingForSeat_RageCostsExhaustionOnALoss(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@dead:example.org")
ragingBerserker(t, uid)
sess := &CombatSession{
UserID: string(uid), Status: CombatStatusLost, Round: 6,
PlayerHP: 0, PlayerHPMax: 40,
}
sess.Statuses.ArmedAbility = "rage"
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
got, _ := LoadDnDCharacter(uid)
if got.Exhaustion != 1 {
t.Errorf("Exhaustion = %d after a raging loss, want 1", got.Exhaustion)
}
}

View File

@@ -10,30 +10,6 @@ import (
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
) )
// postCombatBookkeeping is the close-out every fight owes its character,
// whatever surface it was fought on: achievements, and the subclass state that
// outlives the fight (Berserker exhaustion, Grim Harvest's kill-heal).
//
// It exists because there are four close-outs — two auto-resolve
// (runDungeonCombat, runZoneCombatRoster) and two turn-based
// (finishCombatSession, finishPartyCombatSession) — and for a long time only
// the auto-resolve pair ran any of this. The same Elite kill therefore paid out
// differently depending on whether the player let it auto-resolve or fought it
// a round at a time. Route all four through here and the divergence cannot
// silently reopen.
//
// raged is whether the character's Berserker rage was active for this fight;
// mods carries the fight-start modifiers Grim Harvest reads. HP persistence is
// NOT done here — the turn-based paths already own their own HP writes.
func (p *AdventurePlugin) postCombatBookkeeping(
userID id.UserID, dndChar *DnDCharacter, raged bool, result CombatResult, mods CombatModifiers,
) {
p.grantCombatAchievements(userID, result)
if err := persistDnDPostCombatSubclass(dndChar, raged, result, mods); err != nil {
slog.Error("dnd: post-combat subclass persist", "user", userID, "err", err)
}
}
// grantCombatAchievements checks combat results for achievement-worthy moments. // grantCombatAchievements checks combat results for achievement-worthy moments.
func (p *AdventurePlugin) grantCombatAchievements(userID id.UserID, result CombatResult) { func (p *AdventurePlugin) grantCombatAchievements(userID id.UserID, result CombatResult) {
if p.achievements == nil { if p.achievements == nil {
@@ -111,7 +87,7 @@ func (p *AdventurePlugin) runDungeonCombat(
applySubclassPassives(&playerStats, &playerMods, dndChar) applySubclassPassives(&playerStats, &playerMods, dndChar)
applyMagicItemEffects(&playerStats, &playerMods, userID) applyMagicItemEffects(&playerStats, &playerMods, userID)
trySimAutoArm(dndChar) trySimAutoArm(dndChar)
if firedName, fired := armAbilityForFight(dndChar, &playerMods); fired { if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName) slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
} }
applyDnDDungeonMonsterLayer(&enemyStats, loc.Tier) applyDnDDungeonMonsterLayer(&enemyStats, loc.Tier)
@@ -153,8 +129,12 @@ func (p *AdventurePlugin) runDungeonCombat(
// until a player-driven use command lands. // until a player-driven use command lands.
consumeFiredHealingItems(userID, countHealEventsFired(result)) consumeFiredHealingItems(userID, countHealEventsFired(result))
p.grantCombatAchievements(userID, result)
persistDnDHPAfterCombat(userID, result.PlayerEndHP) persistDnDHPAfterCombat(userID, result.PlayerEndHP)
p.postCombatBookkeeping(userID, dndChar, playerMods.BerserkerRage, result, playerMods) if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage, result, playerMods); err != nil {
slog.Error("dnd: post-combat subclass persist (dungeon)", "user", userID, "err", err)
}
if xp := dungeonCombatXP(result, loc.Tier); xp > 0 { if xp := dungeonCombatXP(result, loc.Tier); xp > 0 {
if _, err := p.grantDnDXP(userID, xp); err != nil { if _, err := p.grantDnDXP(userID, xp); err != nil {
@@ -185,7 +165,7 @@ func (p *AdventurePlugin) loadConsumableInventory(userID id.UserID) []Consumable
// Auto-craft if player has foraging level 10+ // Auto-craft if player has foraging level 10+
char, err := loadAdvCharacter(userID) char, err := loadAdvCharacter(userID)
if err == nil && char.ForagingSkill >= 10 { if err == nil && char.ForagingSkill >= 10 {
craftResults, updatedItems := autoCraftConsumables(userID, items, char.ForagingSkill, homeWorkshopCraftBonus(char.HouseTier)) craftResults, updatedItems := autoCraftConsumables(userID, items, char.ForagingSkill)
if len(craftResults) > 0 { if len(craftResults) > 0 {
items = updatedItems items = updatedItems
for _, cr := range craftResults { for _, cr := range craftResults {

View File

@@ -101,11 +101,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
if refusal != "" { if refusal != "" {
return p.replyDM(ctx, refusal) return p.replyDM(ctx, refusal)
} }
// The persisted session scales enemy HP for a party (solo scales by 1.0, so enemyHP := enemy.Stats.MaxHP
// this is a no-op there); mirror it here so the entry banner and the opening
// round resolve against the same ceiling startPartyCombatSession persisted and
// the rebuilt rounds use.
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats))
// Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded // Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded
// per seat onto the session and its participant rows, so they survive the // per seat onto the session and its participant rows, so they survive the
@@ -134,11 +130,6 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
if sess.IsParty() { if sess.IsParty() {
players := seatCombatants(seats) players := seatCombatants(seats)
// Align the in-memory template with the scaled HP persisted above, so the
// opening-round settle resolves the enemy against the same MaxHP the
// rebuilt rounds do (regen clamp, bloodied-ability threshold). The persist
// already happened off the unscaled value, so this does not double-scale.
enemy.Stats.MaxHP = enemyHP
// The enemy may have won initiative. Resolve everything the round owes // The enemy may have won initiative. Resolve everything the round owes
// before anyone is asked to act, so the opening block narrates the hit // before anyone is asked to act, so the opening block narrates the hit
// rather than showing its damage with no explanation. // rather than showing its damage with no explanation.
@@ -341,10 +332,6 @@ func continueHint(userID id.UserID) string {
// the room's manual combat is done, and a fresh !zone advance clears the room. // the room's manual combat is done, and a fresh !zone advance clears the room.
func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSession, enemy Combatant) string { func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSession, enemy Combatant) string {
persistDnDHPAfterCombat(userID, sess.PlayerHP) persistDnDHPAfterCombat(userID, sess.PlayerHP)
// Achievements and post-combat subclass state are owed on every terminal
// status, not just a win — a Berserker who rages and loses still comes out
// of it exhausted. The auto-resolve close-outs have always done this.
p.postCombatBookkeepingForSeat(sess, 0)
run, _ := getZoneRun(sess.RunID) run, _ := getZoneRun(sess.RunID)
var zone ZoneDefinition var zone ZoneDefinition
@@ -363,12 +350,28 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
var b strings.Builder var b strings.Builder
switch sess.Status { switch sess.Status {
case CombatStatusWon: case CombatStatusWon:
recordZoneKillForUser(userID, sess.EnemyID)
applyRoomCombatThreatForUser(userID, elite)
// zoneCombatXP only reads PlayerWon + NearDeath off the result.
nearDeath := sess.PlayerHPMax > 0 && sess.PlayerHP*5 < sess.PlayerHPMax
tier := 1 tier := 1
if run != nil { if run != nil {
tier = int(zone.Tier) tier = int(zone.Tier)
} }
bossOnExpedition := p.applyOwnerWinEffects(userID, sess.EnemyID, elite) if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 {
p.grantSeatWinXP(userID, sess.PlayerHP, sess.PlayerHPMax, monster, tier) if _, err := p.grantDnDXP(userID, xp); err != nil {
slog.Error("combat: grantDnDXP turn-based", "user", userID, "err", err)
}
}
bossOnExpedition := false
if !elite {
// §8.1 — zone boss defeat drops expedition threat. Silent no-op
// for standalone zone runs (no active expedition).
if exp, eerr := getActiveExpedition(userID); eerr == nil && exp != nil {
_ = applyBossDefeatThreat(exp.ID)
bossOnExpedition = true
}
}
if line := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence); line != "" { if line := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence); line != "" {
b.WriteString(line + "\n") b.WriteString(line + "\n")
} }
@@ -392,7 +395,11 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
} }
case CombatStatusLost: case CombatStatusLost:
endRunOnLoss(userID, sess.RunID, true) if run != nil {
_, _ = applyMoodEvent(sess.RunID, MoodEventPlayerDeath)
}
_ = abandonZoneRun(userID)
forceExtractExpeditionForRunLoss(userID, "combat death")
markAdventureDead(userID, "zone", zone.Display) markAdventureDead(userID, "zone", zone.Display)
if line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence); line != "" { if line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence); line != "" {
b.WriteString(line + "\n") b.WriteString(line + "\n")
@@ -403,7 +410,8 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
// Flee = run ends, light penalty: wounds persist (HP already saved), // Flee = run ends, light penalty: wounds persist (HP already saved),
// but no death timer. Chosen candidate from the migration plan's // but no death timer. Chosen candidate from the migration plan's
// open question on flee outcome. // open question on flee outcome.
endRunOnLoss(userID, sess.RunID, false) _ = abandonZoneRun(userID)
forceExtractExpeditionForRunLoss(userID, "combat flee")
b.WriteString(fmt.Sprintf("🏃 You broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name)) b.WriteString(fmt.Sprintf("🏃 You broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name))
default: default:
@@ -590,18 +598,6 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" { if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
return PlayerAction{}, noop, msg return PlayerAction{}, noop, msg
} }
// Park the Necromancy kill-heal stash on the casting seat. The
// auto-resolve path keeps it on the fight-start CombatModifiers, which
// a turn-based fight has nowhere to hold — it rebuilds its combatants
// every round. Only a damaging cast stashes (a miss leaves the slot at
// 0), and each one overwrites the last, so the stash always describes
// the seat's most recent landed spell — which is the only one that can
// have been lethal by the time the close-out reads it.
if out.GrimHarvestSlot > 0 {
as := ct.sess.actorStatusesPtr(seat)
as.GrimHarvestSlot = out.GrimHarvestSlot
as.GrimHarvestNecrotic = out.GrimHarvestNecrotic
}
eff = &turnActionEffect{ eff = &turnActionEffect{
Label: out.Desc, Label: out.Desc,
Action: "spell_cast", Action: "spell_cast",

View File

@@ -162,11 +162,9 @@ type CombatModifiers struct {
// ArcaneWardHP: flat HP buffer absorbed before player HP. Refilled at the // ArcaneWardHP: flat HP buffer absorbed before player HP. Refilled at the
// start of each combat by Abjuration L5+ (2× Mage level, +prof at L7). // start of each combat by Abjuration L5+ (2× Mage level, +prof at L7).
// Persists across rounds within a single combat; not refunded between fights. // Persists across rounds within a single combat; not refunded between fights.
// GrimHarvestSlot/Necrotic: snapshot of the damaging spell stashed for the // GrimHarvestSlot/Necrotic: snapshot of the queued spell stashed by
// post-combat Grim Harvest hook (Necromancy L5+) — by applyPendingCast on // applyPendingCast for the post-combat Grim Harvest hook (Necromancy L5+).
// the auto-resolve path, and by seatFightStartMods reading the seat's // Heal fires only if the spell event is what dropped the enemy to 0.
// statuses on the turn-based one. Heal fires only if that spell's event is
// what dropped the enemy to 0.
ArcaneWardHP int ArcaneWardHP int
GrimHarvestSlot int GrimHarvestSlot int
GrimHarvestNecrotic bool GrimHarvestNecrotic bool

View File

@@ -64,12 +64,6 @@ func simulateParty(players []Combatant, enemy Combatant, phases []CombatPhase) P
} }
func simulatePartyWithRNG(players []Combatant, enemy Combatant, phases []CombatPhase, rng *rand.Rand) PartyCombatResult { func simulatePartyWithRNG(players []Combatant, enemy Combatant, phases []CombatPhase, rng *rand.Rand) PartyCombatResult {
// Party-only: bump the enemy's max HP so the fight lasts long enough for the
// scaled action economy to actually threaten each member. Solo is unchanged
// (scale 1.0), so SimulateCombat and the golden do not move.
if len(players) > 1 {
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, len(players))
}
enemyStart := enemy.Stats.MaxHP enemyStart := enemy.Stats.MaxHP
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP { if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP {
enemyStart = enemy.Stats.StartHP enemyStart = enemy.Stats.StartHP
@@ -295,141 +289,6 @@ func roundInitiative(st *combatState, enemy *Combatant, phase *CombatPhase) []in
return order return order
} }
// enemyActionsThisRound is how many attack-actions the enemy takes this round
// against the seated roster. Solo returns 1 without drawing from the RNG, so both
// combat engines collapse to their pre-party behaviour and the characterization
// golden and d8prereq corpus do not move.
//
// A party's action budget is a *fractional expectation* (partyActionExpectation),
// realised as floor(exp) actions plus one more with probability frac(exp). The
// fraction matters because the action lever is coarse: against a party of two, an
// integer 2 actions is a 100% faceroll and 3 is brutal (~45%), with nothing
// between — a per-round coin-flip for the extra action is the only way to land
// the band in the gap. The single draw is taken only for a party, so the solo RNG
// stream is untouched.
func enemyActionsThisRound(st *combatState) int {
n := len(st.actors)
if n < 2 {
return 1
}
exp := partyActionExpectation(n)
base := int(exp)
if frac := exp - float64(base); frac > 0 && st.randFloat() < frac {
base++
}
return base
}
// partyActionExpectation is the expected number of enemy attack-actions per round
// against a party of n. A single enemy swing (the pre-P8 behaviour) let each
// member absorb ~1/N² of the solo incoming and cleared 100% of T5; the sim band
// landed the martial-leader target only near ~2N actions, and HP alone can't touch
// a martial (fighter stays at 100% even at enemy HP ×1.6). So the enemy's action
// economy is the load-bearing lever, and it is fractional so a two-person party
// lands between soloing and a trio instead of snapping to one of two integers
// (against a duo, an integer 2 actions is a ~91% faceroll and 3 is a ~45% meat
// grinder — 2.4 sits fighter+cleric at ~75%, between solo 70% and trio 90%).
//
// N≥3 follows 2N1, the point that gave fighter ~90% / cleric-led ~72% at
// HP ×1.15. The whole curve is monotonic by party size and never drops a
// composition below its solo clear rate — bringing a friend is never a penalty.
func partyActionExpectation(n int) float64 {
switch {
case n < 2:
return 1
case n == 2:
return 2.4
default:
return float64(2*n - 1)
}
}
// partyEnemyHPScale is the party-only multiplier on the enemy's max HP. Solo
// (roster < 2) returns 1.0, so the solo path — and the characterization golden
// and the d8prereq corpus — is byte-for-byte untouched. With the action economy
// fixed, each member now takes ~1 swing a round, so a longer fight is more
// cumulative exposure per member: HP became a live difficulty lever exactly the
// way P6e predicted it would once the enemy stopped swinging only once.
//
// 1.15 is where the P8 sim sweep landed the band: with 2N1 actions, a party of
// three clears the T5 martial-leader band at ~89% (fighter) / ~67% (cleric-led),
// clearly safer than soloing (70% / 27%) but with real TPKs and member deaths —
// not the 100% faceroll a single enemy swing produced.
func partyEnemyHPScale(rosterSize int) float64 {
if rosterSize < 2 {
return 1.0
}
return 1.15
}
// scaledEnemyMaxHP applies the party HP scalar to a base max-HP with one rounding
// rule, so every call site (the auto-resolve engine, the party session's initial
// persist, and the per-turn rebuild) agrees on the same number.
func scaledEnemyMaxHP(baseMaxHP, rosterSize int) int {
return int(float64(baseMaxHP) * partyEnemyHPScale(rosterSize))
}
// enemyActionPlan is the shared action budget both combat engines resolve an
// enemy turn against: how many attack-actions the enemy takes, and whether the
// first one reuses the round's already-picked target (and its already-rolled
// procs). A cleave/lifesteal ability already spent the first action, so one fewer
// follows and none of them reuse the initial target. Both engines call this so
// the load-bearing count stays in lockstep even though their per-action bodies
// differ.
func enemyActionPlan(st *combatState, abilityDealtDamage bool) (count int, reuseFirst bool) {
count = enemyActionsThisRound(st)
reuseFirst = !abilityDealtDamage
if abilityDealtDamage {
count--
}
return count, reuseFirst
}
// enemyRoundSwings resolves the enemy's attacks for one round of the auto-resolve
// engine. A solo roster takes exactly one swing at its single seat, reusing the
// round's already-rolled target and pet procs, so its RNG stream and event log are
// byte-for-byte the pre-P8 engine's. A party faces enemyActionsThisRound() swings,
// each re-targeted at a random standing seat with that seat's own pet procs, so the
// damage is spread across the roster instead of pinning the round's first target.
//
// abilityDealtDamage means a cleave/lifesteal already spent the enemy's first
// action this round, so one fewer swing follows — for solo that collapses to the
// old "the ability stands in for the attack" skip. Returns true if the fight is
// decided.
func enemyRoundSwings(st *combatState, enemy *Combatant, phase *CombatPhase, seats []CombatResult,
target int, abilityDealtDamage, petWhiff, petDeflect, sporeMiss bool) bool {
swings, reuseFirst := enemyActionPlan(st, abilityDealtDamage)
for k := 0; k < swings; k++ {
tgt := target
sw, sd, sp := petWhiff, petDeflect, sporeMiss
if !(reuseFirst && k == 0) {
// A fresh target for every swing past the first: re-pick among the
// standing seats and roll that seat's own pet procs. This branch never
// runs for a solo roster, so it adds no draws to the solo stream.
var alive bool
if tgt, alive = enemyTargetSeat(st); !alive {
return combatOver(st)
}
st.seat(tgt)
sw = st.c.Mods.PetWhiffProc > 0 && st.randFloat() < st.c.Mods.PetWhiffProc
sd = st.c.Mods.PetDeflectProc > 0 && st.randFloat() < st.c.Mods.PetDeflectProc
if sd {
seats[tgt].PetDeflected = true
}
sp = st.sporeRounds > 0 && st.randFloat() < 0.15
}
st.seat(tgt)
mark := len(st.events)
over := resolveEnemyAttack(st, st.c, enemy, phase, &seats[tgt], sw, sd, sp)
stampEventSeats(st, mark, tgt)
if over && combatOver(st) {
return true
}
}
return false
}
// simulatePartyRound runs one round for the whole roster. Returns true if the // simulatePartyRound runs one round for the whole roster. Returns true if the
// fight is over. // fight is over.
func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, seats []CombatResult) bool { func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, seats []CombatResult) bool {
@@ -524,7 +383,14 @@ func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, s
// via Mods.InitiativeBias — +X means they go first more often. // via Mods.InitiativeBias — +X means they go first more often.
for _, s := range roundInitiative(st, enemy, phase) { for _, s := range roundInitiative(st, enemy, phase) {
if s == enemySeat { if s == enemySeat {
if enemyRoundSwings(st, enemy, phase, seats, target, abilityDealtDamage, petWhiff, petDeflect, sporeMiss) { if abilityDealtDamage {
continue
}
st.seat(target)
mark := len(st.events)
over := resolveEnemyAttack(st, st.c, enemy, phase, &seats[target], petWhiff, petDeflect, sporeMiss)
stampEventSeats(st, mark, target)
if over && combatOver(st) {
return true return true
} }
continue continue
@@ -582,78 +448,6 @@ func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, s
return false return false
} }
// ── Misty's pair, shared by both engines ─────────────────────────────────────
//
// These two are the only round-end effects the turn engine did not have its own
// copy of. Everything else in endOfRoundForSeat either exists there already (the
// pet, the spiritual weapon — fired after a player action rather than at round
// end), is deliberately absent (the environmental hazard: turnCombatPhase is a
// single flat "Duel" phase with EnvironmentProc at 0), or is replaced by an
// explicit player command (the consumable auto-heal, which is `!consume`).
//
// So they are hoisted rather than re-implemented. A parallel sibling is what let
// the two win close-outs drift apart (deferred item D); this pair is now one
// list of effects with two callers, and cannot.
//
// Neither draws from the RNG unless its proc is armed, so a character with no
// Misty history rolls exactly the dice it rolled before — the sim corpus and
// combat_characterization.golden do not move.
// mistyCrowdRevenge is the debuff for declining Misty: her crowd takes a swing
// at the end of the round. Returns true when it decided the fight — that is,
// when it dropped this character, the death save failed, and nobody else is
// standing. A downed character in a still-live party returns false.
func mistyCrowdRevenge(st *combatState, player *Combatant, phaseName string) bool {
if player.Mods.CrowdRevengeProc <= 0 || st.randFloat() >= player.Mods.CrowdRevengeProc {
return false
}
dmg := player.Mods.CrowdRevengeDmg
st.playerHP = max(0, st.playerHP-dmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "environment", Action: "crowd_revenge",
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Desc: "Misty's crowd",
})
return st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive()
}
// mistyHeal is the buff's payout. It cannot decide the fight, so it returns
// nothing. result may be a scratch value the caller discards (the turn engine's
// is) — the durable record is the misty_heal event on the log, which is what
// seatCombatResult reads to award combat_misty_clutch.
func mistyHeal(st *combatState, player *Combatant, phaseName string, result *CombatResult) {
if player.Mods.MistyHealProc <= 0 || st.randFloat() >= player.Mods.MistyHealProc {
return
}
healAmt := player.Mods.MistyHealAmt
st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt)
result.MistyHealed = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal",
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Desc: "Misty",
})
}
// seatEndOfRound runs the round-end effects the turn engine owes one seat, in
// the order endOfRoundForSeat runs them: the crowd's swing, then — if the seat
// survived it — Misty's heal. Returns true when the fight is over.
//
// It exists because the turn engine's stepRoundEnd never ran either one. A
// player carrying Misty's buff lost it by fighting manually; worse, a player
// carrying her debuff *escaped* it by doing the same, which needed no discovery
// to exploit — just press !attack.
func seatEndOfRound(st *combatState, player *Combatant, phaseName string, result *CombatResult) bool {
if over := mistyCrowdRevenge(st, player, phaseName); over {
return true
}
if st.playerHP <= 0 {
return false
}
mistyHeal(st, player, phaseName, result)
return false
}
// endOfRoundForSeat runs the per-character close of a round against the seat the // endOfRoundForSeat runs the per-character close of a round against the seat the
// cursor already points at: environment, Misty's crowd, the pet, the spiritual // cursor already points at: environment, Misty's crowd, the pet, the spiritual
// weapon, Misty's heal, and the consumable auto-heal — in that order, which is // weapon, Misty's heal, and the consumable auto-heal — in that order, which is
@@ -676,9 +470,18 @@ func endOfRoundForSeat(st *combatState, phase *CombatPhase, result *CombatResult
} }
// Misty crowd revenge (debuff for declining Misty) // Misty crowd revenge (debuff for declining Misty)
if over := mistyCrowdRevenge(st, player, phaseName); over { if player.Mods.CrowdRevengeProc > 0 && st.randFloat() < player.Mods.CrowdRevengeProc {
dmg := player.Mods.CrowdRevengeDmg
st.playerHP = max(0, st.playerHP-dmg)
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "environment", Action: "crowd_revenge",
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Desc: "Misty's crowd",
})
if st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive() {
return true return true
} }
}
// A character the round just killed stops acting, but the fight goes on if // A character the round just killed stops acting, but the fight goes on if
// anyone else is standing. // anyone else is standing.
@@ -714,7 +517,16 @@ func endOfRoundForSeat(st *combatState, phase *CombatPhase, result *CombatResult
} }
// Misty heal // Misty heal
mistyHeal(st, player, phaseName, result) if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc {
healAmt := player.Mods.MistyHealAmt
st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt)
result.MistyHealed = true
st.events = append(st.events, CombatEvent{
Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal",
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
Desc: "Misty",
})
}
// Consumable heal: triggers when the character drops below 60% HP. Fires up // Consumable heal: triggers when the character drops below 60% HP. Fires up
// to HealItemCharges times per fight (1 = legacy one-shot; bosses can // to HealItemCharges times per fight (1 = legacy one-shot; bosses can

View File

@@ -124,89 +124,31 @@ func TestSimulateParty_DownedMemberDoesNotEndTheFight(t *testing.T) {
} }
} }
// P8 scaling constants: solo is exempt on both levers (1 action, ×1.0 HP) so the // The enemy swings at one seat per round, not at everyone.
// characterization golden and the d8prereq corpus are untouched. A party's action func TestSimulateParty_EnemyStrikesOneSeatPerRound(t *testing.T) {
// budget is a fractional expectation — 2.4 for a duo (so it lands between soloing
// and a trio, where an integer 2 vs 3 has no room), 2N1 for N≥3 — plus ×1.15
// enemy HP.
func TestP8PartyScaling_SoloExemptPartyScaled(t *testing.T) {
if got := partyActionExpectation(1); got != 1 {
t.Fatalf("solo action expectation = %v, want 1", got)
}
if got := partyEnemyHPScale(1); got != 1.0 {
t.Fatalf("solo HP scale = %v, want 1.0", got)
}
if got := scaledEnemyMaxHP(200, 1); got != 200 {
t.Fatalf("solo enemy HP = %d, want 200 (unscaled)", got)
}
if got := partyActionExpectation(2); got != 2.4 {
t.Fatalf("duo action expectation = %v, want 2.4", got)
}
for n := 3; n <= 5; n++ {
if got, want := partyActionExpectation(n), float64(2*n-1); got != want {
t.Fatalf("party of %d: action expectation = %v, want %v", n, got, want)
}
}
if got := partyEnemyHPScale(3); got != 1.15 {
t.Fatalf("party HP scale = %v, want 1.15", got)
}
// int(200*1.15) truncates 229.99… to 229; the 1-HP floor is immaterial.
if got := scaledEnemyMaxHP(200, 3); got != 229 {
t.Fatalf("party enemy HP = %d, want 229 (trunc 200*1.15)", got)
}
}
// P8: the enemy's action economy scales with the roster. A party of N faces up to
// N attack-actions a round, each re-targeted, so the enemy's damage spreads across
// the roster instead of pinning one seat — the 1/N² exposure that made P6e's party
// a 100%-clear faceroll. Solo stays at exactly one swing a round, the pre-party
// behaviour the characterization golden pins.
func TestSimulateParty_EnemyActionEconomyScalesWithRoster(t *testing.T) {
enemyRollsPerRound := func(res PartyCombatResult) map[int]int {
perRound := map[int]int{}
for _, e := range res.Events {
if e.Actor == "enemy" && e.Roll > 0 {
perRound[e.Round]++
}
}
return perRound
}
// Solo: never more than one enemy swing in a round.
soloTank := baseEnemy()
soloTank.Stats.MaxHP = 5000
solo := simulatePartyWithRNG(
[]Combatant{basePlayer()}, soloTank, dungeonCombatPhases, seededRNG(23))
if len(enemyRollsPerRound(solo)) == 0 {
t.Fatal("solo: the enemy never attacked")
}
for round, n := range enemyRollsPerRound(solo) {
if n > 1 {
t.Fatalf("solo round %d: enemy swung %d times, want at most 1", round, n)
}
}
// Party of 3: no round exceeds the roster's action budget, and at least one
// round sees more than one swing — the enemy is really taking extra actions.
tank := baseEnemy() tank := baseEnemy()
tank.Stats.MaxHP = 5000 tank.Stats.MaxHP = 5000
party := simulatePartyWithRNG(
res := simulatePartyWithRNG(
[]Combatant{basePlayer(), basePlayer(), basePlayer()}, tank, dungeonCombatPhases, seededRNG(23)) []Combatant{basePlayer(), basePlayer(), basePlayer()}, tank, dungeonCombatPhases, seededRNG(23))
budget := int(partyActionExpectation(3))
if partyActionExpectation(3) > float64(budget) { perRound := map[int]map[int]bool{}
budget++ // ceil: the coin-flip round can add one action for _, e := range res.Events {
if e.Actor != "enemy" || e.Roll == 0 {
continue
} }
sawMulti := false if perRound[e.Round] == nil {
for round, n := range enemyRollsPerRound(party) { perRound[e.Round] = map[int]bool{}
if n > budget {
t.Fatalf("party round %d: enemy swung %d times, over the %d-action budget", round, n, budget)
} }
if n > 1 { perRound[e.Round][e.Seat] = true
sawMulti = true
} }
if len(perRound) == 0 {
t.Fatal("the enemy never attacked")
}
for round, seats := range perRound {
if len(seats) != 1 {
t.Fatalf("round %d: enemy attack rolls landed on %d seats, want 1", round, len(seats))
} }
if !sawMulti {
t.Fatal("a party of 3 never faced more than one enemy swing in a round — action economy did not scale")
} }
} }

View File

@@ -1,232 +0,0 @@
package plugin
import (
"testing"
"maunium.net/go/mautrix/id"
)
// Grim Harvest on the turn-based surface.
//
// The auto-resolve path stashes the killing spell's slot level on the
// fight-start CombatModifiers, where the close-out reads it. A turn-based fight
// has nowhere to keep that: it rebuilds its combatants from the session row on
// every !attack / !cast. resolveTurnSpell computed the stash into a local mod
// set and dropped it on the floor, so a Necromancy Mage who killed with a spell
// never healed — on a class that already trails.
//
// The stash now rides on the casting seat's ActorStatuses, and the close-out
// asks whether the *last* spell_cast is the one that dropped the enemy to 0.
//
// (Empowered Evocation and Overchannel were never broken here: they only move
// mods.SpellPreDamage, which resolveTurnSpell already returns as EnemyDamage.)
// necromancer is a Mage who has reached L5 Grim Harvest.
func necromancer(t *testing.T, uid id.UserID, level int) *DnDCharacter {
t.Helper()
if err := createAdvCharacter(uid, string(uid)); err != nil {
t.Fatal(err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassMage, Subclass: SubclassNecromancy, Level: level,
STR: 8, DEX: 12, CON: 12, INT: 16, WIS: 10, CHA: 10,
HPMax: 30, HPCurrent: 10, ArmorClass: 12,
}
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
return c
}
// tough is a stat block a spell can be cast at without any of it mattering.
func tough() CombatStats { return CombatStats{MaxHP: 40, AC: 10} }
// ── the leak itself ──────────────────────────────────────────────────────────
// resolveTurnSpell must hand the stash back to its caller. Before this, the
// slot level lived and died inside the function.
func TestResolveTurnSpell_CarriesTheGrimHarvestStashOut(t *testing.T) {
c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, INT: 16}
// magic_missile: auto-hit, force damage, upcast to a 3rd-level slot.
spell, _ := lookupSpell("magic_missile")
enemy := tough()
out, ok := resolveTurnSpell(c, spell, 3, &enemy)
if !ok {
t.Fatal("magic_missile should be a supported turn spell")
}
if out.GrimHarvestSlot != 3 {
t.Errorf("GrimHarvestSlot = %d, want 3 (the slot it was cast at)", out.GrimHarvestSlot)
}
if out.GrimHarvestNecrotic {
t.Error("magic_missile deals force damage — Necrotic should be false")
}
// blight: auto-hit, necrotic — the flag that triples the heal.
spell, _ = lookupSpell("blight")
enemy = tough()
out, _ = resolveTurnSpell(c, spell, 4, &enemy)
if out.GrimHarvestSlot != 4 || !out.GrimHarvestNecrotic {
t.Errorf("blight L4: slot=%d necrotic=%v, want 4/true", out.GrimHarvestSlot, out.GrimHarvestNecrotic)
}
}
// Nobody else stashes. A Mage below L5 has not learned the harvest, and an
// Evocation Mage never will.
func TestResolveTurnSpell_NoStashForAnyoneElse(t *testing.T) {
spell, _ := lookupSpell("magic_missile")
for _, c := range []*DnDCharacter{
{Class: ClassMage, Subclass: SubclassNecromancy, Level: 4, INT: 16},
{Class: ClassMage, Subclass: SubclassEvocation, Level: 12, INT: 16},
{Class: ClassSorcerer, Level: 12, CHA: 16},
} {
enemy := tough()
out, _ := resolveTurnSpell(c, spell, 3, &enemy)
if out.GrimHarvestSlot != 0 {
t.Errorf("%s/%s L%d stashed slot %d, want 0",
c.Class, c.Subclass, c.Level, out.GrimHarvestSlot)
}
}
}
// ── which cast counts ────────────────────────────────────────────────────────
// The killing-blow check used to break on the first spell_cast it saw. In a
// turn-based fight the mage casts every round, so the first one is an opening
// cantrip that left the enemy standing — and it vetoed the heal the killing
// spell had earned. It is the last cast that has to be lethal.
func TestGrimHarvestHeal_ReadsTheLastCastNotTheFirst(t *testing.T) {
c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, INT: 16}
mods := CombatModifiers{GrimHarvestSlot: 2, GrimHarvestNecrotic: true}
result := CombatResult{PlayerWon: true, Events: []CombatEvent{
{Round: 1, Action: "spell_cast", EnemyHP: 22}, // opening cantrip, enemy stands
{Round: 2, Action: "enemy_attack", EnemyHP: 22},
{Round: 2, Action: "spell_cast", EnemyHP: 0}, // this one killed it
}}
if got := grimHarvestHeal(c, result, mods); got != 6 {
t.Errorf("heal = %d, want 6 (3× a 2nd-level necrotic slot)", got)
}
}
// The mirror: the mage softened it up with a spell, then finished it with a
// weapon. No spell landed the blow, so no harvest.
func TestGrimHarvestHeal_WeaponKillAfterASpellDoesNotHarvest(t *testing.T) {
c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, INT: 16}
mods := CombatModifiers{GrimHarvestSlot: 2, GrimHarvestNecrotic: true}
result := CombatResult{PlayerWon: true, Events: []CombatEvent{
{Round: 1, Action: "spell_cast", EnemyHP: 9},
{Round: 2, Action: "player_attack", EnemyHP: 0},
}}
if got := grimHarvestHeal(c, result, mods); got != 0 {
t.Errorf("heal = %d, want 0 — the sword landed the blow, not the spell", got)
}
}
// ── the seam through the session ─────────────────────────────────────────────
// The stash has to survive the round-trip the fight puts it through: parked on
// the seat by the cast, carried across commit()'s snapshot, read back by the
// close-out.
func TestSeatFightStartMods_ReadsTheStashOffTheSeat(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@seatstash:example.org")
c := necromancer(t, uid, 5)
sess := &CombatSession{UserID: string(uid), Status: CombatStatusWon}
sess.Statuses.GrimHarvestSlot = 3
sess.Statuses.GrimHarvestNecrotic = true
mods := seatFightStartMods(sess, 0, c)
if mods.GrimHarvestSlot != 3 || !mods.GrimHarvestNecrotic {
t.Fatalf("seatFightStartMods lost the stash: %+v", mods)
}
// snapshotActor rebuilds ActorStatuses from combatState each commit; fields
// with no combatState counterpart must be carried over from the prior
// snapshot, the way ArmedAbility is.
kept := snapshotActor(&actor{}, sess.Statuses.ActorStatuses)
if kept.GrimHarvestSlot != 3 || !kept.GrimHarvestNecrotic {
t.Errorf("commit() dropped the stash: %+v", kept)
}
}
// End to end at the close-out: a necromancer who ended the fight at 10/30 HP
// with a lethal 2nd-level necrotic spell walks away with 6 HP back.
func TestPostCombatBookkeepingForSeat_GrimHarvestHealsOnASpellKill(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@harvest:example.org")
necromancer(t, uid, 5)
sess := &CombatSession{
UserID: string(uid), Status: CombatStatusWon, Round: 3,
PlayerHP: 10, PlayerHPMax: 30, EnemyHP: 0,
TurnLog: []CombatEvent{
{Round: 1, Action: "spell_cast", EnemyHP: 14},
{Round: 3, Action: "spell_cast", EnemyHP: 0},
},
}
sess.Statuses.GrimHarvestSlot = 2
sess.Statuses.GrimHarvestNecrotic = true
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
got, _ := LoadDnDCharacter(uid)
if got.HPCurrent != 16 {
t.Errorf("HPCurrent = %d after a turn-based spell kill, want 16 (10 + 3×2)", got.HPCurrent)
}
}
// A seat with no stash — the mage never cast, or missed every time — is left
// exactly as the fight left them.
func TestPostCombatBookkeepingForSeat_NoStashNoHeal(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@nostash:example.org")
necromancer(t, uid, 5)
sess := &CombatSession{
UserID: string(uid), Status: CombatStatusWon, Round: 2,
PlayerHP: 10, PlayerHPMax: 30, EnemyHP: 0,
TurnLog: []CombatEvent{{Round: 2, Action: "player_attack", EnemyHP: 0}},
}
(&AdventurePlugin{}).postCombatBookkeepingForSeat(sess, 0)
got, _ := LoadDnDCharacter(uid)
if got.HPCurrent != 10 {
t.Errorf("HPCurrent = %d, want 10 — nothing was stashed", got.HPCurrent)
}
}
// Party fights: the stash is per-seat, so seat 1's harvest cannot heal seat 0.
// This is the same class of bug P5 fixed for mid-fight buffs.
func TestGrimHarvestStash_IsPerSeat(t *testing.T) {
setupEmptyTestDB(t)
leader, member := id.UserID("@leader:example.org"), id.UserID("@member:example.org")
fightTestChar(t, leader, 30)
necromancer(t, member, 5)
sess := &CombatSession{
UserID: string(leader), Status: CombatStatusWon, Round: 2,
PlayerHP: 20, PlayerHPMax: 30, EnemyHP: 0,
Participants: []CombatParticipant{{Seat: 1, UserID: string(member), HP: 10, HPMax: 30}},
TurnLog: []CombatEvent{
{Round: 2, Seat: 1, Action: "spell_cast", EnemyHP: 0},
},
}
sess.Participants[0].Statuses.GrimHarvestSlot = 2
sess.Participants[0].Statuses.GrimHarvestNecrotic = true
p := &AdventurePlugin{}
p.postCombatBookkeepingForSeat(sess, 0)
p.postCombatBookkeepingForSeat(sess, 1)
gotLeader, _ := LoadDnDCharacter(leader)
if gotLeader.HPCurrent != 30 {
t.Errorf("leader HPCurrent = %d, want 30 — the member's harvest is not theirs", gotLeader.HPCurrent)
}
gotMember, _ := LoadDnDCharacter(member)
if gotMember.HPCurrent != 16 {
t.Errorf("member HPCurrent = %d, want 16 (10 + 3×2)", gotMember.HPCurrent)
}
}

View File

@@ -1,169 +0,0 @@
package plugin
import (
"math/rand/v2"
"testing"
)
// Misty's two round-end procs on the turn-based surface.
//
// Both were built onto every turn-based combatant by DerivePlayerStats and then
// never read: stepRoundEnd had no counterpart to endOfRoundForSeat. The buff was
// simply lost. The debuff was an exploit — a player who declined Misty escaped
// her crowd entirely by fighting with !attack instead of letting the room
// auto-resolve, which needed no discovery at all.
//
// seatEndOfRound is the shared hook. These pin that it fires, that it cannot be
// dodged, and that a character with no Misty history is not touched at all —
// the property that keeps the sim corpus and the golden file still.
// mistyState seats one combatant at hp, cursor armed, with a seeded RNG.
func mistyState(t *testing.T, player *Combatant, hp int) *combatState {
t.Helper()
st := testCombatState(hp, 100, 1, rand.New(rand.NewPCG(7, 7)))
st.actor.c = player
st.actor.hpMax = player.Stats.MaxHP
return st
}
// The two procs at certainty, so no test depends on a roll.
func mistyCursed(maxHP, dmg int) *Combatant {
return &Combatant{
Name: "Cursed",
Stats: CombatStats{MaxHP: maxHP, AC: 10},
Mods: CombatModifiers{CrowdRevengeProc: 1.0, CrowdRevengeDmg: dmg},
}
}
func mistyBuffed(maxHP, heal int) *Combatant {
return &Combatant{
Name: "Buffed",
Stats: CombatStats{MaxHP: maxHP, AC: 10},
Mods: CombatModifiers{MistyHealProc: 1.0, MistyHealAmt: heal},
}
}
// ── the exploit ──────────────────────────────────────────────────────────────
// The debuff must land at round end in a manual fight, exactly as it does when
// the room auto-resolves. Before seatEndOfRound, !attack was a clean escape.
func TestSeatEndOfRound_CrowdRevengeCannotBeDodgedByFightingManually(t *testing.T) {
st := mistyState(t, mistyCursed(40, 6), 40)
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})
if over {
t.Fatal("a 6-damage swing against 40 HP should not end the fight")
}
if st.playerHP != 34 {
t.Errorf("playerHP = %d, want 34 — Misty's crowd took its swing", st.playerHP)
}
if !hasAction(st.events, "crowd_revenge") {
t.Error("no crowd_revenge event on the log")
}
}
// The debuff can be lethal, and a lethal one with nobody else standing ends the
// fight rather than leaving a corpse to take the next round's turn.
func TestSeatEndOfRound_CrowdRevengeCanEndASoloFight(t *testing.T) {
st := mistyState(t, mistyCursed(40, 30), 3)
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})
if st.playerHP != 0 {
t.Fatalf("playerHP = %d, want 0", st.playerHP)
}
if !over {
t.Error("a solo character dropped by the crowd should end the fight")
}
}
// ── the buff ─────────────────────────────────────────────────────────────────
// The heal fires, caps at max HP, and marks the result so the achievement can
// be granted.
func TestSeatEndOfRound_MistyHealFiresAndCaps(t *testing.T) {
st := mistyState(t, mistyBuffed(40, 12), 34)
var res CombatResult
seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &res)
if st.playerHP != 40 {
t.Errorf("playerHP = %d, want 40 — the heal caps at max HP", st.playerHP)
}
if !res.MistyHealed {
t.Error("MistyHealed flag not set")
}
if !hasAction(st.events, "misty_heal") {
t.Error("no misty_heal event on the log")
}
}
// A character the crowd just dropped is not then healed back up by the same
// hook. The heal is for the living.
func TestSeatEndOfRound_NoHealForACharacterTheCrowdJustDropped(t *testing.T) {
c := mistyCursed(40, 40)
c.Mods.MistyHealProc = 1.0
c.Mods.MistyHealAmt = 20
st := mistyState(t, c, 10)
st.actors = append(st.actors, &actor{playerHP: 5}) // an ally keeps the fight alive
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{})
if over {
t.Fatal("an ally is still standing — the fight is not over")
}
if st.playerHP != 0 {
t.Errorf("playerHP = %d, want 0 — the heal must not resurrect them", st.playerHP)
}
if hasAction(st.events, "misty_heal") {
t.Error("misty_heal fired on a downed character")
}
}
// ── the property that protects the corpus ────────────────────────────────────
// A character with no Misty history is untouched: no HP change, no events, and
// — because both procs short-circuit before st.randFloat() — no dice drawn. If
// the hook drew even one float, every simulated fight would diverge and
// combat_characterization.golden would move.
func TestSeatEndOfRound_UnbuffedCharacterIsUntouchedAndDrawsNoDice(t *testing.T) {
plain := &Combatant{Name: "Plain", Stats: CombatStats{MaxHP: 40, AC: 10}}
st := mistyState(t, plain, 40)
control := mistyState(t, plain, 40) // same seed, never passed to the hook
if over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, &CombatResult{}); over {
t.Fatal("an unbuffed character cannot be dropped by a hook that does nothing")
}
if st.playerHP != 40 || len(st.events) != 0 {
t.Errorf("unbuffed character was touched: hp=%d events=%d", st.playerHP, len(st.events))
}
// The RNG streams must still be in lockstep: the hook consumed nothing.
if got, want := st.randFloat(), control.randFloat(); got != want {
t.Errorf("seatEndOfRound consumed RNG: next float %v, want %v", got, want)
}
}
// ── the wiring ───────────────────────────────────────────────────────────────
// The unit tests above call seatEndOfRound directly, which proves the hook is
// correct but not that stepRoundEnd calls it. This drives the real session API
// through a round_end step. Comment the call out of stepRoundEnd and this test
// reports PlayerHP=40 — the exploit, exactly as it shipped.
func TestStepRoundEnd_WiresSeatEndOfRound(t *testing.T) {
setupEmptyTestDB(t)
player := mistyCursed(40, 6)
enemy := &Combatant{Name: "Target", Stats: CombatStats{MaxHP: 100, AC: 10}}
sess := &CombatSession{
SessionID: "wiring", UserID: "@u:example.org", Status: CombatStatusActive,
PlayerHP: 40, PlayerHPMax: 40, EnemyHP: 100,
Phase: CombatPhaseRoundEnd, Round: 1,
}
if _, err := advanceCombatSession(sess, player, enemy, PlayerAction{}); err != nil {
t.Fatal(err)
}
if sess.PlayerHP != 34 {
t.Errorf("PlayerHP = %d, want 34 — stepRoundEnd never ran the hook", sess.PlayerHP)
}
if !hasAction(sess.TurnLog, "crowd_revenge") {
t.Error("no crowd_revenge event persisted to the session log")
}
}

View File

@@ -979,12 +979,6 @@ func renderAllySeatEvent(e CombatEvent, name, enemyName string) string {
return down(fmt.Sprintf("☠️ %s takes %d from poison.", who, e.Damage)) return down(fmt.Sprintf("☠️ %s takes %d from poison.", who, e.Damage))
case "environmental": case "environmental":
return down(fmt.Sprintf("%s takes %d from the room.", who, e.Damage)) return down(fmt.Sprintf("%s takes %d from the room.", who, e.Damage))
case "crowd_revenge":
// Deliberately unattributed. An ally sees the damage land — silence
// would read as HP vanishing — but Misty's grudge is the owner's own
// discovery, and naming her here would spoil it for the whole party.
// Same reason misty_heal below reads as a plain recovery.
return down(fmt.Sprintf("%s takes %d.", who, e.Damage))
case "flat_damage": case "flat_damage":
return fmt.Sprintf("%s deals %d.", who, e.Damage) return fmt.Sprintf("%s deals %d.", who, e.Damage)
case "heal_item", "misty_heal": case "heal_item", "misty_heal":

View File

@@ -51,12 +51,9 @@ func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
// nat20/nat1 mood deltas from the whole fight's event log — the run's, so once. // nat20/nat1 mood deltas from the whole fight's event log — the run's, so once.
scanMoodEventsFromEvents(sess.RunID, sess.TurnLog) scanMoodEventsFromEvents(sess.RunID, sess.TurnLog)
// Every seat's HP lands on their sheet regardless of how the fight ended, and // Every seat's HP lands on their sheet regardless of how the fight ended.
// so does the bookkeeping that outlives the fight — a Berserker who raged and
// lost is still exhausted. Both fan out; neither is the owner's alone.
for seat := range sess.RosterSize() { for seat := range sess.RosterSize() {
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat)) persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
p.postCombatBookkeepingForSeat(sess, seat)
} }
switch sess.Status { switch sess.Status {
@@ -65,7 +62,8 @@ func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
case CombatStatusLost: case CombatStatusLost:
return p.finishPartyLoss(ct, zone, cadence) return p.finishPartyLoss(ct, zone, cadence)
case CombatStatusFled: case CombatStatusFled:
endRunOnLoss(owner, sess.RunID, false) _ = abandonZoneRun(owner)
forceExtractExpeditionForRunLoss(owner, "combat flee")
return p.eachSeat(ct, fmt.Sprintf( return p.eachSeat(ct, fmt.Sprintf(
"🏃 The party broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name)) "🏃 The party broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name))
default: default:
@@ -83,7 +81,16 @@ func (p *AdventurePlugin) finishPartyWin(
sess := ct.sess sess := ct.sess
owner := id.UserID(sess.UserID) owner := id.UserID(sess.UserID)
bossOnExpedition := p.applyOwnerWinEffects(owner, sess.EnemyID, elite) recordZoneKillForUser(owner, sess.EnemyID)
applyRoomCombatThreatForUser(owner, elite)
bossOnExpedition := false
if !elite {
if exp, eerr := getActiveExpedition(owner); eerr == nil && exp != nil {
_ = applyBossDefeatThreat(exp.ID)
bossOnExpedition = true
}
}
tier := 1 tier := 1
if run != nil { if run != nil {
@@ -100,8 +107,14 @@ func (p *AdventurePlugin) finishPartyWin(
uid := id.UserID(sess.seatUserID(seat)) uid := id.UserID(sess.seatUserID(seat))
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat) hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
// A member who went down before the killing blow still earns the kill. // A member who went down before the killing blow still earns the kill
p.grantSeatWinXP(uid, hp, hpMax, monster, tier) // but at a fifth of their pool or less, the XP path calls it near-death.
nearDeath := hpMax > 0 && hp*5 < hpMax
if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 {
if _, err := p.grantDnDXP(uid, xp); err != nil {
slog.Error("combat: party grantDnDXP", "user", uid, "err", err)
}
}
var b strings.Builder var b strings.Builder
if shared != "" && seat == 0 { if shared != "" && seat == 0 {
@@ -140,7 +153,11 @@ func (p *AdventurePlugin) finishPartyLoss(ct *combatTurn, zone ZoneDefinition, c
sess := ct.sess sess := ct.sess
owner := id.UserID(sess.UserID) owner := id.UserID(sess.UserID)
endRunOnLoss(owner, sess.RunID, true) if run, _ := getZoneRun(sess.RunID); run != nil {
_, _ = applyMoodEvent(sess.RunID, MoodEventPlayerDeath)
}
_ = abandonZoneRun(owner)
forceExtractExpeditionForRunLoss(owner, "combat death")
line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence) line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence)
out := make([]string, sess.RosterSize()) out := make([]string, sess.RosterSize())
@@ -164,62 +181,3 @@ func (p *AdventurePlugin) eachSeat(ct *combatTurn, block string) []string {
} }
return out return out
} }
// ── Shared terminal effects (item D) ─────────────────────────────────────────
//
// finishCombatSession (solo, combat_cmd.go) and finishPartyWin/finishPartyLoss
// (party, above) still write their own player-facing text — the solo line says
// "You finished", the party line says "The party stands", and the death-on-win
// rule is deliberately roster-size-dependent (see item E). But the *effects*
// they fire were hand-copied lists, and item A drifted exactly there. These
// helpers are the single copy of each effect; both close-outs route through
// them so the effect lists cannot diverge again.
// applyOwnerWinEffects fires the terminal effects a win owes the run and
// expedition — the zone-kill record, room threat, and the boss-defeat threat
// drop — once, through the row's owner (a party member owns neither row).
// Returns whether the kill was a zone boss on an active expedition; both
// close-outs use it to reframe the final line as the expedition's climax.
func (p *AdventurePlugin) applyOwnerWinEffects(owner id.UserID, enemyID string, elite bool) (bossOnExpedition bool) {
recordZoneKillForUser(owner, enemyID)
applyRoomCombatThreatForUser(owner, elite)
if !elite {
// §8.1 — a zone boss defeat drops expedition threat. Silent no-op for a
// standalone zone run with no active expedition.
if exp, eerr := getActiveExpedition(owner); eerr == nil && exp != nil {
_ = applyBossDefeatThreat(exp.ID)
bossOnExpedition = true
}
}
return bossOnExpedition
}
// grantSeatWinXP pays one combatant their kill XP. A seat that finished at a
// fifth of its pool or less takes the near-death path. Full XP per person,
// solo or party, per the accessibility-over-crunch stance.
func (p *AdventurePlugin) grantSeatWinXP(uid id.UserID, hp, hpMax int, monster DnDMonsterTemplate, tier int) {
nearDeath := hpMax > 0 && hp*5 < hpMax
if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 {
if _, err := p.grantDnDXP(uid, xp); err != nil {
slog.Error("combat: grantDnDXP", "user", uid, "err", err)
}
}
}
// endRunOnLoss tears down the owner's run and wrapping expedition when a fight
// ends in death or flight. On a death (not a flee) it also stamps the run's
// mood event. Owner-scoped, fires once; the per-seat death mark stays with the
// caller, since it is decided per HP and gated on roster size.
func endRunOnLoss(owner id.UserID, runID string, death bool) {
if death {
if run, _ := getZoneRun(runID); run != nil {
_, _ = applyMoodEvent(runID, MoodEventPlayerDeath)
}
}
_ = abandonZoneRun(owner)
reason := "combat flee"
if death {
reason = "combat death"
}
forceExtractExpeditionForRunLoss(owner, reason)
}

View File

@@ -58,10 +58,19 @@ func (p *AdventurePlugin) buildFightSeats(
} }
for i, uid := range roster { for i, uid := range roster {
leader := i == 0 leader := i == 0
player, e, _, err := p.buildZoneCombatants(uid, monster, tier, dmMood)
if err != nil {
if leader {
return nil, nil, "", "Couldn't set up the fight: " + err.Error()
}
slog.Warn("combat: party member left out of fight", "user", uid, "err", err)
skip(uid, "Couldn't bring you into the fight: "+err.Error())
continue
}
if leader {
enemy = &e
}
// Both refusals below are cheap and neither needs the build, so they run
// before it: consuming a seat's armed ability and *then* sitting them out
// would spend their rage on a fight they never joined.
hp, hpMax := dndHPSnapshot(uid) hp, hpMax := dndHPSnapshot(uid)
if hp <= 0 { if hp <= 0 {
if leader { if leader {
@@ -87,32 +96,7 @@ func (p *AdventurePlugin) buildFightSeats(
continue continue
} }
// Consumed exactly once for the fight, here. Every later rebuild seats = append(seats, CombatSeatSetup{UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player})
// re-applies this id off the seat's statuses rather than re-arming.
armed := ""
if c, cerr := LoadDnDCharacter(uid); cerr == nil && c != nil {
trySimAutoArm(c)
armed = consumeArmedAbility(c)
}
player, e, _, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed)
if err != nil {
if leader {
return nil, nil, "", "Couldn't set up the fight: " + err.Error()
}
slog.Warn("combat: party member left out of fight", "user", uid, "err", err)
skip(uid, "Couldn't bring you into the fight: "+err.Error())
continue
}
if leader {
enemy = &e
}
if armed != "" {
slog.Info("combat: armed ability fired (turn-based start)", "user", uid, "ability", armed)
}
seats = append(seats, CombatSeatSetup{
UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player, ArmedAbility: armed,
})
} }
return seats, enemy, senderSkip, "" return seats, enemy, senderSkip, ""
} }

View File

@@ -88,24 +88,6 @@ type ActorStatuses struct {
// and druids with no sustained DPS once their burst landed. // and druids with no sustained DPS once their burst landed.
ConcentrationDmg int `json:"concentration_dmg,omitempty"` ConcentrationDmg int `json:"concentration_dmg,omitempty"`
// ArmedAbility is the id of the active ability this character armed and
// spent entering the fight (rage, second_wind, …). The resource is already
// debited and the character disarmed; this is the record that lets every
// rebuild of an in-flight fight re-apply the ability's mods, and lets the
// close-out know a rage fired. Empty when nothing was armed.
ArmedAbility string `json:"armed_ability,omitempty"`
// GrimHarvestSlot / GrimHarvestNecrotic snapshot the most recent damaging
// spell this seat cast, for the Necromancy Mage's post-combat kill-heal.
// The auto-resolve path carries the same pair on CombatModifiers, stashed
// once by applyPendingCast; the turn-based path can cast every round, so
// the stash lives here and each damaging cast overwrites it. Whether the
// heal actually fires is decided at close-out by grimHarvestHeal, which
// asks whether the *last* spell_cast event is the one that dropped the
// enemy to 0 — so a stale stash from an earlier, non-lethal cast is inert.
GrimHarvestSlot int `json:"grim_harvest_slot,omitempty"`
GrimHarvestNecrotic bool `json:"grim_harvest_necrotic,omitempty"`
// Once-per-fight class/race/subclass one-shots: the "already used" flags. // Once-per-fight class/race/subclass one-shots: the "already used" flags.
// Without persistence these reset every round on resume, letting a Halfling // Without persistence these reset every round on resume, letting a Halfling
// reroll a nat 1 or an Orc rage every single round of a turn-based fight. // reroll a nat 1 or an Orc rage every single round of a turn-based fight.
@@ -239,26 +221,23 @@ func (s *ActorStatuses) applyBuffDelta(d turnBuffDelta) {
// the Abjuration Arcane Ward is normally non-zero at fight start — the // the Abjuration Arcane Ward is normally non-zero at fight start — the
// turn-based build deliberately omits pre-combat consumables and queued casts — // turn-based build deliberately omits pre-combat consumables and queued casts —
// but the full set is seeded for robustness. Returns true if anything was set. // but the full set is seeded for robustness. Returns true if anything was set.
func seedCombatSessionOneShots(s *CombatSession, seat CombatSeatSetup) bool { func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) bool {
return seedActorOneShots(&s.Statuses.ActorStatuses, seat) return seedActorOneShots(&s.Statuses.ActorStatuses, playerMods)
} }
// seedActorOneShots copies one character's fight-start one-shot resources onto // seedActorOneShots copies one character's fight-start one-shot resources onto
// their persisted statuses. Seat 0's live on the session row; a party member's // their persisted statuses. Seat 0's live on the session row; a party member's
// live on their participant row, and each seat reads its own combatant's mods — // live on their participant row, and each seat reads its own combatant's mods —
// a party Abjurer brings their own Arcane Ward, not the leader's. // a party Abjurer brings their own Arcane Ward, not the leader's.
func seedActorOneShots(st *ActorStatuses, seat CombatSeatSetup) bool { func seedActorOneShots(st *ActorStatuses, playerMods CombatModifiers) bool {
playerMods := seat.Mods
st.WardCharges = playerMods.WardCharges st.WardCharges = playerMods.WardCharges
st.SporeRounds = playerMods.SporeCloud st.SporeRounds = playerMods.SporeCloud
st.ReflectFrac = playerMods.ReflectNext st.ReflectFrac = playerMods.ReflectNext
st.AutoCritFirst = playerMods.AutoCritFirst st.AutoCritFirst = playerMods.AutoCritFirst
st.ArcaneWardHP = playerMods.ArcaneWardHP st.ArcaneWardHP = playerMods.ArcaneWardHP
st.HealChargesLeft = playerMods.HealItemCharges st.HealChargesLeft = playerMods.HealItemCharges
st.ArmedAbility = seat.ArmedAbility
return st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 || return st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 || st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0
st.ArmedAbility != ""
} }
// CombatParticipant is one party member's seat in a fight, from seat 1 up. // CombatParticipant is one party member's seat in a fight, from seat 1 up.
@@ -488,10 +467,7 @@ func (p *AdventurePlugin) startPartyCombatSession(
if len(seats) == 0 { if len(seats) == 0 {
return nil, fmt.Errorf("start combat session: empty roster") return nil, fmt.Errorf("start combat session: empty roster")
} }
// Party-only enemy HP bump (solo roster scales by 1.0). The per-turn rebuild enemyHP := enemy.Stats.MaxHP
// in partyCombatantsForSession applies the identical scalar to the enemy's
// Stats.MaxHP, so the persisted current HP and the rebuilt max never drift.
enemyHP := scaledEnemyMaxHP(enemy.Stats.MaxHP, len(seats))
owner := seats[0] owner := seats[0]
sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID, sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID,
owner.HP, owner.HPMax, enemyHP, enemyHP) owner.HP, owner.HPMax, enemyHP, enemyHP)
@@ -501,13 +477,13 @@ func (p *AdventurePlugin) startPartyCombatSession(
// Seat 0's one-shots live on the session row; seeding them is a mutation of // Seat 0's one-shots live on the session row; seeding them is a mutation of
// sess.Statuses that the save below flushes along with the participants. // sess.Statuses that the save below flushes along with the participants.
dirty := seedCombatSessionOneShots(sess, owner) dirty := seedCombatSessionOneShots(sess, owner.Mods)
if len(seats) > 1 { if len(seats) > 1 {
ps := make([]CombatParticipant, 0, len(seats)-1) ps := make([]CombatParticipant, 0, len(seats)-1)
for i, s := range seats[1:] { for i, s := range seats[1:] {
var st ActorStatuses var st ActorStatuses
seedActorOneShots(&st, s) seedActorOneShots(&st, s.Mods)
ps = append(ps, CombatParticipant{ ps = append(ps, CombatParticipant{
Seat: i + 1, UserID: string(s.UserID), HP: s.HP, HPMax: s.HPMax, Statuses: st, Seat: i + 1, UserID: string(s.UserID), HP: s.HP, HPMax: s.HPMax, Statuses: st,
}) })
@@ -549,10 +525,6 @@ type CombatSeatSetup struct {
HPMax int HPMax int
Mods CombatModifiers Mods CombatModifiers
C *Combatant C *Combatant
// ArmedAbility is the ability id this seat consumed entering the fight, ""
// if they armed nothing. It is persisted onto the seat's statuses so every
// later rebuild can re-apply the ability without re-spending it.
ArmedAbility string
} }
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil). // getActiveCombatSession returns the player's in-flight fight, or (nil, nil).

View File

@@ -33,18 +33,11 @@ import (
// + armed ability) and the monster's bestiary stat block with tier scaling and // + armed ability) and the monster's bestiary stat block with tier scaling and
// the DM-mood combat tilt folded in. The returned dndChar is handed back so // the DM-mood combat tilt folded in. The returned dndChar is handed back so
// callers can run post-combat subclass persistence without reloading it. // callers can run post-combat subclass persistence without reloading it.
//
// armed is the ability id already consumed for this fight (consumeArmedAbility,
// or armAbilityForFight's return on the auto-resolve paths); "" means none. The
// build re-applies it rather than consuming, because the turn-based path calls
// this once per player command and a consuming build would spend the ability on
// round 1 and drop it for the rest of the fight.
func (p *AdventurePlugin) buildZoneCombatants( func (p *AdventurePlugin) buildZoneCombatants(
userID id.UserID, userID id.UserID,
monster DnDMonsterTemplate, monster DnDMonsterTemplate,
tier int, tier int,
dmMood int, dmMood int,
armed string,
) (player Combatant, enemy Combatant, dndChar *DnDCharacter, err error) { ) (player Combatant, enemy Combatant, dndChar *DnDCharacter, err error) {
tilt := dmMoodCombatTilt(dmMood) tilt := dmMoodCombatTilt(dmMood)
char, err := loadAdvCharacter(userID) char, err := loadAdvCharacter(userID)
@@ -71,7 +64,10 @@ func (p *AdventurePlugin) buildZoneCombatants(
applyRacePassives(&playerStats, &playerMods, dndChar) applyRacePassives(&playerStats, &playerMods, dndChar)
applySubclassPassives(&playerStats, &playerMods, dndChar) applySubclassPassives(&playerStats, &playerMods, dndChar)
applyMagicItemEffects(&playerStats, &playerMods, userID) applyMagicItemEffects(&playerStats, &playerMods, userID)
applyAbilityByID(dndChar, armed, &playerMods) trySimAutoArm(dndChar)
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
slog.Info("dnd: armed ability fired (turn-based build)", "user", userID, "ability", firedName)
}
enemyStats, enemyMods := monster.toCombatStats() enemyStats, enemyMods := monster.toCombatStats()
// Tier scaling mirrors runZoneCombat: only raise AC/AttackBonus to a // Tier scaling mirrors runZoneCombat: only raise AC/AttackBonus to a
@@ -156,11 +152,7 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
players := make([]*Combatant, len(seats)) players := make([]*Combatant, len(seats))
var enemy Combatant var enemy Combatant
for seat, uid := range seats { for seat, uid := range seats {
// The ability this seat armed was consumed once, at fight start, and its player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood)
// id parked on their statuses. Re-applying it here — not re-consuming —
// is what makes a rage last the whole fight instead of one round.
st := sess.actorStatusesForSeat(seat)
player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood, st.ArmedAbility)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err) return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
} }
@@ -169,107 +161,18 @@ func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Com
// one-shots (ward/spore/…) live on their persisted statuses and flow // one-shots (ward/spore/…) live on their persisted statuses and flow
// through the turn engine's resume/commit cycle, so only the persistent // through the turn engine's resume/commit cycle, so only the persistent
// stat deltas are applied here — and only that seat's own. // stat deltas are applied here — and only that seat's own.
applySessionBuffs(&player, st) applySessionBuffs(&player, sess.actorStatusesForSeat(seat))
players[seat] = &player players[seat] = &player
if seat == 0 { if seat == 0 {
// The enemy build reads only (monster, tier, dmMood): every seat // The enemy build reads only (monster, tier, dmMood): every seat
// rebuilds the identical stat block, so seat 0's copy is the fight's. // rebuilds the identical stat block, so seat 0's copy is the fight's.
// Only the *player* half of the build varies by seat. // Only the *player* half of the build varies by seat.
enemy = e enemy = e
// 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 (roster 1) scales by 1.0.
if sess.IsParty() {
enemy.Stats.MaxHP = scaledEnemyMaxHP(enemy.Stats.MaxHP, sess.RosterSize())
}
} }
} }
return players, &enemy, nil return players, &enemy, nil
} }
// seatFightStartMods re-derives the modifiers a finished fight's close-out still
// needs: the Berserker's rage flag, which decides whether the character owes a
// point of exhaustion, and the Necromancy Mage's Grim Harvest stash.
//
// It re-applies the seat's armed ability to an empty mod set rather than
// rebuilding the whole combatant: no Apply writes to the character (they read
// level and HP and write only mods), so this is pure, and the passive/equipment
// layers a full build would add are not read by any post-combat hook.
//
// The Grim Harvest pair is not re-derived but read back off the seat's
// statuses, where castActionForSeat parked it: the spell that stashed it was
// cast mid-fight, not at fight start, and nothing in the character sheet still
// remembers which one it was.
func seatFightStartMods(sess *CombatSession, seat int, c *DnDCharacter) CombatModifiers {
var mods CombatModifiers
st := sess.actorStatusesForSeat(seat)
applyAbilityByID(c, st.ArmedAbility, &mods)
mods.GrimHarvestSlot = st.GrimHarvestSlot
mods.GrimHarvestNecrotic = st.GrimHarvestNecrotic
return mods
}
// seatCombatResult reconstructs, for one seat of a finished turn-based fight,
// the slice of CombatResult that the post-combat hooks actually read. The turn
// engine never builds a CombatResult — it persists a session and a shared event
// log — so the close-out has to assemble one.
//
// MistyHealed is read back off the seat's event log rather than off a flag: the
// turn engine's CombatResult is a scratch value it discards between steps, and
// the log is the only thing that survives to the close-out. This is how
// combat_pet_save has always been detected.
//
// SniperKilled stays false. Arina's proc is a pre-combat one-shot, not a
// round-end effect, so seatEndOfRound does not carry it and the turn engine
// still has no place to fire it — combat_sniper_kill remains unreachable from a
// manual kill.
//
// NearDeath mirrors the auto-resolve engine's win threshold (below 15% of max);
// its loss-side meaning is unused here, since the only hook that reads it gates
// on PlayerWon.
func seatCombatResult(sess *CombatSession, seat int) CombatResult {
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
won := sess.Status == CombatStatusWon
events := eventsForSeat(sess.TurnLog, seat)
return CombatResult{
PlayerWon: won,
Events: events,
PlayerEndHP: hp,
EnemyEndHP: sess.EnemyHP,
TotalRounds: sess.Round,
NearDeath: won && hp > 0 && float64(hp) < float64(max(1, hpMax))*0.15,
MistyHealed: hasAction(events, "misty_heal"),
}
}
// hasAction reports whether the event log holds at least one event with the
// given Action. The one-line "did this happen in the fight?" scan the close-out
// uses to read a proc's outcome off the log rather than a flag.
func hasAction(events []CombatEvent, action string) bool {
for _, e := range events {
if e.Action == action {
return true
}
}
return false
}
// postCombatBookkeepingForSeat is postCombatBookkeeping for one seat of a
// terminal turn-based session — the bridge between what the turn engine
// persisted and what the shared close-out expects.
func (p *AdventurePlugin) postCombatBookkeepingForSeat(sess *CombatSession, seat int) {
uid := id.UserID(sess.seatUserID(seat))
// Loaded after the caller's persistDnDHPAfterCombat, so a Grim-Harvest-style
// hook that heals off HPCurrent reads the fight's real ending HP.
c, err := LoadDnDCharacter(uid)
if err != nil || c == nil {
slog.Error("combat: post-combat bookkeeping skipped, no sheet", "user", uid, "err", err)
return
}
mods := seatFightStartMods(sess, seat, c)
p.postCombatBookkeeping(uid, c, mods.BerserkerRage, seatCombatResult(sess, seat), mods)
}
// applySessionBuffs re-derives the persistent stat effect of every mid-fight // applySessionBuffs re-derives the persistent stat effect of every mid-fight
// buff onto one rebuilt character. The buffs are stored as accumulated deltas // buff onto one rebuilt character. The buffs are stored as accumulated deltas
// (diffTurnBuff produced them against that character's state at cast time), so // (diffTurnBuff produced them against that character's state at cast time), so

View File

@@ -454,23 +454,15 @@ func TestApplySessionBuffs(t *testing.T) {
func TestSeedCombatSessionOneShots(t *testing.T) { func TestSeedCombatSessionOneShots(t *testing.T) {
// Arcane Ward is the one resource normally live at fight start. // Arcane Ward is the one resource normally live at fight start.
s := &CombatSession{} s := &CombatSession{}
if !seedCombatSessionOneShots(s, CombatSeatSetup{Mods: CombatModifiers{ArcaneWardHP: 15}}) { if !seedCombatSessionOneShots(s, CombatModifiers{ArcaneWardHP: 15}) {
t.Error("seed should report true when Arcane Ward is present") t.Error("seed should report true when Arcane Ward is present")
} }
if s.Statuses.ArcaneWardHP != 15 { if s.Statuses.ArcaneWardHP != 15 {
t.Errorf("ArcaneWardHP = %d, want 15", s.Statuses.ArcaneWardHP) t.Errorf("ArcaneWardHP = %d, want 15", s.Statuses.ArcaneWardHP)
} }
// The armed ability rides along, so a rebuild mid-fight can re-apply it.
armed := &CombatSession{}
if !seedCombatSessionOneShots(armed, CombatSeatSetup{ArmedAbility: "rage"}) {
t.Error("seed should report true when an ability was armed")
}
if armed.Statuses.ArmedAbility != "rage" {
t.Errorf("ArmedAbility = %q, want rage", armed.Statuses.ArmedAbility)
}
// Nothing to seed → false, statuses untouched. // Nothing to seed → false, statuses untouched.
empty := &CombatSession{} empty := &CombatSession{}
if seedCombatSessionOneShots(empty, CombatSeatSetup{}) { if seedCombatSessionOneShots(empty, CombatModifiers{}) {
t.Error("seed should report false with no fight-start resources") t.Error("seed should report false with no fight-start resources")
} }
} }

View File

@@ -157,33 +157,12 @@ func DerivePlayerStats(
} }
} }
// Pet modifiers. Two pets each contribute at half weight — the combat mods // Pet modifiers
// are an average over the active pets, so a pair reads as roughly one full
// pet (flavor-forward, not a stat spike). A lone pet averages over one
// element, so x/1==x and 0.0+x==x reproduce the former values exactly,
// keeping the single-pet combat golden byte-identical. The engines roll one
// attack/deflect/whiff off these mods per round regardless of pet count, so
// the RNG draw order is unchanged too.
pets := make([]struct{ level, armor int }, 0, 2)
if char.HasPet() { if char.HasPet() {
pets = append(pets, struct{ level, armor int }{char.PetLevel, char.PetArmorTier}) mods.PetAttackProc = petAttackChance(char.PetLevel)
} mods.PetAttackDmg = 3 + char.PetLevel // per-attack variance added in engine
if char.HasPet2() { mods.PetDeflectProc = petDeflectChance(char.PetLevel, char.PetArmorTier)
pets = append(pets, struct{ level, armor int }{char.Pet2Level, char.Pet2ArmorTier}) mods.PetWhiffProc = 0.01 + float64(char.PetLevel)*0.005
}
if n := len(pets); n > 0 {
var atk, defl, whiff float64
levelSum := 0
for _, pt := range pets {
atk += petAttackChance(pt.level)
defl += petDeflectChance(pt.level, pt.armor)
whiff += 0.01 + float64(pt.level)*0.005
levelSum += pt.level
}
mods.PetAttackProc = atk / float64(n)
mods.PetDeflectProc = defl / float64(n)
mods.PetWhiffProc = whiff / float64(n)
mods.PetAttackDmg = 3 + (levelSum*2+n)/(2*n) // 3 + rounded average level
} }
if char.PetMorningDefense { if char.PetMorningDefense {
mods.DamageReduct *= 0.95 // 5% less damage mods.DamageReduct *= 0.95 // 5% less damage

View File

@@ -414,11 +414,10 @@ func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) {
te.stepPlayerTurn(action) te.stepPlayerTurn(action)
te.stampSeat(0, acting) te.stampSeat(0, acting)
case CombatPhaseEnemyTurn: case CombatPhaseEnemyTurn:
// stepEnemyTurn self-stamps each attack-action to the seat it targeted: a
// party's enemy re-targets across the roster within one turn, so there is
// no single seat the whole phase lands on. Solo stamps seat 0 throughout,
// exactly as the old blanket stamp did.
te.stepEnemyTurn() te.stepEnemyTurn()
// stepEnemyTurn seats its target before anything resolves, and the whole
// phase lands on that one character.
te.stampSeat(0, te.st.seatIdx)
case CombatPhaseRoundEnd: case CombatPhaseRoundEnd:
te.stepRoundEnd() te.stepRoundEnd()
case CombatPhaseOver: case CombatPhaseOver:
@@ -616,39 +615,34 @@ func (te *turnEngine) stepEnemyTurn() {
return return
} }
te.st.seat(target) te.st.seat(target)
// A control spell cast last phase forfeits the enemy's whole turn (every // A control spell cast last phase forfeits the enemy's attack this round —
// attack-action) this round — unless the enemy is fear_immune, in which case // unless the enemy is fear_immune, in which case the control fizzled and it
// the control fizzled and it acts as normal. // acts as normal.
if te.st.enemySkipFirst { if te.st.enemySkipFirst {
te.st.enemySkipFirst = false te.st.enemySkipFirst = false
mark := len(te.st.events)
if enemyImmuneToControl(te.enemy, te.st) { if enemyImmuneToControl(te.enemy, te.st) {
te.st.events = append(te.st.events, CombatEvent{ te.st.events = append(te.st.events, CombatEvent{
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "fear_resist", Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "fear_resist",
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP, PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
}) })
te.stampSeat(mark, target)
} else { } else {
te.st.events = append(te.st.events, CombatEvent{ te.st.events = append(te.st.events, CombatEvent{
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held", Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held",
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP, PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
}) })
te.stampSeat(mark, target)
te.advance() te.advance()
return return
} }
} }
// Monster ability fires once, at the top of the enemy turn, against the // Monster ability fires at the top of the enemy turn. cleave / lifesteal
// initial target. cleave / lifesteal resolve their own damage and stand in // resolve their own damage and stand in for the attack action this round;
// for the enemy's first attack-action this round; every other effect (poison // every other effect (poison / stun / enrage / armor_break) is a rider that
// / stun / enrage / armor_break) is a rider that leaves the multiattack below // leaves the multiattack below intact. applyAbility returns true when the
// intact. applyAbility returns true when the player went down without a save. // player went down without a save.
abilityDealtDamage := false abilityDealtDamage := false
if te.enemy.Ability != nil && turnAbilityFires(te.enemy.Ability, te.st, te.enemy) { if te.enemy.Ability != nil && turnAbilityFires(te.enemy.Ability, te.st, te.enemy) {
mark := len(te.st.events)
if applyAbility(te.st, te.st.c, te.enemy, &turnCombatPhase, te.result) { if applyAbility(te.st, te.st.c, te.enemy, &turnCombatPhase, te.result) {
te.stampSeat(mark, target)
// The target went down without a save. The fight only ends if it // The target went down without a save. The fight only ends if it
// took the last member with it; otherwise the enemy has spent its // took the last member with it; otherwise the enemy has spent its
// turn on that kill. // turn on that kill.
@@ -659,88 +653,55 @@ func (te *turnEngine) stepEnemyTurn() {
} }
return return
} }
te.stampSeat(mark, target)
switch te.enemy.Ability.Effect { switch te.enemy.Ability.Effect {
case "cleave", "lifesteal": case "cleave", "lifesteal":
abilityDealtDamage = true abilityDealtDamage = true
} }
} }
// The enemy takes enemyActionsThisRound() attack-actions, each its full if !abilityDealtDamage {
// SRD multiattack, re-targeting a fresh standing seat per action so a party's // Pet defensive procs are a single proc per enemy turn: roll once, then
// damage is spread across the roster rather than pinning the first target. // spend it on the first swing only. Whiff makes that one swing a
// A solo roster is exactly one action against its one seat — no re-target is // guaranteed miss; deflect halves its damage. Against a multiattack the
// drawn — so its event stream and RNG draws are unchanged. When the ability // remaining swings resolve normally — a single proc shouldn't nullify a
// already dealt damage it was the first action, so one fewer follows; for solo // boss's whole multiattack round. (This deliberately diverges from the
// that collapses to the old "the ability stood in for the attack" skip. // auto-resolve engine's apply-to-all model.)
actions, reuseFirstTarget := enemyActionPlan(te.st, abilityDealtDamage)
for a := 0; a < actions; a++ {
if !(reuseFirstTarget && a == 0) {
tgt, alive := te.enemyTarget()
if !alive {
te.finish(CombatStatusLost)
return
}
te.st.seat(tgt)
target = tgt
}
if te.enemyAttackAction(target) {
return
}
}
te.advance()
}
// enemyAttackAction resolves one enemy attack-action — the full SRD multiattack
// profile — against the seat the cursor already points at, and stamps every event
// it emits to that seat (the party's enemy re-targets across the roster within one
// turn, so the whole-turn blanket stamp in step() no longer holds). Registered
// elites/bosses swing their full profile; everyone else gets a single attack from
// the template stats.
//
// Returns true only when it decided the fight (te.finish has already been called),
// so the caller ends the enemy turn. It returns false when the target dropped but
// the roster is still alive — this action is over, and the caller re-targets the
// next one.
func (te *turnEngine) enemyAttackAction(seat int) (finished bool) {
mark := len(te.st.events)
// Pet defensive procs are a single proc per attack-action: roll once, then
// spend on the first swing only. Whiff makes that one swing a guaranteed miss;
// deflect halves its damage. Against a multiattack the remaining swings resolve
// normally — a single proc shouldn't nullify a boss's whole multiattack.
// (This deliberately diverges from the auto-resolve engine's apply-to-all.)
petWhiff := te.st.c.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.st.c.Mods.PetWhiffProc petWhiff := te.st.c.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.st.c.Mods.PetWhiffProc
petDeflect := te.st.c.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.st.c.Mods.PetDeflectProc petDeflect := te.st.c.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.st.c.Mods.PetDeflectProc
if petDeflect { if petDeflect {
te.result.PetDeflected = true te.result.PetDeflected = true
} }
// SRD multiattack: each profile entry is one attack roll resolved
// through the shared primitive. A registered elite/boss swings its full
// profile; everyone else gets a single attack from the template stats.
// resolveEnemyAttack returns true when the fight is decided — either the
// player went down without a death save, or a reflect consumable killed
// the enemy. Disambiguate by inspecting HP.
for i, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) { for i, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) {
swing := *te.enemy swing := *te.enemy
swing.Stats.Attack = atk.Damage swing.Stats.Attack = atk.Damage
swing.Stats.AttackBonus = atk.AttackBonus swing.Stats.AttackBonus = atk.AttackBonus
// Spend the proc on the first swing only; later swings see false.
swingWhiff := petWhiff && i == 0 swingWhiff := petWhiff && i == 0
swingDeflect := petDeflect && i == 0 swingDeflect := petDeflect && i == 0
decided := resolveEnemyAttack(te.st, te.st.c, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false) decided := resolveEnemyAttack(te.st, te.st.c, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false)
if te.st.playerHP <= 0 { if te.st.playerHP <= 0 {
te.stampSeat(mark, seat) // The target is down. The enemy stops swinging at a corpse; the
// The target is down. The enemy stops swinging at a corpse; the fight // fight ends only if the roster is empty.
// ends only if the roster is empty, otherwise this action is over and
// the caller re-targets.
if !te.st.anyAlive() { if !te.st.anyAlive() {
te.finish(CombatStatusLost) te.finish(CombatStatusLost)
return true return
} }
return false break
} }
if decided || te.st.enemyHP <= 0 { if decided || te.st.enemyHP <= 0 {
te.stampSeat(mark, seat)
te.finish(CombatStatusWon) te.finish(CombatStatusWon)
return true return
} }
} }
te.stampSeat(mark, seat) }
return false te.advance()
} }
// turnAbilityFires decides whether a monster ability triggers this enemy turn. // turnAbilityFires decides whether a monster ability triggers this enemy turn.
@@ -795,9 +756,7 @@ func (te *turnEngine) stepRoundEnd() {
// Concentration aura (Spirit Guardians et al.): the lingering spell bites // Concentration aura (Spirit Guardians et al.): the lingering spell bites
// the enemy each round it stays up. Concentration is per-caster, so every // the enemy each round it stays up. Concentration is per-caster, so every
// seat holding one pulses. Ticks before enemy regen so a lethal pulse // seat holding one pulses. Ticks before enemy regen so a lethal pulse
// settles the fight before the enemy knits its wounds back — and before // settles the fight before the enemy knits its wounds back.
// Misty's crowd swings, so a caster whose aura would end the round is not
// robbed of the win by an end-of-round debuff.
for i := range st.actors { for i := range st.actors {
st.seat(i) st.seat(i)
if st.concentrationDmg <= 0 || st.enemyHP <= 0 || st.playerHP <= 0 { if st.concentrationDmg <= 0 || st.enemyHP <= 0 || st.playerHP <= 0 {
@@ -813,24 +772,6 @@ func (te *turnEngine) stepRoundEnd() {
return return
} }
} }
// Misty's crowd, then Misty's heal — per seat, after the round's other
// damage (including the concentration pulse above) has landed so the heal
// can answer it, which is the order endOfRoundForSeat uses. Both no-op (and
// draw no RNG) for a character with no Misty history, which is every
// simulated one.
for i := range st.actors {
st.seat(i)
if st.playerHP <= 0 {
continue
}
mark := len(st.events)
over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, te.result)
te.stampSeat(mark, i)
if over {
te.finish(CombatStatusLost)
return
}
}
st.seat(0) st.seat(0)
// Regenerate (monster ability): the enemy knits its wounds at round end. // Regenerate (monster ability): the enemy knits its wounds at round end.
if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP { if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP {

View File

@@ -410,52 +410,22 @@ func trySimAutoArm(c *DnDCharacter) string {
return ab.Name return ab.Name
} }
// consumeArmedAbility disarms the character, returning the ability id that was // applyArmedAbility checks for a pre-armed ability on the character and
// armed. It is the *mutating* half of arming: the flag is cleared and saved, so // applies its effect to the player's CombatModifiers, then clears the armed
// it must run exactly once per fight, at fight start. // flag. Called from combat_bridge.go before SimulateCombat.
// func applyArmedAbility(c *DnDCharacter, mods *CombatModifiers) (string, bool) {
// It is split from applyAbilityByID because a turn-based fight rebuilds its
// combatants from the DB on every player command. A rebuild that consumed would
// fire the ability on round 1, clear the flag, and then hand every later round a
// character with no ability at all — the player pays the resource for one round
// of a buff that is supposed to span the fight. So the fight consumes once and
// persists the id on the session; each rebuild re-applies it from there.
//
// An id that is no longer in the ability table is disarmed and reported as "".
func consumeArmedAbility(c *DnDCharacter) string {
if c == nil || c.ArmedAbility == "" { if c == nil || c.ArmedAbility == "" {
return ""
}
armed := c.ArmedAbility
c.ArmedAbility = ""
_ = SaveDnDCharacter(c)
if _, ok := dndActiveAbilities[armed]; !ok {
return ""
}
return armed
}
// applyAbilityByID folds an ability's effect into a freshly-derived set of
// CombatModifiers. It is pure with respect to persistence — no DB write, no
// disarm — so it is safe to call on every rebuild of an in-flight fight.
//
// abilityID is what consumeArmedAbility returned at fight start. Empty (nobody
// armed anything) and unknown ids are both no-ops.
func applyAbilityByID(c *DnDCharacter, abilityID string, mods *CombatModifiers) (string, bool) {
if c == nil || abilityID == "" {
return "", false return "", false
} }
ab, ok := dndActiveAbilities[abilityID] ab, ok := dndActiveAbilities[c.ArmedAbility]
if !ok { if !ok {
c.ArmedAbility = ""
_ = SaveDnDCharacter(c)
return "", false return "", false
} }
ab.Apply(c, mods) ab.Apply(c, mods)
return ab.Name, true firedName := ab.Name
} c.ArmedAbility = ""
_ = SaveDnDCharacter(c)
// armAbilityForFight consumes whatever the character armed and applies it in one return firedName, true
// step, for the auto-resolve callers that build a combatant and immediately
// fight with it. A turn-based fight must not use this — see consumeArmedAbility.
func armAbilityForFight(c *DnDCharacter, mods *CombatModifiers) (string, bool) {
return applyAbilityByID(c, consumeArmedAbility(c), mods)
} }

View File

@@ -146,7 +146,7 @@ func TestSpendAndRefreshResource(t *testing.T) {
} }
} }
func TestArmAbilityForFight_FighterSecondWind(t *testing.T) { func TestApplyArmedAbility_FighterSecondWind(t *testing.T) {
setupAbilitiesTestDB(t) setupAbilitiesTestDB(t)
uid := id.UserID("@arm_fighter:example") uid := id.UserID("@arm_fighter:example")
c := &DnDCharacter{ c := &DnDCharacter{
@@ -162,7 +162,7 @@ func TestArmAbilityForFight_FighterSecondWind(t *testing.T) {
} }
mods := CombatModifiers{} mods := CombatModifiers{}
name, fired := armAbilityForFight(c, &mods) name, fired := applyArmedAbility(c, &mods)
if !fired { if !fired {
t.Fatal("ability did not fire") t.Fatal("ability did not fire")
} }
@@ -181,7 +181,7 @@ func TestArmAbilityForFight_FighterSecondWind(t *testing.T) {
} }
} }
func TestArmAbilityForFight_MageMagicMissile(t *testing.T) { func TestApplyArmedAbility_MageMagicMissile(t *testing.T) {
setupAbilitiesTestDB(t) setupAbilitiesTestDB(t)
uid := id.UserID("@arm_mage:example") uid := id.UserID("@arm_mage:example")
c := &DnDCharacter{ c := &DnDCharacter{
@@ -192,7 +192,7 @@ func TestArmAbilityForFight_MageMagicMissile(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
mods := CombatModifiers{} mods := CombatModifiers{}
_, fired := armAbilityForFight(c, &mods) _, fired := applyArmedAbility(c, &mods)
if !fired { if !fired {
t.Fatal("magic missile did not fire") t.Fatal("magic missile did not fire")
} }
@@ -202,10 +202,10 @@ func TestArmAbilityForFight_MageMagicMissile(t *testing.T) {
} }
} }
func TestArmAbilityForFight_NoArm(t *testing.T) { func TestApplyArmedAbility_NoArm(t *testing.T) {
c := &DnDCharacter{Class: ClassFighter, ArmedAbility: ""} c := &DnDCharacter{Class: ClassFighter, ArmedAbility: ""}
mods := CombatModifiers{} mods := CombatModifiers{}
_, fired := armAbilityForFight(c, &mods) _, fired := applyArmedAbility(c, &mods)
if fired { if fired {
t.Error("fired with no armed ability") t.Error("fired with no armed ability")
} }

View File

@@ -25,7 +25,7 @@ import (
// //
// Bypassed deliberately (Phase 0 simplifying constraints, doc §2): // Bypassed deliberately (Phase 0 simplifying constraints, doc §2):
// //
// - DB-touching layers: applyMagicItemEffects, armAbilityForFight, and // - DB-touching layers: applyMagicItemEffects, applyArmedAbility, and
// the SaveDnDCharacter inside applyPendingCast. The harness is pure // the SaveDnDCharacter inside applyPendingCast. The harness is pure
// Go; tests run without a sqlite instance. // Go; tests run without a sqlite instance.
// - Race passives beyond Human (+1 all): neutral baseline, again per §2. // - Race passives beyond Human (+1 all): neutral baseline, again per §2.

View File

@@ -481,23 +481,10 @@ func formatN(n int, word string) string {
// wound layered with the fresh equipment cushion. There is no scale // wound layered with the fresh equipment cushion. There is no scale
// conversion; persistDnDHPAfterCombat is the inverse direct copy. // conversion; persistDnDHPAfterCombat is the inverse direct copy.
func applyDnDHPScaling(stats *CombatStats, c *DnDCharacter) { func applyDnDHPScaling(stats *CombatStats, c *DnDCharacter) {
if c == nil || c.HPMax <= 0 { if c == nil || c.HPMax <= 0 || c.HPCurrent >= c.HPMax {
return return
} }
// Well-rested temporary HP (from a long rest at a T2+ home) is a cushion startHP := c.HPCurrent + stats.HPBonus
// layered above the fight's MaxHP. It is dormant (0) for anyone not
// currently rested — including every scenario in the balance corpus and
// the class-balance harness, which never sets it — so the full-HP fast
// path below stays byte-identical for them and the golden does not move.
if c.TempHP > 0 {
stats.MaxHP += c.TempHP
}
if c.HPCurrent >= c.HPMax {
// Full HP: StartHP=0 sentinel already means "enter at MaxHP", which
// now includes the +TempHP cushion.
return
}
startHP := c.HPCurrent + stats.HPBonus + c.TempHP
if startHP < 1 { if startHP < 1 {
startHP = 1 startHP = 1
} }

View File

@@ -224,28 +224,6 @@ func getActiveExpedition(userID id.UserID) (*Expedition, error) {
return e, err return e, err
} }
// ownedLiveExpedition returns the expedition this player owns and has not yet
// finished with — 'active' or 'extracting'. An extracted expedition is not
// over: it holds its roster for the whole resume window (releaseParty is not
// called on it), so the owner is still the only person who can close it.
//
// Active rows sort first, so a leader who owns both — extracted from one, then
// started another — resolves to the one they are standing in.
func ownedLiveExpedition(userID id.UserID) (*Expedition, error) {
row := db.Get().QueryRow(`
SELECT`+expeditionSelectCols+`
FROM dnd_expedition e
WHERE e.user_id = ?
AND e.status IN ('active', 'extracting')
ORDER BY (e.status = 'active') DESC, e.start_date DESC
LIMIT 1`, string(userID))
e, err := scanExpedition(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return e, err
}
// getExpedition fetches by ID regardless of status. Test/admin use. // getExpedition fetches by ID regardless of status. Test/admin use.
func getExpedition(id string) (*Expedition, error) { func getExpedition(id string) (*Expedition, error) {
row := db.Get().QueryRow(` row := db.Get().QueryRow(`
@@ -343,42 +321,6 @@ func updateSupplies(expID string, s ExpeditionSupplies) error {
return err return err
} }
// withExpeditionSupplies serializes one read-modify-write of the shared supply
// pool. updateSupplies rewrites supplies_json wholesale, so a caller that folds
// its delta onto an *Expedition it read earlier silently discards anything that
// landed in between — a member's pooled packs, another writer's spend. Handlers
// run one goroutine per event, so those writers genuinely interleave.
//
// fn is handed a freshly-read expedition under the pool's own lock and returns
// the supplies to persist. Callers that keep using their own *Expedition
// afterwards must copy the returned pool back onto it.
//
// advUserLock cannot stand in here: it is keyed by sender, so two members
// racing the same expedition row take two different mutexes and exclude nobody.
func (p *AdventurePlugin) withExpeditionSupplies(
expID string, fn func(fresh *Expedition) (ExpeditionSupplies, error),
) (ExpeditionSupplies, error) {
mu := p.advExpeditionLock(expID)
mu.Lock()
defer mu.Unlock()
fresh, err := getExpedition(expID)
if err != nil {
return ExpeditionSupplies{}, err
}
if fresh == nil {
return ExpeditionSupplies{}, fmt.Errorf("expedition %s not found", expID)
}
next, err := fn(fresh)
if err != nil {
return ExpeditionSupplies{}, err
}
if err := updateSupplies(expID, next); err != nil {
return ExpeditionSupplies{}, err
}
return next, nil
}
// updateCamp persists camp state. Pass nil to break camp. // updateCamp persists camp state. Pass nil to break camp.
func updateCamp(expID string, c *CampState) error { func updateCamp(expID string, c *CampState) error {
var arg any var arg any
@@ -407,18 +349,9 @@ func setExpeditionRunID(expID, runID string) error {
return err return err
} }
// abandonExpedition flags the player's live expedition as abandoned. Idempotent. // abandonExpedition flags the active expedition as abandoned. Idempotent.
//
// It spans 'extracting' as well as 'active': an extracted expedition still owns
// its roster, so abandoning is the leader's only way to free their party
// without paying to `!resume` first. Character reset (dnd_setup) leans on this
// too — a rerolled leader who left an extracted party behind would otherwise
// strand every member until the sweeper reaped the row.
//
// One row per call, active first, so the run-spawn rollback in expeditionCmdStart
// tears down the expedition it just created rather than an older extracted one.
func abandonExpedition(userID id.UserID) error { func abandonExpedition(userID id.UserID) error {
e, err := ownedLiveExpedition(userID) e, err := getActiveExpedition(userID)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -190,18 +190,10 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
} }
} }
// The affordability check above ran against `exp` as it was read, and exp.Supplies.Current -= cost
// nightRolloverBurn may have moved the pool since. Deduct against the pool if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
// as it stands — the same arithmetic, just not onto a stale snapshot.
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
next := fresh.Supplies
next.Current -= cost
return next, nil
})
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't deduct supplies: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't deduct supplies: "+err.Error())
} }
exp.Supplies = pooled
camp := &CampState{ camp := &CampState{
Active: true, Active: true,
Type: kind, Type: kind,

View File

@@ -322,37 +322,6 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
"You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.", "You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
zone.Display, existing.CurrentDay)) zone.Display, existing.CurrentDay))
} }
// A leader who extracted still holds their roster for the resume window, and
// `!resume` only ever reaches the *newest* extracted row. Starting fresh on
// top of one would orphan it: unreachable, un-reapable until the sweeper
// catches it, with every member still seated and refused a run of their own.
//
// Only a row with a roster blocks. A solo extraction strands nobody, so
// walking away from it stays a normal thing to do.
if pending, _ := getResumableExpedition(ctx.Sender); pending != nil {
switch {
case extractionLapsed(pending, time.Now().UTC()):
// Past the window — reap it here rather than make them wait an hour
// for the sweeper, and let the new expedition proceed. Route through
// the shared reap so the freed members hear about it, same as the
// sweeper and `!expedition abandon` do.
if err := p.reapLapsedExtraction(pending); err != nil {
slog.Warn("expedition: reap lapsed on start", "expedition", pending.ID, "err", err)
}
default:
// A roster still holds; block. On a roster-read error, assume it is
// occupied and refuse — proceeding would orphan a party we could not
// confirm was empty, the one outcome this guard exists to prevent. A
// solo extraction (n == 1) strands nobody, so walking away is fine.
n, err := partySize(pending.ID)
if err != nil || n > 1 {
zone, _ := getZone(pending.ZoneID)
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You extracted from **%s** on Day %d and your party is still waiting on you. `!resume` to lead them back in, or `!expedition abandon` to let it go — until you do one or the other, none of them can start a run of their own.",
zone.Display, pending.CurrentDay))
}
}
}
cost := float64(purchase.Cost()) cost := float64(purchase.Cost())
if p.euro == nil { if p.euro == nil {
@@ -641,15 +610,6 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
} }
if exp == nil {
// An extracted expedition is still the owner's to close — it holds the
// roster until the resume window lapses. Without this, a leader who
// wanted out had to pay to `!resume` first just to abandon.
if exp, err = getResumableExpedition(ctx.Sender); err != nil {
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
}
isLeader = exp != nil
}
if exp == nil { if exp == nil {
return p.SendDM(ctx.Sender, "No active expedition to abandon.") return p.SendDM(ctx.Sender, "No active expedition to abandon.")
} }
@@ -659,37 +619,15 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
"Only your party leader can abandon the expedition. `!expedition leave` to walk out alone.") "Only your party leader can abandon the expedition. `!expedition leave` to walk out alone.")
} }
zone, _ := getZone(exp.ZoneID) zone, _ := getZone(exp.ZoneID)
extracted := exp.Status == ExpeditionStatusExtracting
audience := expeditionAudience(exp) // read before abandonExpedition disbands the roster
if err := abandonExpedition(ctx.Sender); err != nil { if err := abandonExpedition(ctx.Sender); err != nil {
return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error()) return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error())
} }
markActedToday(ctx.Sender) markActedToday(ctx.Sender)
_ = retireAllRegionRuns(exp) _ = retireAllRegionRuns(exp)
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "") _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "")
// An extracted party is standing in town, not in the dungeon: their supplies if err := p.SendDM(ctx.Sender, fmt.Sprintf(
// are already spent and their loot is already banked. Say the true thing.
body := fmt.Sprintf(
"Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.", "Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.",
zone.Display, exp.CurrentDay)
if extracted {
body = fmt.Sprintf(
"You let the expedition in **%s** go. Day %d is where it ends — loot, XP, and coins are kept. The dungeon remembers.",
zone.Display, exp.CurrentDay)
}
// The roster is being disbanded out from under the members; they hear it from
// their leader rather than discovering it the next time a command works again.
for _, uid := range audience {
if uid == ctx.Sender {
continue
}
if err := p.SendDM(uid, fmt.Sprintf(
"Your leader called off the expedition in **%s** on Day %d. You're free to start a run of your own.",
zone.Display, exp.CurrentDay)); err != nil { zone.Display, exp.CurrentDay)); err != nil {
slog.Warn("expedition: abandon DM failed", "user", uid, "expedition", exp.ID, "err", err)
}
}
if err := p.SendDM(ctx.Sender, body); err != nil {
return err return err
} }
// Emergence seam: see maybeRollPetArrivalOnEmerge. // Emergence seam: see maybeRollPetArrivalOnEmerge.

View File

@@ -82,39 +82,34 @@ type nightRolloverResult struct {
// to finish the rollover; legacy deliverBriefing interleaves processOvernightCamp // to finish the rollover; legacy deliverBriefing interleaves processOvernightCamp
// between the two so a fortified camp's 5 lands before drift's +3. // between the two so a fortified camp's 5 lands before drift's +3.
func (p *AdventurePlugin) nightRolloverBurn(e *Expedition) (float32, error) { func (p *AdventurePlugin) nightRolloverBurn(e *Expedition) (float32, error) {
var burn float32
// Forage and burn land in one write, so they resolve together against the
// pool as it stands now — not as `e` last saw it.
newSupplies, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
// D5-c: Ranger forage runs before the daily burn so the +SU lands on // D5-c: Ranger forage runs before the daily burn so the +SU lands on
// today's supplies, not tomorrow's. Logged so the end-of-day digest // today's supplies, not tomorrow's. Logged so the end-of-day digest
// can surface the gain; pure no-op for non-Ranger characters. // can surface the gain; pure no-op for non-Ranger characters.
if c, cerr := LoadDnDCharacter(id.UserID(fresh.UserID)); cerr == nil && c != nil { if c, err := LoadDnDCharacter(id.UserID(e.UserID)); err == nil && c != nil {
if gain := applyRangerForage(fresh, c, nil); gain > 0 { if gain := applyRangerForage(e, c, nil); gain > 0 {
_ = appendExpeditionLog(fresh.ID, fresh.CurrentDay, "forage", _ = appendExpeditionLog(e.ID, e.CurrentDay, "forage",
fmt.Sprintf("ranger forage +%g SU", gain), fmt.Sprintf("ranger forage +%g SU", gain),
flavor.Pick(flavor.HarvestForageSuccess)) flavor.Pick(flavor.HarvestForageSuccess))
} }
} }
burnOverride := applyZoneTemporalPreBurn(fresh, fresh.CurrentDay+1) burnOverride := applyZoneTemporalPreBurn(e, e.CurrentDay+1)
var newSupplies ExpeditionSupplies
var burn float32
if burnOverride.Multiplier > 0 { if burnOverride.Multiplier > 0 {
// The temporal override replaces the harsh/siege multiplier, not the // The temporal override replaces the harsh/siege multiplier, not the
// roster's: a party still eats N × 0.8 rations inside a time-warped zone. // roster's: a party still eats N × 0.8 rations inside a time-warped zone.
burn = fresh.Supplies.DailyBurn * burnOverride.Multiplier * float32(expeditionBurnRatePct(fresh.ID)) / 100 burn = e.Supplies.DailyBurn * burnOverride.Multiplier * float32(expeditionBurnRatePct(e.ID)) / 100
next := fresh.Supplies newSupplies = e.Supplies
next.Current -= burn newSupplies.Current -= burn
if next.Current < 0 { if newSupplies.Current < 0 {
next.Current = 0 newSupplies.Current = 0
} }
next.ForagedToday = false newSupplies.ForagedToday = false
return next, nil } else {
harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e)
newSupplies, burn = applyExpeditionDailyBurn(e, harsh, e.SiegeMode)
} }
harsh := fresh.ThreatLevel > 60 || zoneTemporalHarsh(fresh) if err := updateSupplies(e.ID, newSupplies); err != nil {
next, b := applyExpeditionDailyBurn(fresh, harsh, fresh.SiegeMode)
burn = b
return next, nil
})
if err != nil {
return 0, err return 0, err
} }
if err := advanceExpeditionDay(e.ID); err != nil { if err := advanceExpeditionDay(e.ID); err != nil {

View File

@@ -30,7 +30,6 @@ import (
const ( const (
extractResumeWindow = 7 * 24 * time.Hour extractResumeWindow = 7 * 24 * time.Hour
forcedExtractCoinTaxFrac = 0.20 forcedExtractCoinTaxFrac = 0.20
extractionSweepInterval = time.Hour
) )
var ( var (
@@ -219,94 +218,6 @@ func getResumableExpedition(userID id.UserID) (*Expedition, error) {
return e, err return e, err
} }
// extractionLapsed reports whether an 'extracting' row is past its resume
// window and should be reaped. A NULL completed_at counts as lapsed: the column
// is the only clock the window has, and a row without one can never expire.
func extractionLapsed(e *Expedition, now time.Time) bool {
return e.CompletedAt == nil || now.Sub(*e.CompletedAt) > extractResumeWindow
}
// loadLapsedExtractions returns every extracted expedition whose resume window
// has closed, regardless of owner.
func loadLapsedExtractions(now time.Time) ([]*Expedition, error) {
rows, err := db.Get().Query(`
SELECT`+expeditionSelectCols+`
FROM dnd_expedition e
WHERE e.status = 'extracting'
AND (e.completed_at IS NULL OR e.completed_at < ?)`,
now.Add(-extractResumeWindow))
if err != nil {
return nil, err
}
defer rows.Close()
return scanExpeditionRows(rows)
}
// sweepLapsedExtractions closes out every extraction whose seven-day window has
// run out, which releases its roster.
//
// Without this the window is a promise the code never keeps. `handleResumeCmd`
// expires a lapsed row lazily, but only the *leader* can call `!resume` — so a
// leader who quits, forgets, or simply starts a different expedition leaves the
// row 'extracting' forever, and releaseParty (which fires only on a terminal
// status) never runs. Every member stays seated, and assertNotAdventuring keeps
// refusing them a run of their own. `!expedition leave` is their escape, but a
// player should not have to find it.
func (p *AdventurePlugin) sweepLapsedExtractions(now time.Time) {
exps, err := loadLapsedExtractions(now)
if err != nil {
slog.Error("expedition: load lapsed extractions", "err", err)
return
}
for _, e := range exps {
if err := p.reapLapsedExtraction(e); err != nil {
slog.Warn("expedition: expire lapsed extraction", "expedition", e.ID, "err", err)
continue
}
}
}
// reapLapsedExtraction closes one lapsed extraction and tells its roster the
// way back has shut. It is the single reap-with-notify path: the hourly
// sweeper loops it, and expeditionCmdStart calls it when a player starts a new
// expedition on top of one whose window has already closed — so a member is
// never silently unseated by whichever path happens to reach the row first.
//
// The audience is read before the close-out: completeExpedition disbands the
// roster, and expeditionAudience reads that roster.
func (p *AdventurePlugin) reapLapsedExtraction(e *Expedition) error {
audience := expeditionAudience(e)
if err := completeExpedition(e.ID, ExpeditionStatusFailed); err != nil {
return err
}
zone, _ := getZone(e.ZoneID)
body := fmt.Sprintf(
"🕯 **The way back has closed — %s**\n\nYour extracted expedition sat past its seven-day resume window, and the dungeon reshaped without you. Day %d is where it ends.\n\nThe loot, XP, and coins you carried out are still yours. `!expedition list` when you're ready for the next one.",
zone.Display, e.CurrentDay)
for _, uid := range audience {
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: lapsed-extraction DM failed", "user", uid, "expedition", e.ID, "err", err)
}
}
return nil
}
// expeditionExtractionSweepTicker reaps lapsed extractions hourly. The window is
// seven days, so the cadence only bounds how long a freed member waits to hear
// about it.
func (p *AdventurePlugin) expeditionExtractionSweepTicker() {
ticker := time.NewTicker(extractionSweepInterval)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
p.sweepLapsedExtractions(time.Now().UTC())
}
}
}
// resumeExpedition flips an extracting row back to 'active' with fresh // resumeExpedition flips an extracting row back to 'active' with fresh
// supplies. Threat/temporal/region state are preserved as-is. // supplies. Threat/temporal/region state are preserved as-is.
func resumeExpedition(expID string, supplies ExpeditionSupplies) error { func resumeExpedition(expID string, supplies ExpeditionSupplies) error {
@@ -412,9 +323,8 @@ func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error
if exp == nil { if exp == nil {
return p.SendDM(ctx.Sender, "No extracted expedition to resume. Use `!expedition start <zone>` to begin a new one.") return p.SendDM(ctx.Sender, "No extracted expedition to resume. Use `!expedition start <zone>` to begin a new one.")
} }
if extractionLapsed(exp, time.Now().UTC()) { if exp.CompletedAt == nil || time.Since(*exp.CompletedAt) > extractResumeWindow {
// Expire it so it doesn't keep resurfacing. The hourly sweeper would get // Expire it so it doesn't keep resurfacing.
// here on its own; this keeps the refusal and the reap in one breath.
_ = completeExpedition(exp.ID, ExpeditionStatusFailed) _ = completeExpedition(exp.ID, ExpeditionStatusFailed)
return p.SendDM(ctx.Sender, return p.SendDM(ctx.Sender,
"That extraction is past its 7-day resume window — the dungeon has reshaped without you. Start a new expedition.") "That extraction is past its 7-day resume window — the dungeon has reshaped without you. Start a new expedition.")

View File

@@ -176,30 +176,20 @@ const twoWeeksCacheSize = 3
func (p *AdventurePlugin) grantTwoWeeksCache(e *Expedition) string { func (p *AdventurePlugin) grantTwoWeeksCache(e *Expedition) string {
var lines []string var lines []string
if e.Supplies.DailyBurn > 0 { if s := e.Supplies; s.DailyBurn > 0 {
var restocked bool
pooled, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
s := fresh.Supplies
if s.DailyBurn <= 0 {
return s, nil
}
s.Current += twoWeeksRestockDays * s.DailyBurn s.Current += twoWeeksRestockDays * s.DailyBurn
if s.Max > 0 && s.Current > s.Max { if s.Max > 0 && s.Current > s.Max {
s.Current = s.Max s.Current = s.Max
} }
restocked = s.Current > fresh.Supplies.Current if s.Current > e.Supplies.Current {
return s, nil if err := updateSupplies(e.ID, s); err != nil {
})
switch {
case err != nil:
slog.Error("milestone: two weeks restock failed", slog.Error("milestone: two weeks restock failed",
"expedition", e.ID, "err", err) "expedition", e.ID, "err", err)
case restocked: } else {
lines = append(lines, fmt.Sprintf( lines = append(lines, fmt.Sprintf(
"📦 Supplies restocked — %.1f of %.1f.", pooled.Current, pooled.Max)) "📦 Supplies restocked — %.1f of %.1f.", s.Current, s.Max))
e.Supplies = pooled e.Supplies = s
default: }
e.Supplies = pooled
} }
} }

View File

@@ -143,17 +143,13 @@ func (p *AdventurePlugin) regionCmdTravel(ctx MessageContext, exp *Expedition) e
// on both paths. // on both paths.
func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition, cur, next ExpeditionRegion) (string, error) { func (p *AdventurePlugin) advanceToNextRegion(userID id.UserID, exp *Expedition, cur, next ExpeditionRegion) (string, error) {
// Burn one day of supplies (transit day). // Burn one day of supplies (transit day).
var burned float32 siege := exp.SiegeMode
newSupplies, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) { harsh := exp.ThreatLevel > 60 || zoneTemporalHarsh(exp)
harsh := fresh.ThreatLevel > 60 || zoneTemporalHarsh(fresh) newSupplies, burned := applyExpeditionDailyBurn(exp, harsh, siege)
next, b := applyExpeditionDailyBurn(fresh, harsh, fresh.SiegeMode) exp.Supplies = newSupplies
burned = b if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
return next, nil
})
if err != nil {
return "", fmt.Errorf("apply transit supply burn: %w", err) return "", fmt.Errorf("apply transit supply burn: %w", err)
} }
exp.Supplies = newSupplies
if err := advanceExpeditionDay(exp.ID); err != nil { if err := advanceExpeditionDay(exp.ID); err != nil {
return "", fmt.Errorf("advance expedition day: %w", err) return "", fmt.Errorf("advance expedition day: %w", err)
} }

View File

@@ -222,15 +222,8 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error {
innPaid = true innPaid = true
} }
// Well-rested buff: only when resting at your own home (not the inn), and
// only from tier 2 up — a tier-1 shack and a rented inn room grant nothing.
restTier := 0
if hasHousing {
restTier = house.Tier
}
c.HPCurrent = c.HPMax c.HPCurrent = c.HPMax
c.TempHP = wellRestedTempHP(c.HPMax, restTier) c.TempHP = 0
c.ShortRestCharges = c.Level c.ShortRestCharges = c.Level
// Phase 10 SUB2a — long rest clears one level of exhaustion (5e: a // Phase 10 SUB2a — long rest clears one level of exhaustion (5e: a
// long rest clears one). For Berserker who racks up exhaustion via // long rest clears one). For Berserker who racks up exhaustion via
@@ -248,9 +241,8 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error {
} }
markActedToday(ctx.Sender) markActedToday(ctx.Sender)
_ = refreshAllResources(ctx.Sender) _ = refreshAllResources(ctx.Sender)
// Phase 9: spell slots refresh on long rest. A home rest also folds in the // Phase 9: spell slots refresh on long rest.
// well-rested bonus slots (no-op for non-casters and inn/tier-1 rests). _ = refreshSpellSlots(ctx.Sender)
slotBonus, _ := applyLongRestSpellSlots(c, restTier)
// Phase 9: Cleric prep flags reset (SP4) — until SP4 ships, default // Phase 9: Cleric prep flags reset (SP4) — until SP4 ships, default
// Cleric grants are already prepared=1 so this is a no-op for them. // Cleric grants are already prepared=1 so this is a no-op for them.
// Voluntary concentration ends at long rest (mage_armor's 8h is exactly // Voluntary concentration ends at long rest (mage_armor's 8h is exactly
@@ -271,13 +263,5 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error {
if line := dndRestLongFlavorLine(hasHousing); line != "" { if line := dndRestLongFlavorLine(hasHousing); line != "" {
msg += "\n\n_" + line + "_" msg += "\n\n_" + line + "_"
} }
if bits := wellRestedSummary(c.TempHP, slotBonus); bits != "" {
homeName := "home"
if def := houseTierByTier(restTier); def != nil {
homeName = def.Name
}
msg += fmt.Sprintf("\n\n🛏 _Well-rested at your %s — %s until your next long rest._",
strings.ToLower(homeName), bits)
}
return p.SendDM(ctx.Sender, msg) return p.SendDM(ctx.Sender, msg)
} }

View File

@@ -212,9 +212,6 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta,
if adv.PetName != "" { if adv.PetName != "" {
b.WriteString(fmt.Sprintf(" Pet: %s the %s (lv %d)\n", adv.PetName, adv.PetType, adv.PetLevel)) b.WriteString(fmt.Sprintf(" Pet: %s the %s (lv %d)\n", adv.PetName, adv.PetType, adv.PetLevel))
} }
if adv.Pet2Name != "" {
b.WriteString(fmt.Sprintf(" Pet: %s the %s (lv %d)\n", adv.Pet2Name, adv.Pet2Type, adv.Pet2Level))
}
if house.Tier > 0 { if house.Tier > 0 {
b.WriteString(fmt.Sprintf(" Housing: tier %d\n", house.Tier)) b.WriteString(fmt.Sprintf(" Housing: tier %d\n", house.Tier))
} }

View File

@@ -310,13 +310,6 @@ type turnSpellOutcome struct {
PlayerHeal int PlayerHeal int
EnemySkip bool EnemySkip bool
Desc string Desc string
// GrimHarvestSlot / GrimHarvestNecrotic are the Necromancy Mage's kill-heal
// stash for this cast, zero for everyone else. Unlike the fields above they
// outlive the casting round: the caller parks them on the seat's
// ActorStatuses and the close-out decides whether the cast was lethal.
GrimHarvestSlot int
GrimHarvestNecrotic bool
} }
// resolveTurnSpell resolves a spell as a turn-based player action, reusing the // resolveTurnSpell resolves a spell as a turn-based player action, reusing the
@@ -351,8 +344,6 @@ func resolveTurnSpell(c *DnDCharacter, spell SpellDefinition, slotLevel int, ene
out.EnemyDamage = mods.SpellPreDamage out.EnemyDamage = mods.SpellPreDamage
out.EnemySkip = mods.SpellEnemySkipFirst out.EnemySkip = mods.SpellEnemySkipFirst
out.Desc = mods.SpellPreDamageDesc out.Desc = mods.SpellPreDamageDesc
out.GrimHarvestSlot = mods.GrimHarvestSlot
out.GrimHarvestNecrotic = mods.GrimHarvestNecrotic
if out.Desc == "" { if out.Desc == "" {
out.Desc = spell.Name out.Desc = spell.Name
} }

View File

@@ -564,48 +564,6 @@ func refreshSpellSlots(userID id.UserID) error {
return err return err
} }
// applyLongRestSpellSlots resets a caster's slot pool to its base (class +
// subclass) total with used=0, then folds in any well-rested bonus slots for a
// home rest of the given tier. The bonus lives in `total`, so it is spent like
// an ordinary slot and is wiped by the *next* long rest's reset — expiry needs
// no separate bookkeeping. It is safe to call on every long rest because
// ensureSpellsForCharacter already keeps `total` == slotsForCharacter(c) on
// load, so the base reset is a no-op except for clearing a prior bonus.
//
// Non-casters have an empty pool; the function falls back to the plain
// used=0 refresh and grants nothing. Returns the bonus actually granted (nil if
// none) so the caller can name it in the rest message.
func applyLongRestSpellSlots(c *DnDCharacter, restTier int) (map[int]int, error) {
pool := slotsForCharacter(c)
if len(pool) == 0 {
return nil, refreshSpellSlots(c.UserID)
}
bonus := wellRestedSlotBonus(restTier, pool)
tx, err := db.Get().Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM dnd_spell_slots WHERE user_id = ?`, string(c.UserID)); err != nil {
return nil, err
}
for lvl, total := range pool {
if _, err := tx.Exec(`
INSERT INTO dnd_spell_slots (user_id, slot_level, total, used)
VALUES (?, ?, ?, 0)`,
string(c.UserID), lvl, total+bonus[lvl]); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
if len(bonus) == 0 {
return nil, nil
}
return bonus, nil
}
// partialRefreshSpellSlots restores spell slots on short rest. All L1 slots // partialRefreshSpellSlots restores spell slots on short rest. All L1 slots
// come back, plus floor(charLevel/4) additional slots distributed // come back, plus floor(charLevel/4) additional slots distributed
// lowest-first across tiers ≥2. Returns slot_level→count restored so the // lowest-first across tiers ≥2. Returns slot_level→count restored so the

View File

@@ -934,22 +934,11 @@ func grimHarvestHeal(c *DnDCharacter, result CombatResult, mods CombatModifiers)
if !result.PlayerWon || mods.GrimHarvestSlot <= 0 { if !result.PlayerWon || mods.GrimHarvestSlot <= 0 {
return 0 return 0
} }
// The spell killed the enemy iff the spell_cast event itself dropped EnemyHP // The pre-combat spell killed the enemy iff the spell_cast event itself
// to 0. If a later round-event (a weapon swing, the pet, a concentration // dropped EnemyHP to 0. If a later round-event finished the kill, no heal.
// aura re-tick) finished the kill, no heal. for _, ev := range result.Events {
// if ev.Action == "spell_cast" {
// It is the *last* spell_cast that has to be lethal, not the first. The if ev.EnemyHP > 0 {
// auto-resolve path casts once, pre-combat, so the two coincide there; the
// turn-based path can cast every round, and there the stash on the seat's
// statuses describes that last cast. Reading the first event would let a
// non-lethal opening cantrip veto a heal the killing spell had earned.
//
// No spell_cast event at all cannot happen while the stash is set — both
// surfaces emit one for every damaging cast — so the loop falling through
// is not a case, just an absence.
for i := len(result.Events) - 1; i >= 0; i-- {
if result.Events[i].Action == "spell_cast" {
if result.Events[i].EnemyHP > 0 {
return 0 return 0
} }
break break

View File

@@ -471,12 +471,6 @@ type advanceResult struct {
// harvest ran (Entry/Trap/Elite/Boss rooms, forks, stops). // harvest ran (Entry/Trap/Elite/Boss rooms, forks, stops).
harvest autoHarvestResult harvest autoHarvestResult
harvestFooter string harvestFooter string
// zoneCleared is true only when this step was a *full* zone clear
// (reason == stopComplete and not a mid-zone region clear). The K
// event anchor reads it so a foreground `!zone advance` clear can roll
// the grind-loop player's mid-day event — a mid-zone region clear does
// not, since the run continues and it would fire per region.
zoneCleared bool
} }
// zoneCmdAdvance resolves the room the player is currently standing in, // zoneCmdAdvance resolves the room the player is currently standing in,
@@ -501,16 +495,7 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, err.Error()) return p.SendDM(ctx.Sender, err.Error())
} }
sendErr := p.streamOrSend(ctx.Sender, res.preStream, res.intro, res.phases, res.final) return p.streamOrSend(ctx.Sender, res.preStream, res.intro, res.phases, res.final)
// K fourth event anchor: a foreground single-day zone clear is a presence
// moment for the grind-loop player the other three anchors miss. Rolled
// after the clear DM so any triggered event lands behind it, and only here
// — zoneCmdAdvance is the foreground path; autopilot walks go through
// runAutopilotWalk and never reach this.
if res.zoneCleared {
p.maybeFireAnchoredEvent(ctx.Sender, advEventChanceZoneClear)
}
return sendErr
} }
// advanceOnce runs the single-room advance pipeline and returns a // advanceOnce runs the single-room advance pipeline and returns a
@@ -752,8 +737,7 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin
// and point at the next one — "Cleared {zone}. Run complete." reads // and point at the next one — "Cleared {zone}. Run complete." reads
// wrong right before the auto-advance transit block (and is shared with // wrong right before the auto-advance transit block (and is shared with
// manual `!region travel`, which advances next). // manual `!region travel`, which advances next).
region, next, midZone := midZoneRegionClear(ctx.Sender, run.RunID) if region, next, midZone := midZoneRegionClear(ctx.Sender, run.RunID); midZone {
if midZone {
b.WriteString(fmt.Sprintf("🏁 **Cleared %s.** The way to %s opens ahead.\n\n", region.Name, next.Name)) b.WriteString(fmt.Sprintf("🏁 **Cleared %s.** The way to %s opens ahead.\n\n", region.Name, next.Name))
} else { } else {
b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Run complete.\n\n", zone.Display)) b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Run complete.\n\n", zone.Display))
@@ -784,7 +768,6 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin
phases: phases, phases: phases,
final: b.String(), final: b.String(),
reason: stopComplete, reason: stopComplete,
zoneCleared: !midZone,
}, nil }, nil
} }
if forkMsg != "" { if forkMsg != "" {

View File

@@ -349,14 +349,12 @@ func (p *AdventurePlugin) applyAmbientEffect(e *Expedition, ev ambientEvent) str
return "Something out there is paying attention now." return "Something out there is paying attention now."
case "pack_rat": case "pack_rat":
drain := float32(0.2) + float32(rng.IntN(2))*float32(0.1) // 0.2 or 0.3 drain := float32(0.2) + float32(rng.IntN(2))*float32(0.1) // 0.2 or 0.3
if _, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) { ns := e.Supplies
ns := fresh.Supplies
ns.Current -= drain ns.Current -= drain
if ns.Current < 0 { if ns.Current < 0 {
ns.Current = 0 ns.Current = 0
} }
return ns, nil if err := updateSupplies(e.ID, ns); err != nil {
}); err != nil {
slog.Warn("expedition: ambient supply drain", "expedition", e.ID, "err", err) slog.Warn("expedition: ambient supply drain", "expedition", e.ID, "err", err)
return "" return ""
} }

View File

@@ -250,15 +250,10 @@ func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision
if exp.Supplies.Current < cost { if exp.Supplies.Current < cost {
return "", fmt.Errorf("insufficient supplies (have %.1f, need %.1f)", exp.Supplies.Current, cost) return "", fmt.Errorf("insufficient supplies (have %.1f, need %.1f)", exp.Supplies.Current, cost)
} }
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) { exp.Supplies.Current -= cost
next := fresh.Supplies if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
next.Current -= cost
return next, nil
})
if err != nil {
return "", err return "", err
} }
exp.Supplies = pooled
camp := &CampState{ camp := &CampState{
Active: true, Active: true,
Type: d.Kind, Type: d.Kind,

View File

@@ -1,158 +0,0 @@
package plugin
import (
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// seedExtraction inserts an 'extracting' row whose window closed `age` ago.
// A negative age is a live extraction.
func seedExtraction(t *testing.T, expeditionID string, owner id.UserID, age time.Duration) {
t.Helper()
seedExpedition(t, expeditionID, owner, ExpeditionStatusExtracting)
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET completed_at = ? WHERE expedition_id = ?`,
time.Now().UTC().Add(-extractResumeWindow-age), expeditionID); err != nil {
t.Fatal(err)
}
}
func seatedOn(t *testing.T, member id.UserID) string {
t.Helper()
e, err := seatedExpeditionFor(member)
if err != nil {
t.Fatal(err)
}
if e == nil {
return ""
}
return e.ID
}
// The window is the only thing bounding how long a leader's roster holds their
// party. Nothing else flips an 'extracting' row terminal unless the leader
// personally types !resume, so if the sweep misses, members are stuck forever.
func TestSweepLapsedExtractions_ReleasesTheRosterItStranded(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@sweep-leader:example.org")
member := id.UserID("@sweep-member:example.org")
seedExtraction(t, "exp-lapsed", leader, time.Hour)
if err := joinParty("exp-lapsed", member); err != nil {
t.Fatal(err)
}
if got := seatedOn(t, member); got != "exp-lapsed" {
t.Fatalf("member seated on %q before sweep, want exp-lapsed", got)
}
(&AdventurePlugin{}).sweepLapsedExtractions(time.Now().UTC())
e, err := getExpedition("exp-lapsed")
if err != nil {
t.Fatal(err)
}
if e.Status != ExpeditionStatusFailed {
t.Errorf("status = %q, want failed", e.Status)
}
if got := seatedOn(t, member); got != "" {
t.Errorf("member still seated on %q after sweep", got)
}
if n, _ := partySize("exp-lapsed"); n != 1 {
t.Errorf("roster survived the sweep: partySize = %d", n)
}
}
// The mirror: a leader who extracted an hour ago still has six days to !resume,
// and their party must still be there when they do.
func TestSweepLapsedExtractions_LeavesALiveExtractionAlone(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@sweep-live-leader:example.org")
member := id.UserID("@sweep-live-member:example.org")
seedExtraction(t, "exp-live", leader, -6*24*time.Hour) // one day in
if err := joinParty("exp-live", member); err != nil {
t.Fatal(err)
}
(&AdventurePlugin{}).sweepLapsedExtractions(time.Now().UTC())
e, _ := getExpedition("exp-live")
if e.Status != ExpeditionStatusExtracting {
t.Errorf("status = %q, want extracting — the resume window is still open", e.Status)
}
if got := seatedOn(t, member); got != "exp-live" {
t.Errorf("member unseated from a resumable expedition (seated on %q)", got)
}
}
// completed_at is the only clock the window has. A row without one can never
// lapse, so it must read as already lapsed rather than as immortal.
func TestExtractionLapsed_NullCompletedAtIsLapsed(t *testing.T) {
now := time.Now().UTC()
if !extractionLapsed(&Expedition{}, now) {
t.Error("nil completed_at should read as lapsed")
}
fresh := now.Add(-time.Hour)
if extractionLapsed(&Expedition{CompletedAt: &fresh}, now) {
t.Error("an hour-old extraction is not lapsed")
}
old := now.Add(-extractResumeWindow - time.Second)
if !extractionLapsed(&Expedition{CompletedAt: &old}, now) {
t.Error("a row past the window is lapsed")
}
}
// A leader must be able to let an extracted expedition go without paying to
// !resume it first — that is the escape hatch !expedition start points them at.
func TestAbandonExpedition_ReachesAnExtractedRowAndFreesTheParty(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@abandon-extracted-leader:example.org")
member := id.UserID("@abandon-extracted-member:example.org")
seedExtraction(t, "exp-extracted", leader, -24*time.Hour) // live, resumable
if err := joinParty("exp-extracted", member); err != nil {
t.Fatal(err)
}
if err := abandonExpedition(leader); err != nil {
t.Fatalf("abandonExpedition on an extracting row: %v", err)
}
e, _ := getExpedition("exp-extracted")
if e.Status != ExpeditionStatusAbandoned {
t.Errorf("status = %q, want abandoned", e.Status)
}
if got := seatedOn(t, member); got != "" {
t.Errorf("member still seated on %q", got)
}
}
// expeditionCmdStart's run-spawn rollback calls abandonExpedition to tear down
// the row it just created. A leader who also owns an older extracted row must
// not have that one reaped instead.
func TestAbandonExpedition_PrefersTheActiveRowOverAnExtractedOne(t *testing.T) {
setupEmptyTestDB(t)
leader := id.UserID("@abandon-prefers-active:example.org")
seedExtraction(t, "exp-old-extracted", leader, -24*time.Hour)
seedExpedition(t, "exp-new-active", leader, ExpeditionStatusActive)
if err := abandonExpedition(leader); err != nil {
t.Fatal(err)
}
if e, _ := getExpedition("exp-new-active"); e.Status != ExpeditionStatusAbandoned {
t.Errorf("active row status = %q, want abandoned", e.Status)
}
if e, _ := getExpedition("exp-old-extracted"); e.Status != ExpeditionStatusExtracting {
t.Errorf("extracted row was collateral damage: status = %q", e.Status)
}
}
func TestAbandonExpedition_NoLiveRow(t *testing.T) {
setupEmptyTestDB(t)
if err := abandonExpedition(id.UserID("@abandon-nothing:example.org")); err != ErrNoActiveExpedition {
t.Errorf("err = %v, want ErrNoActiveExpedition", err)
}
}

View File

@@ -115,6 +115,18 @@ func partySize(expeditionID string) (int, error) {
return n, nil return n, nil
} }
// openParty seats the leader, converting a solo expedition row into a party of
// one. It is idempotent: a second call on the same expedition is a no-op, so
// the invite path can call it unconditionally before adding a member.
func openParty(expeditionID string, leaderID id.UserID) error {
_, err := db.Get().Exec(`
INSERT INTO expedition_party (expedition_id, user_id, role)
VALUES (?, ?, 'leader')
ON CONFLICT (expedition_id, user_id) DO NOTHING`,
expeditionID, string(leaderID))
return err
}
// joinParty seats a member. It refuses a full roster, a duplicate, and a player // joinParty seats a member. It refuses a full roster, a duplicate, and a player
// who already leads or rides an expedition of their own — the "one active // who already leads or rides an expedition of their own — the "one active
// expedition per user" rule that startExpedition enforces in code, extended to // expedition per user" rule that startExpedition enforces in code, extended to
@@ -159,11 +171,9 @@ func joinParty(expeditionID string, userID id.UserID) error {
return tx.Commit() return tx.Commit()
} }
// seatLeader converts a solo expedition row into a party of one, inside a // seatLeader is openParty inside a caller's transaction: it reads the owner off
// caller's transaction. It reads the owner off the expedition row rather than // the expedition row rather than trusting a passed-in id, so the roster's leader
// trusting a passed-in id, so the roster's leader can never disagree with // can never disagree with dnd_expedition.user_id.
// dnd_expedition.user_id. Idempotent (ON CONFLICT DO NOTHING), so the invite
// path can call it unconditionally before adding a member.
func seatLeader(tx *sql.Tx, expeditionID string) error { func seatLeader(tx *sql.Tx, expeditionID string) error {
var owner string var owner string
err := tx.QueryRow( err := tx.QueryRow(

View File

@@ -178,13 +178,21 @@ func (p *AdventurePlugin) expeditionCmdAccept(ctx MessageContext, c *DnDCharacte
if isHol, _ := isHolidayToday(); isHol { if isHol, _ := isHolidayToday(); isHol {
suppliesPurchase.StandardPacks++ suppliesPurchase.StandardPacks++
} }
// `exp` was fetched before the coin debit, and a second invitee accepting — // updateSupplies overwrites supplies_json wholesale, so the pool has to be
// or the leader's day-burn tick — may have rewritten the row since. Folding // re-read under the expedition's own lock: `exp` was fetched before the coin
// onto that stale snapshot would silently drop their packs or resurrect // debit, and a second invitee accepting — or the leader's day-burn tick —
// spent SU, so the fold happens against a fresh read under the pool's lock. // may have rewritten the row since. Folding onto that stale snapshot would
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) { // silently drop their packs or resurrect spent SU.
return addSupplyPurchase(fresh.Supplies, suppliesPurchase), nil expMu := p.advExpeditionLock(exp.ID)
}) expMu.Lock()
fresh, err := getExpedition(exp.ID)
if err != nil || fresh == nil {
expMu.Unlock()
return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool.")
}
pooled := addSupplyPurchase(fresh.Supplies, suppliesPurchase)
err = updateSupplies(exp.ID, pooled)
expMu.Unlock()
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool: "+err.Error()) return p.SendDM(ctx.Sender, "You're in, but your supplies didn't reach the pool: "+err.Error())
} }

View File

@@ -22,24 +22,6 @@ func seedExpedition(t *testing.T, expeditionID string, owner id.UserID, status s
} }
} }
// seatLeaderFixture seats the expedition's leader the way the invite path does —
// through seatLeader in its own transaction — reading the owner off the row that
// seedExpedition already wrote. Replaces the retired openParty helper.
func seatLeaderFixture(t *testing.T, expeditionID string) {
t.Helper()
tx, err := db.Get().Begin()
if err != nil {
t.Fatal(err)
}
if err := seatLeader(tx, expeditionID); err != nil {
_ = tx.Rollback()
t.Fatalf("seatLeader %s: %v", expeditionID, err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
}
func TestParty_SoloExpeditionHasNoRoster(t *testing.T) { func TestParty_SoloExpeditionHasNoRoster(t *testing.T) {
setupEmptyTestDB(t) setupEmptyTestDB(t)
owner := id.UserID("@solo:example.org") owner := id.UserID("@solo:example.org")
@@ -71,9 +53,10 @@ func TestParty_OpenIsIdempotent(t *testing.T) {
owner := id.UserID("@lead:example.org") owner := id.UserID("@lead:example.org")
seedExpedition(t, "exp-1", owner, "active") seedExpedition(t, "exp-1", owner, "active")
// seatLeader is idempotent — a second call on the same expedition is a no-op.
for i := 0; i < 2; i++ { for i := 0; i < 2; i++ {
seatLeaderFixture(t, "exp-1") if err := openParty("exp-1", owner); err != nil {
t.Fatalf("openParty #%d: %v", i+1, err)
}
} }
members, err := partyMembers("exp-1") members, err := partyMembers("exp-1")
if err != nil { if err != nil {
@@ -88,7 +71,9 @@ func TestParty_JoinSeatsMembersLeaderFirst(t *testing.T) {
setupEmptyTestDB(t) setupEmptyTestDB(t)
owner := id.UserID("@lead2:example.org") owner := id.UserID("@lead2:example.org")
seedExpedition(t, "exp-2", owner, "active") seedExpedition(t, "exp-2", owner, "active")
seatLeaderFixture(t, "exp-2") if err := openParty("exp-2", owner); err != nil {
t.Fatal(err)
}
for _, u := range []id.UserID{"@b:x", "@c:x"} { for _, u := range []id.UserID{"@b:x", "@c:x"} {
if err := joinParty("exp-2", u); err != nil { if err := joinParty("exp-2", u); err != nil {
t.Fatalf("joinParty %s: %v", u, err) t.Fatalf("joinParty %s: %v", u, err)
@@ -142,7 +127,9 @@ func TestParty_JoinRefusesFullRoster(t *testing.T) {
setupEmptyTestDB(t) setupEmptyTestDB(t)
owner := id.UserID("@lead3:example.org") owner := id.UserID("@lead3:example.org")
seedExpedition(t, "exp-3", owner, "active") seedExpedition(t, "exp-3", owner, "active")
seatLeaderFixture(t, "exp-3") if err := openParty("exp-3", owner); err != nil {
t.Fatal(err)
}
for _, u := range []id.UserID{"@b:x", "@c:x"} { for _, u := range []id.UserID{"@b:x", "@c:x"} {
if err := joinParty("exp-3", u); err != nil { if err := joinParty("exp-3", u); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -158,7 +145,9 @@ func TestParty_JoinRefusesDuplicate(t *testing.T) {
setupEmptyTestDB(t) setupEmptyTestDB(t)
owner := id.UserID("@lead4:example.org") owner := id.UserID("@lead4:example.org")
seedExpedition(t, "exp-4", owner, "active") seedExpedition(t, "exp-4", owner, "active")
seatLeaderFixture(t, "exp-4") if err := openParty("exp-4", owner); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-4", "@b:x"); err != nil { if err := joinParty("exp-4", "@b:x"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -178,8 +167,12 @@ func TestParty_JoinRefusesPlayerBusyElsewhere(t *testing.T) {
setupEmptyTestDB(t) setupEmptyTestDB(t)
seedExpedition(t, "exp-5", "@lead5:example.org", "active") seedExpedition(t, "exp-5", "@lead5:example.org", "active")
seedExpedition(t, "exp-6", "@lead6:example.org", "active") seedExpedition(t, "exp-6", "@lead6:example.org", "active")
seatLeaderFixture(t, "exp-5") if err := openParty("exp-5", "@lead5:example.org"); err != nil {
seatLeaderFixture(t, "exp-6") t.Fatal(err)
}
if err := openParty("exp-6", "@lead6:example.org"); err != nil {
t.Fatal(err)
}
// Owns their own active expedition. // Owns their own active expedition.
seedExpedition(t, "exp-own", "@busy:example.org", "active") seedExpedition(t, "exp-own", "@busy:example.org", "active")
@@ -205,7 +198,9 @@ func TestParty_LeaveRemovesMemberButNotLeader(t *testing.T) {
setupEmptyTestDB(t) setupEmptyTestDB(t)
owner := id.UserID("@lead7:example.org") owner := id.UserID("@lead7:example.org")
seedExpedition(t, "exp-7", owner, "active") seedExpedition(t, "exp-7", owner, "active")
seatLeaderFixture(t, "exp-7") if err := openParty("exp-7", owner); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-7", "@b:x"); err != nil { if err := joinParty("exp-7", "@b:x"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -222,7 +217,9 @@ func TestParty_LeaveRemovesMemberButNotLeader(t *testing.T) {
} }
// Leaving frees the member for their own next run. // Leaving frees the member for their own next run.
seedExpedition(t, "exp-7b", "@lead7b:example.org", "active") seedExpedition(t, "exp-7b", "@lead7b:example.org", "active")
seatLeaderFixture(t, "exp-7b") if err := openParty("exp-7b", "@lead7b:example.org"); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-7b", "@b:x"); err != nil { if err := joinParty("exp-7b", "@b:x"); err != nil {
t.Errorf("departed member could not rejoin elsewhere: %v", err) t.Errorf("departed member could not rejoin elsewhere: %v", err)
} }
@@ -232,7 +229,9 @@ func TestParty_DisbandFreesEveryone(t *testing.T) {
setupEmptyTestDB(t) setupEmptyTestDB(t)
owner := id.UserID("@lead8:example.org") owner := id.UserID("@lead8:example.org")
seedExpedition(t, "exp-8", owner, "complete") seedExpedition(t, "exp-8", owner, "complete")
seatLeaderFixture(t, "exp-8") if err := openParty("exp-8", owner); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-8", "@b:x"); err != nil { if err := joinParty("exp-8", "@b:x"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -243,7 +242,9 @@ func TestParty_DisbandFreesEveryone(t *testing.T) {
t.Errorf("after disband, partySize = %d, want 1 (no rows)", n) t.Errorf("after disband, partySize = %d, want 1 (no rows)", n)
} }
seedExpedition(t, "exp-8b", "@lead8b:example.org", "active") seedExpedition(t, "exp-8b", "@lead8b:example.org", "active")
seatLeaderFixture(t, "exp-8b") if err := openParty("exp-8b", "@lead8b:example.org"); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-8b", "@b:x"); err != nil { if err := joinParty("exp-8b", "@b:x"); err != nil {
t.Errorf("disbanded member still looks busy: %v", err) t.Errorf("disbanded member still looks busy: %v", err)
} }
@@ -256,7 +257,9 @@ func TestParty_ActiveExpeditionForResolvesBothRoles(t *testing.T) {
owner := id.UserID("@lead9:example.org") owner := id.UserID("@lead9:example.org")
member := id.UserID("@member9:example.org") member := id.UserID("@member9:example.org")
seedExpedition(t, "exp-9", owner, "active") seedExpedition(t, "exp-9", owner, "active")
seatLeaderFixture(t, "exp-9") if err := openParty("exp-9", owner); err != nil {
t.Fatal(err)
}
if err := joinParty("exp-9", member); err != nil { if err := joinParty("exp-9", member); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -579,8 +579,18 @@ func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction
return p.SendDM(ctx.Sender, "Equip cancelled.") return p.SendDM(ctx.Sender, "Equip cancelled.")
} }
idx, ok := parseMenuIndex(reply, len(data.Items)) idx := 0
if !ok { parsed := false
for _, c := range reply {
if c >= '0' && c <= '9' {
idx = idx*10 + int(c-'0')
parsed = true
} else {
break
}
}
idx-- // 1-indexed → 0-indexed
if !parsed || idx < 0 || idx >= len(data.Items) {
p.pending.Store(string(ctx.Sender), interaction) p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, "Invalid selection. Reply with a number from the list, or \"cancel\".") return p.SendDM(ctx.Sender, "Invalid selection. Reply with a number from the list, or \"cancel\".")
} }

View File

@@ -1,132 +0,0 @@
package plugin
import (
"strings"
"testing"
"maunium.net/go/mautrix/id"
)
// captureSink is the test implementation of MessageSink. It records every
// outbound message a handler emits so a test can assert on a handler whose
// only observable output is a DM or a room announcement — the paths that had
// no seam below the live client before this. Install it with installSink.
type captureSink struct {
msgs []outboundMessage
}
func (s *captureSink) Capture(m outboundMessage) (id.EventID, error) {
s.msgs = append(s.msgs, m)
// A stable, non-empty id keeps the *ID send variants honest for callers
// that expect an event id back (e.g. one they would later edit).
return id.EventID("$sink"), nil
}
// dmsTo returns the text of every DM captured for a given user, in order.
func (s *captureSink) dmsTo(user id.UserID) []string {
var out []string
for _, m := range s.msgs {
if m.ToUser == user {
out = append(out, m.Text)
}
}
return out
}
// roomMsgs returns the text of every room message captured for a room.
func (s *captureSink) roomMsgs(room id.RoomID) []string {
var out []string
for _, m := range s.msgs {
if m.ToRoom == room {
out = append(out, m.Text)
}
}
return out
}
// installSink points a plugin's outbound sends at a fresh captureSink and
// returns it. The Base carries no client in these tests, so without the sink
// SendDM would silently no-op — which is exactly why these paths were untestable.
func installSink(p *AdventurePlugin) *captureSink {
s := &captureSink{}
p.Sink = s
return s
}
// TestMessageSink_CapturesDMAndRoom is the seam's own unit test: it proves both
// the DM and room-message routes divert into the sink with the right target and
// text, and that the *ID variants report back the id the sink chose.
func TestMessageSink_CapturesDMAndRoom(t *testing.T) {
p := &AdventurePlugin{}
sink := installSink(p)
user := id.UserID("@player:example.org")
room := id.RoomID("!hall:example.org")
if err := p.SendDM(user, "a direct message"); err != nil {
t.Fatalf("SendDM: %v", err)
}
if err := p.SendMessage(room, "a room announcement"); err != nil {
t.Fatalf("SendMessage: %v", err)
}
evID, err := p.SendDMID(user, "an editable dm")
if err != nil {
t.Fatalf("SendDMID: %v", err)
}
if evID != "$sink" {
t.Errorf("SendDMID returned %q, want the sink's id", evID)
}
if got := sink.dmsTo(user); len(got) != 2 || got[0] != "a direct message" || got[1] != "an editable dm" {
t.Errorf("DMs to %s = %v", user, got)
}
if got := sink.roomMsgs(room); len(got) != 1 || got[0] != "a room announcement" {
t.Errorf("room messages = %v", got)
}
// A DM must not leak into the room bucket, or vice versa.
if len(sink.roomMsgs(id.RoomID(user))) != 0 {
t.Error("a DM was miscaptured as a room message")
}
}
// TestExpeditionCmdLeave_DMsBothEnds drives the real handler behind review
// fix #2 end to end — the member soft-lock path — and asserts on the two DMs it
// sends. Before the sink this was unreachable in a unit test: SendDM no-ops
// without a client, so nothing could observe that the leader was notified and
// the departing member was told they turned back.
func TestExpeditionCmdLeave_DMsBothEnds(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead-leave:example.org")
member := id.UserID("@member-leave:example.org")
seedExpedition(t, "exp-leave", owner, "active")
seatLeaderFixture(t, "exp-leave")
if err := joinParty("exp-leave", member); err != nil {
t.Fatal(err)
}
p := &AdventurePlugin{}
sink := installSink(p)
if err := p.expeditionCmdLeave(MessageContext{Sender: member}); err != nil {
t.Fatalf("expeditionCmdLeave: %v", err)
}
// The departing member is freed and told so.
memberDMs := sink.dmsTo(member)
if len(memberDMs) != 1 || memberDMs[0] != "You turn back for town. Your supplies stay with the party." {
t.Errorf("member DMs = %v", memberDMs)
}
// The leader is notified their party shrank.
leaderDMs := sink.dmsTo(owner)
if len(leaderDMs) != 1 || leaderDMs[0] == "" {
t.Fatalf("leader DMs = %v, want one departure notice", leaderDMs)
}
if !strings.Contains(leaderDMs[0], "turned back") || !strings.Contains(leaderDMs[0], "supplies stay") {
t.Errorf("leader notice = %q", leaderDMs[0])
}
// And the DB actually reflects the departure the DMs described.
if n, _ := partySize("exp-leave"); n != 1 {
t.Errorf("after leave, partySize = %d, want 1", n)
}
}

View File

@@ -310,82 +310,6 @@ func petStateFromAdvChar(c *AdventureCharacter) PetState {
} }
} }
// pet2StateFromAdvChar projects the second pet's fields off an
// AdventureCharacter. SupplyShopUnlocked / MorningDefense are always zero for
// slot 2 — those mechanics stay pet-1-only (see AdventureCharacter.Pet2*).
func pet2StateFromAdvChar(c *AdventureCharacter) PetState {
return PetState{
Type: c.Pet2Type,
Name: c.Pet2Name,
XP: c.Pet2XP,
Level: c.Pet2Level,
ArmorTier: c.Pet2ArmorTier,
Level10Date: c.Pet2Level10Date,
Arrived: c.Pet2Arrived,
ChasedAway: c.Pet2ChasedAway,
Reactivated: c.Pet2Reactivated,
}
}
// loadPet2State returns the player's second pet from the pet2_* columns. Empty
// PetState{} if the user has no row or no second pet.
func loadPet2State(userID id.UserID) (PetState, error) {
var (
s PetState
flagsRaw string
)
err := db.Get().QueryRow(
`SELECT pet2_type, pet2_name, pet2_xp, pet2_level, pet2_armor_tier,
pet2_flags_json, pet2_level_10_date
FROM player_meta WHERE user_id = ?`,
string(userID),
).Scan(&s.Type, &s.Name, &s.XP, &s.Level, &s.ArmorTier,
&flagsRaw, &s.Level10Date)
if err == sql.ErrNoRows {
return PetState{}, nil
}
if err != nil {
return PetState{}, err
}
var f petFlagsJSON
if flagsRaw != "" {
_ = json.Unmarshal([]byte(flagsRaw), &f)
}
s.Arrived = f.Arrived
s.ChasedAway = f.ChasedAway
s.Reactivated = f.Reactivated
return s, nil
}
// upsertPlayerMetaPet2State writes the second pet's column set.
func upsertPlayerMetaPet2State(userID id.UserID, s PetState) error {
flags, err := json.Marshal(petFlagsJSON{
Arrived: s.Arrived,
ChasedAway: s.ChasedAway,
Reactivated: s.Reactivated,
})
if err != nil {
return err
}
_, err = db.Get().Exec(
`INSERT INTO player_meta (
user_id, pet2_type, pet2_name, pet2_xp, pet2_level, pet2_armor_tier,
pet2_flags_json, pet2_level_10_date
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
pet2_type = excluded.pet2_type,
pet2_name = excluded.pet2_name,
pet2_xp = excluded.pet2_xp,
pet2_level = excluded.pet2_level,
pet2_armor_tier = excluded.pet2_armor_tier,
pet2_flags_json = excluded.pet2_flags_json,
pet2_level_10_date = excluded.pet2_level_10_date`,
string(userID), s.Type, s.Name, s.XP, s.Level, s.ArmorTier,
string(flags), s.Level10Date,
)
return err
}
// HouseState is the in-memory mirror of player_meta's house_* columns. Phase // HouseState is the in-memory mirror of player_meta's house_* columns. Phase
// L4e ports housing/mortgage off AdvCharacter (gogobee_legacy_migration.md // L4e ports housing/mortgage off AdvCharacter (gogobee_legacy_migration.md
// §6.5). All six fields mutate together at known sites (purchase, payoff, // §6.5). All six fields mutate together at known sites (purchase, payoff,
@@ -1270,17 +1194,6 @@ func applyPlayerMetaOverlay(c *AdventureCharacter) {
c.PetReactivated = s.Reactivated c.PetReactivated = s.Reactivated
c.PetMorningDefense = s.MorningDefense c.PetMorningDefense = s.MorningDefense
} }
if s, err := loadPet2State(uid); err == nil {
c.Pet2Type = s.Type
c.Pet2Name = s.Name
c.Pet2XP = s.XP
c.Pet2Level = s.Level
c.Pet2ArmorTier = s.ArmorTier
c.Pet2Level10Date = s.Level10Date
c.Pet2Arrived = s.Arrived
c.Pet2ChasedAway = s.ChasedAway
c.Pet2Reactivated = s.Reactivated
}
if s, err := loadHouseState(uid); err == nil { if s, err := loadHouseState(uid); err == nil {
c.HouseTier = s.Tier c.HouseTier = s.Tier
c.HouseLoanBalance = s.LoanBalance c.HouseLoanBalance = s.LoanBalance
@@ -1335,9 +1248,6 @@ func upsertAllPlayerMetaFromAdvChar(c *AdventureCharacter) error {
if err := upsertPlayerMetaPetState(uid, petStateFromAdvChar(c)); err != nil { if err := upsertPlayerMetaPetState(uid, petStateFromAdvChar(c)); err != nil {
return err return err
} }
if err := upsertPlayerMetaPet2State(uid, pet2StateFromAdvChar(c)); err != nil {
return err
}
if err := upsertPlayerMetaHouseState(uid, houseStateFromAdvChar(c)); err != nil { if err := upsertPlayerMetaHouseState(uid, houseStateFromAdvChar(c)); err != nil {
return err return err
} }

View File

@@ -92,30 +92,6 @@ var (
type Base struct { type Base struct {
Client *mautrix.Client Client *mautrix.Client
Prefix string Prefix string
// Sink, when non-nil, receives every outbound message (DM or room) the
// plugin would otherwise hand to the Matrix client, and the client is
// never touched. It is the test seam for handlers whose only observable
// output is a DM — beginCombatTurn's terminal close-out,
// expeditionCmdLeave, the arena season announcement. Production leaves it
// nil, so behaviour is unchanged. See captureSink in the test files.
Sink MessageSink
}
// outboundMessage is one message a handler tried to send. Exactly one of
// ToUser (a DM) or ToRoom (a room message) is set.
type outboundMessage struct {
ToUser id.UserID
ToRoom id.RoomID
Text string
}
// MessageSink captures a plugin's outbound messages in place of the live
// client. Capture returns the event ID to report back to the caller, so the
// *ID send variants keep working. Unexported param by design: only in-package
// tests implement it.
type MessageSink interface {
Capture(outboundMessage) (id.EventID, error)
} }
// NewBase creates a Base with default prefix "!". // NewBase creates a Base with default prefix "!".
@@ -584,10 +560,6 @@ func textContent(text string) *event.MessageEventContent {
// SendMessage sends a message to a room, auto-formatting Markdown as HTML. // SendMessage sends a message to a room, auto-formatting Markdown as HTML.
func (b *Base) SendMessage(roomID id.RoomID, text string) error { func (b *Base) SendMessage(roomID id.RoomID, text string) error {
if b != nil && b.Sink != nil {
_, err := b.Sink.Capture(outboundMessage{ToRoom: roomID, Text: text})
return err
}
content := textContent(text) content := textContent(text)
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content) _, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
if err != nil { if err != nil {
@@ -598,9 +570,6 @@ func (b *Base) SendMessage(roomID id.RoomID, text string) error {
// SendMessageID sends a message and returns the event ID. // SendMessageID sends a message and returns the event ID.
func (b *Base) SendMessageID(roomID id.RoomID, text string) (id.EventID, error) { func (b *Base) SendMessageID(roomID id.RoomID, text string) (id.EventID, error) {
if b != nil && b.Sink != nil {
return b.Sink.Capture(outboundMessage{ToRoom: roomID, Text: text})
}
content := textContent(text) content := textContent(text)
resp, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content) resp, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
if err != nil { if err != nil {
@@ -738,10 +707,6 @@ func IsDMRoom(roomID id.RoomID, userID id.UserID) bool {
// No-op when the Matrix client is nil (which happens in unit tests that // No-op when the Matrix client is nil (which happens in unit tests that
// construct plugins without a real client). // construct plugins without a real client).
func (b *Base) SendDM(userID id.UserID, text string) error { func (b *Base) SendDM(userID id.UserID, text string) error {
if b != nil && b.Sink != nil {
_, err := b.Sink.Capture(outboundMessage{ToUser: userID, Text: text})
return err
}
if b == nil || b.Client == nil { if b == nil || b.Client == nil {
return nil return nil
} }
@@ -754,9 +719,6 @@ func (b *Base) SendDM(userID id.UserID, text string) error {
// SendDMID sends a direct message and returns the event ID (for later editing). // SendDMID sends a direct message and returns the event ID (for later editing).
func (b *Base) SendDMID(userID id.UserID, text string) (id.EventID, error) { func (b *Base) SendDMID(userID id.UserID, text string) (id.EventID, error) {
if b != nil && b.Sink != nil {
return b.Sink.Capture(outboundMessage{ToUser: userID, Text: text})
}
roomID, err := b.GetDMRoom(userID) roomID, err := b.GetDMRoom(userID)
if err != nil { if err != nil {
return "", err return "", err

View File

@@ -86,24 +86,7 @@ func (p *AdventurePlugin) runZoneCombatRoster(
continue continue
} }
// A downed member sits it out. They own no close-out claim — and the player, e, dndChar, err := p.buildZoneCombatants(uid, monster, tier, dmMood)
// check runs before the arm, so sitting out doesn't cost them the
// ability they had readied.
if !leader {
if hp, _ := dndHPSnapshot(uid); hp <= 0 {
continue
}
}
// Auto-resolve builds and fights in one breath, so this seat can arm and
// apply together. The turn-based path has to split the two — see
// consumeArmedAbility.
armed := ""
if c, cerr := LoadDnDCharacter(uid); cerr == nil && c != nil {
trySimAutoArm(c)
armed = consumeArmedAbility(c)
}
player, e, dndChar, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed)
if err != nil { if err != nil {
if leader { if leader {
return bail(err) return bail(err)
@@ -113,6 +96,9 @@ func (p *AdventurePlugin) runZoneCombatRoster(
} }
if leader { if leader {
enemy = e enemy = e
} else if hp, _ := dndHPSnapshot(uid); hp <= 0 {
// A downed member sits it out. They own no close-out claim.
continue
} }
// Pre-combat one-shots that the turn-based path does NOT share: a queued // Pre-combat one-shots that the turn-based path does NOT share: a queued
@@ -150,8 +136,11 @@ func (p *AdventurePlugin) runZoneCombatRoster(
} }
} }
p.grantCombatAchievements(b.uid, seatRes)
persistDnDHPAfterCombat(b.uid, seatRes.PlayerEndHP) persistDnDHPAfterCombat(b.uid, seatRes.PlayerEndHP)
p.postCombatBookkeeping(b.uid, b.dndChar, b.mods.BerserkerRage, seatRes, b.mods) if err := persistDnDPostCombatSubclass(b.dndChar, b.mods.BerserkerRage, seatRes, b.mods); err != nil {
slog.Error("dnd: post-combat subclass persist (zone)", "user", b.uid, "err", err)
}
if xp := zoneCombatXP(seatRes, monster.CR, tier); xp > 0 { if xp := zoneCombatXP(seatRes, monster.CR, tier); xp > 0 {
if _, err := p.grantDnDXP(b.uid, xp); err != nil { if _, err := p.grantDnDXP(b.uid, xp); err != nil {