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
golang.org/x/image v0.40.0
maunium.net/go/mautrix v0.28.1
modernc.org/sqlite v1.50.1
modernc.org/sqlite v1.53.0
)
require (
@@ -43,7 +43,7 @@ require (
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // 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/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=
maunium.net/go/mautrix v0.28.1 h1:Hic3oDMPbLbQu1fhboTRAKZcORMjzzkjxsa+SGk60b0=
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.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
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/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
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.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
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/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
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/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
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/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
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/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
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:
// loadCombatParticipants is skipped entirely when it reads 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 {
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);
-- 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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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: "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: "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.expeditionAmbientTicker()
go p.expeditionAutoRunTicker()
go p.expeditionExtractionSweepTicker()
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
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
}
@@ -420,20 +412,6 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "lore") {
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)
if p.IsCommand(ctx.Body, "bail") {
@@ -531,8 +509,6 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
return p.handleMasteryCmd(ctx)
case lower == "treasures" || strings.HasPrefix(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.")
@@ -550,7 +526,6 @@ const advHelpText = `**Adventure Commands**
` + "`!adventure equip-magic`" + ` — Equip magic items (curios) into your D&D slots
` + "`!adventure sell <item>`" + ` — Sell an inventory item (or ` + "`sell all`" + `)
` + "`!adventure inventory`" + ` — View your inventory
` + "`!adventure vault`" + ` — Store items safely (Tier-4 Established home; ` + "`vault store/take <item>`" + `)
` + "`!adventure leaderboard`" + ` — View the leaderboard
` + "`!adventure respond`" + ` — Respond to a mid-day event
` + "`!adventure rivals`" + ` — View rival duel records
@@ -915,9 +890,9 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
case "npc_encounter":
return p.resolveNPCEncounter(ctx, interaction)
case "pet_arrival":
return p.resolvePetArrival(ctx, interaction)
return p.resolvePetArrival(ctx)
case "pet_type":
return p.resolvePetType(ctx, interaction)
return p.resolvePetType(ctx)
case "pet_name":
return p.resolvePetName(ctx, interaction)
}
@@ -1147,7 +1122,7 @@ func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCha
return
}
if count < maxTreasuresForTier(char.HouseTier) {
if count < advMaxTreasures {
// Directly save
if err := advSaveTreasure(userID, drop.Def); err != nil {
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 {
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 {

View File

@@ -36,15 +36,22 @@ func (p *AdventurePlugin) runBabysitDailyTrickle(char *AdventureCharacter) {
if !char.BabysitActive {
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
if char.HasPet() {
leveled = advancePetLevelsFromXP(&char.PetXP, &char.PetLevel, &char.PetLevel10Date, petXPPerBabysitDay*100) || leveled
}
if char.HasPet2() {
leveled = advancePetLevelsFromXP(&char.Pet2XP, &char.Pet2Level, &char.Pet2Level10Date, petXPPerBabysitDay*100) || leveled
if char.HasPet() && char.PetLevel < 10 {
// 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")
}
}
outcome := "pet_care"
if leveled {

View File

@@ -92,25 +92,13 @@ type AdventureCharacter struct {
PetSupplyShopUnlocked bool
PetLevel10Date string
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
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
StreakDecayed bool
CraftsSucceeded int
DeathSource string // "adventure" | "arena" | "" (legacy/unknown — treated as adventure)
DeathLocation string // human-readable location of last death; cleared on revive is not required
AutoBabysit bool
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
StreakDecayed bool
CraftsSucceeded int
DeathSource string // "adventure" | "arena" | "" (legacy/unknown — treated as adventure)
DeathLocation string // human-readable location of last death; cleared on revive is not required
}
type AdvEquipment struct {
@@ -282,11 +270,6 @@ func (c *AdventureCharacter) HasPet() bool {
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.
func (c *AdventureCharacter) HasHouse() bool {
return c.HouseTier > 0 || c.HouseLoanBalance > 0
@@ -579,7 +562,7 @@ func loadAdvInventory(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 = 0
FROM adventure_inventory WHERE user_id = ?
ORDER BY tier DESC, value DESC`, string(userID))
if err != nil {
return nil, err
@@ -630,10 +613,7 @@ func clearAdvInventory(userID id.UserID) ([]AdvItem, error) {
return nil, err
}
d := db.Get()
// Delete only what loadAdvInventory returned — vaulted rows are out of play
// 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))
_, err = d.Exec(`DELETE FROM adventure_inventory WHERE user_id = ?`, string(userID))
if err != nil {
return nil, err
}
@@ -643,62 +623,10 @@ func clearAdvInventory(userID id.UserID) ([]AdvItem, error) {
func advInventoryCount(userID id.UserID) int {
d := db.Get()
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
}
// 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) {
d := db.Get()
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},
}
// houseTierWorkshop is the T3 "Comfortable" house that unlocks the workshop —
// a flat craft-success bonus while crafting at home.
const houseTierWorkshop = 3
// 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 {
// 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%.
func craftingSuccessRate(foragingLevel, minForaging int) float64 {
base := 0.50
levelsAbove := foragingLevel - minForaging
bonus := float64(levelsAbove) / 5.0 * 0.03
rate := base + bonus + workshopBonus
cap := 0.95
if workshopBonus > 0 {
cap = 0.98
}
if rate > cap {
return cap
rate := base + bonus
if rate > 0.95 {
return 0.95
}
return rate
}
@@ -388,7 +369,7 @@ type CraftResult struct {
// their current foraging level, plus a teaser line for the next unlock
// threshold. Hides exact ingredient lists for recipes the player hasn't
// 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
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._")
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.
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))
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",
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
// 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 {
return nil, items
}
@@ -474,7 +452,7 @@ func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int,
break
}
rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging, workshopBonus)
rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging)
success := rand.Float64() < rate
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
// 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 (
advEventChanceDigest = 0.08
advEventChanceSell = 0.05
advEventChanceArena = 0.05
advEventChanceZoneClear = 0.08
advEventChanceDigest = 0.08
advEventChanceSell = 0.05
advEventChanceArena = 0.05
)
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.
func TestClaimDailyEventSlot_OnePerDay(t *testing.T) {
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{}
// Slot is the pet slot the arrival conversation is filling: 1 (the first pet)
// 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 advPendingPetArrival struct{}
type advPendingPetType struct{ Slot int }
type advPendingPetType struct{}
type advPendingPetName struct {
PetType string
Slot int
}
// ── 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-1 mechanic but covers barding for both companions. Each pet's block is
// 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()) {
// Pet supply shop (unlocks 1 week after pet level 10)
if char.PetSupplyShopUnlocked && char.HasPet() {
sb.WriteString("\n---\n")
if char.HasPet() {
sb.WriteString(fmt.Sprintf("🐾 **Pet Supplies** — for %s\n\n", char.PetName))
sb.WriteString(petArmorShopView(char.PetType, char.PetArmorTier, "petbuy"))
}
if char.HasPet2() {
sb.WriteString(fmt.Sprintf("\n🐾 **Pet Supplies** — for %s\n\n", char.Pet2Name))
sb.WriteString(petArmorShopView(char.Pet2Type, char.Pet2ArmorTier, "pet2buy"))
}
sb.WriteString(fmt.Sprintf("🐾 **Pet Supplies** — for %s\n\n", char.PetName))
sb.WriteString(petArmorShopView(char))
}
return sb.String()
@@ -281,11 +269,8 @@ func (p *AdventurePlugin) handleThomCmd(ctx MessageContext) error {
case lower == "buy" || strings.HasPrefix(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 "):
return p.petArmorBuyForSlot(ctx, strings.TrimSpace(args[7:]), 1)
return p.handlePetArmorBuy(ctx, char, strings.TrimSpace(args[7:]))
default:
return p.SendDM(ctx.Sender, "Unknown command. Type `!thom` to visit Krooke Realty.")
@@ -688,18 +673,18 @@ func petArmorDefs(petType string) []PetArmorDef {
return petCatArmor
}
func petArmorShopView(petType string, armorTier int, buyCmd string) string {
func petArmorShopView(char *AdventureCharacter) string {
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")
return sb.String()
}
for _, def := range defs {
if def.Tier <= armorTier {
if def.Tier == armorTier {
if def.Tier <= char.PetArmorTier {
if def.Tier == char.PetArmorTier {
sb.WriteString(fmt.Sprintf("🟢 %s (T%d) — Currently equipped\n", def.Name, def.Tier))
}
continue
@@ -707,14 +692,11 @@ func petArmorShopView(petType string, armorTier int, buyCmd string) string {
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("\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()
}
// petArmorBuyForSlot buys the next barding tier for the given pet slot. The
// 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 {
func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureCharacter, tierStr string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
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.")
}
// Slot accessors keep the pet-1 and pet-2 field sets isolated.
hasPet, petType, petName, curTier := char.HasPet(), char.PetType, char.PetName, char.PetArmorTier
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.HasPet() {
return p.SendDM(ctx.Sender, "You don't have a pet.")
}
if !char.PetSupplyShopUnlocked {
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
for _, c := range tierStr {
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')
}
if tier != curTier+1 {
if tier <= curTier {
if tier != char.PetArmorTier+1 {
if tier <= char.PetArmorTier {
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
for i := range defs {
if defs[i].Tier == tier {
@@ -768,26 +742,18 @@ func (p *AdventurePlugin) petArmorBuyForSlot(ctx MessageContext, tierStr string,
balance := p.euro.GetBalance(ctx.Sender)
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 slot == 2 {
reason = "pet2_armor_" + tierStr
}
if !p.euro.Debit(ctx.Sender, float64(def.Price), reason) {
if !p.euro.Debit(ctx.Sender, float64(def.Price), "pet_armor_"+tierStr) {
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 {
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, 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) {
text := petArmorShopView("dog", 0, "petbuy")
char := &AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true, PetArmorTier: 0}
text := petArmorShopView(char)
if !strings.Contains(text, "⬆️") {
t.Error("should show upgrade indicators")
@@ -296,7 +297,8 @@ func TestPetArmorShopView_HasUpgrades(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") {
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.")
}
idx, ok := parseMenuIndex(reply, len(data.Items))
if !ok {
// Parse number
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\".")
}
@@ -492,33 +502,8 @@ func (p *AdventurePlugin) handleMasterworkEquipConfirm(ctx MessageContext, inter
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
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)
if err != nil {
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.
// Returns true if leveled up. Shares the level-up loop with the babysit trickle
// via advancePetLevelsFromXP.
// petGrantXP adds XP to the pet and handles level-ups. Returns true if leveled up.
func petGrantXP(pet *PetState) bool {
if !pet.HasPet() {
if !pet.HasPet() || pet.Level >= 10 {
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
// 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
pet.XP += int(petXPPerAction * 100) // store as centixp for precision
leveled := false
for *level < 10 {
needed := petXPToNextLevel(*level) * 100
if *xp < needed {
for pet.Level < 10 {
needed := petXPToNextLevel(pet.Level) * 100
if pet.XP < needed {
break
}
*xp -= needed
*level++
pet.XP -= needed
pet.Level++
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
}
@@ -215,23 +206,6 @@ func petShouldArrive(pet PetState, house HouseState) bool {
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
// surfaces from an expedition (voluntary extract, abandon, or a survived
// 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)
house, _ := loadHouseState(userID)
if petShouldArrive(pet, house) {
p.petArrivalDM(userID, 1)
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)
p.petArrivalDM(userID)
}
}
// petArrivalDM sends the initial "there's an animal in your house" DM for the
// given slot (1 or 2).
func (p *AdventurePlugin) petArrivalDM(userID id.UserID, slot int) {
// petArrivalDM sends the initial "there's an animal in your house" DM.
func (p *AdventurePlugin) petArrivalDM(userID id.UserID) {
// Don't overwrite an existing pending interaction
if _, exists := p.pending.Load(string(userID)); exists {
return
}
intro := "There's an animal in your house. It looks like a..."
if slot == 2 {
intro = "There's *another* animal in your house. Your first pet seems unbothered. It looks like a..."
}
text := intro + "\n\n" +
text := "There's an animal in your house. It looks like a...\n\n" +
"🚪 Chase it away\n" +
"🍖 Feed it\n\n" +
"Reply with `chase` or `feed`."
p.pending.Store(string(userID), &advPendingInteraction{
Type: "pet_arrival",
Data: &advPendingPetArrival{Slot: slot},
Data: &advPendingPetArrival{},
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
})
_ = 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.
func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext, interaction *advPendingInteraction) error {
func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
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.")
}
slot := petSlotFromData(interaction.Data.(*advPendingPetArrival).Slot)
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
if reply == "chase" || reply == "🚪" {
if slot == 2 {
char.Pet2ChasedAway = true
char.Pet2Reactivated = false
} else {
char.PetChasedAway = true
char.PetReactivated = false
}
char.PetChasedAway = true
char.PetReactivated = false
_ = 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.")
}
@@ -327,7 +276,7 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext, interaction *adv
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "pet_type",
Data: &advPendingPetType{Slot: slot},
Data: &advPendingPetType{},
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
})
return p.SendDM(ctx.Sender, text)
@@ -336,19 +285,18 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext, interaction *adv
// Invalid response — re-prompt
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "pet_arrival",
Data: &advPendingPetArrival{Slot: slot},
Data: &advPendingPetArrival{},
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
})
return p.SendDM(ctx.Sender, "Reply with `chase` or `feed`.")
}
// 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.Lock()
defer userMu.Unlock()
slot := petSlotFromData(interaction.Data.(*advPendingPetType).Slot)
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
petType := ""
@@ -361,7 +309,7 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext, interaction *advPen
if petType == "" {
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "pet_type",
Data: &advPendingPetType{Slot: slot},
Data: &advPendingPetType{},
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
})
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{
Type: "pet_name",
Data: &advPendingPetName{PetType: petType, Slot: slot},
Data: &advPendingPetName{PetType: petType},
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.")
}
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.PetName = name
char.PetArrived = true
char.PetChasedAway = false
char.PetLevel = 1
char.PetXP = 0
}
char.PetType = data.PetType
char.PetName = name
char.PetArrived = true
char.PetChasedAway = false
char.PetLevel = 1
char.PetXP = 0
if err := saveAdvCharacter(char); err != nil {
return p.SendDM(ctx.Sender, "Failed to save.")
}
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char))
emoji := "🐶"
if data.PetType == "cat" {
emoji = "🐱"
}
tail := fmt.Sprintf("%s will join you in combat. %s will not explain their decisions.", name, name)
if petSlotFromData(data.Slot) == 2 {
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))
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.",
emoji, name, titleCase(data.PetType), name, name))
}
// ── Morning Pet Events ─────────────────────────────────────────────────────

View File

@@ -1037,17 +1037,8 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
// ── Treasure Discard Prompt ──────────────────────────────────────────────────
func renderAdvTreasureDiscardPrompt(newTreasure *AdvTreasureDef, existing []AdvTreasureDef) string {
// The flavor templates below are written for the base 3-slot set. With a
// T3 trophy room the set can hold 4, so fall back to a plain dynamic list
// 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()
if len(TreasureInventoryCap) == 0 {
return "You found a treasure but your inventory is full. Reply 1, 2, or 3 to discard, or `keep`."
}
// Build substitution map with existing treasure info

View File

@@ -140,12 +140,8 @@ func findTemperMaterial(items []AdvItem) (AdvItem, bool) {
return AdvItem{}, false
}
// parseMenuIndex reads a leading 1-indexed number off a menu reply and returns
// the bounds-checked 0-indexed position. The single parser behind every "reply
// 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) {
// parseTemperIndex reads a leading 1-indexed number and bounds-checks it.
func parseTemperIndex(reply string, n int) (int, bool) {
idx, parsed := 0, false
for _, c := range strings.TrimSpace(reply) {
if c < '0' || c > '9' {
@@ -218,7 +214,7 @@ func (p *AdventurePlugin) resolveTemperPick(ctx MessageContext, interaction *adv
if strings.EqualFold(reply, "cancel") {
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 {
p.pending.Store(string(ctx.Sender), interaction)
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},
}
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) {
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)
}
}

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
// 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 ─────────────────────────────────────────────────────
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)
}
}
// 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"
)
// 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.
func (p *AdventurePlugin) grantCombatAchievements(userID id.UserID, result CombatResult) {
if p.achievements == nil {
@@ -111,7 +87,7 @@ func (p *AdventurePlugin) runDungeonCombat(
applySubclassPassives(&playerStats, &playerMods, dndChar)
applyMagicItemEffects(&playerStats, &playerMods, userID)
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)
}
applyDnDDungeonMonsterLayer(&enemyStats, loc.Tier)
@@ -153,8 +129,12 @@ func (p *AdventurePlugin) runDungeonCombat(
// until a player-driven use command lands.
consumeFiredHealingItems(userID, countHealEventsFired(result))
p.grantCombatAchievements(userID, result)
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 _, 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+
char, err := loadAdvCharacter(userID)
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 {
items = updatedItems
for _, cr := range craftResults {

View File

@@ -101,11 +101,7 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
if refusal != "" {
return p.replyDM(ctx, refusal)
}
// The persisted session scales enemy HP for a party (solo scales by 1.0, so
// 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))
enemyHP := enemy.Stats.MaxHP
// Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded
// 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() {
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
// before anyone is asked to act, so the opening block narrates the hit
// 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.
func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSession, enemy Combatant) string {
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)
var zone ZoneDefinition
@@ -363,12 +350,28 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
var b strings.Builder
switch sess.Status {
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
if run != nil {
tier = int(zone.Tier)
}
bossOnExpedition := p.applyOwnerWinEffects(userID, sess.EnemyID, elite)
p.grantSeatWinXP(userID, sess.PlayerHP, sess.PlayerHPMax, monster, tier)
if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 {
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 != "" {
b.WriteString(line + "\n")
}
@@ -392,7 +395,11 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
}
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)
if line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence); line != "" {
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),
// but no death timer. Chosen candidate from the migration plan's
// 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))
default:
@@ -590,18 +598,6 @@ func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args strin
if msg := p.chargeSpellCost(uid, spell, slotLevel); 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{
Label: out.Desc,
Action: "spell_cast",

View File

@@ -162,11 +162,9 @@ type CombatModifiers struct {
// ArcaneWardHP: flat HP buffer absorbed before player HP. Refilled at the
// start of each combat by Abjuration L5+ (2× Mage level, +prof at L7).
// Persists across rounds within a single combat; not refunded between fights.
// GrimHarvestSlot/Necrotic: snapshot of the damaging spell stashed for the
// post-combat Grim Harvest hook (Necromancy L5+) — by applyPendingCast on
// the auto-resolve path, and by seatFightStartMods reading the seat's
// statuses on the turn-based one. Heal fires only if that spell's event is
// what dropped the enemy to 0.
// GrimHarvestSlot/Necrotic: snapshot of the queued spell stashed by
// applyPendingCast for the post-combat Grim Harvest hook (Necromancy L5+).
// Heal fires only if the spell event is what dropped the enemy to 0.
ArcaneWardHP int
GrimHarvestSlot int
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 {
// 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
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP {
enemyStart = enemy.Stats.StartHP
@@ -295,141 +289,6 @@ func roundInitiative(st *combatState, enemy *Combatant, phase *CombatPhase) []in
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
// fight is over.
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.
for _, s := range roundInitiative(st, enemy, phase) {
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
}
continue
@@ -582,78 +448,6 @@ func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, s
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
// 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
@@ -676,8 +470,17 @@ func endOfRoundForSeat(st *combatState, phase *CombatPhase, result *CombatResult
}
// Misty crowd revenge (debuff for declining Misty)
if over := mistyCrowdRevenge(st, player, phaseName); over {
return true
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
}
}
// A character the round just killed stops acting, but the fight goes on if
@@ -714,7 +517,16 @@ func endOfRoundForSeat(st *combatState, phase *CombatPhase, result *CombatResult
}
// 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
// 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
// characterization golden and the d8prereq corpus are untouched. A party's action
// 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.
// The enemy swings at one seat per round, not at everyone.
func TestSimulateParty_EnemyStrikesOneSeatPerRound(t *testing.T) {
tank := baseEnemy()
tank.Stats.MaxHP = 5000
party := simulatePartyWithRNG(
res := simulatePartyWithRNG(
[]Combatant{basePlayer(), basePlayer(), basePlayer()}, tank, dungeonCombatPhases, seededRNG(23))
budget := int(partyActionExpectation(3))
if partyActionExpectation(3) > float64(budget) {
budget++ // ceil: the coin-flip round can add one action
}
sawMulti := false
for round, n := range enemyRollsPerRound(party) {
if n > budget {
t.Fatalf("party round %d: enemy swung %d times, over the %d-action budget", round, n, budget)
perRound := map[int]map[int]bool{}
for _, e := range res.Events {
if e.Actor != "enemy" || e.Roll == 0 {
continue
}
if n > 1 {
sawMulti = true
if perRound[e.Round] == nil {
perRound[e.Round] = map[int]bool{}
}
perRound[e.Round][e.Seat] = true
}
if !sawMulti {
t.Fatal("a party of 3 never faced more than one enemy swing in a round — action economy did not scale")
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))
}
}
}

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))
case "environmental":
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":
return fmt.Sprintf("%s deals %d.", who, e.Damage)
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.
scanMoodEventsFromEvents(sess.RunID, sess.TurnLog)
// Every seat's HP lands on their sheet regardless of how the fight ended, and
// 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.
// Every seat's HP lands on their sheet regardless of how the fight ended.
for seat := range sess.RosterSize() {
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
p.postCombatBookkeepingForSeat(sess, seat)
}
switch sess.Status {
@@ -65,7 +62,8 @@ func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
case CombatStatusLost:
return p.finishPartyLoss(ct, zone, cadence)
case CombatStatusFled:
endRunOnLoss(owner, sess.RunID, false)
_ = abandonZoneRun(owner)
forceExtractExpeditionForRunLoss(owner, "combat flee")
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))
default:
@@ -83,7 +81,16 @@ func (p *AdventurePlugin) finishPartyWin(
sess := ct.sess
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
if run != nil {
@@ -100,8 +107,14 @@ func (p *AdventurePlugin) finishPartyWin(
uid := id.UserID(sess.seatUserID(seat))
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
// A member who went down before the killing blow still earns the kill.
p.grantSeatWinXP(uid, hp, hpMax, monster, tier)
// A member who went down before the killing blow still earns the kill
// 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
if shared != "" && seat == 0 {
@@ -140,7 +153,11 @@ func (p *AdventurePlugin) finishPartyLoss(ct *combatTurn, zone ZoneDefinition, c
sess := ct.sess
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)
out := make([]string, sess.RosterSize())
@@ -164,62 +181,3 @@ func (p *AdventurePlugin) eachSeat(ct *combatTurn, block string) []string {
}
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 {
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)
if hp <= 0 {
if leader {
@@ -87,32 +96,7 @@ func (p *AdventurePlugin) buildFightSeats(
continue
}
// Consumed exactly once for the fight, here. Every later rebuild
// 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,
})
seats = append(seats, CombatSeatSetup{UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player})
}
return seats, enemy, senderSkip, ""
}

View File

@@ -88,24 +88,6 @@ type ActorStatuses struct {
// and druids with no sustained DPS once their burst landed.
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.
// 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.
@@ -239,26 +221,23 @@ func (s *ActorStatuses) applyBuffDelta(d turnBuffDelta) {
// the Abjuration Arcane Ward is normally non-zero at fight start — the
// 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.
func seedCombatSessionOneShots(s *CombatSession, seat CombatSeatSetup) bool {
return seedActorOneShots(&s.Statuses.ActorStatuses, seat)
func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) bool {
return seedActorOneShots(&s.Statuses.ActorStatuses, playerMods)
}
// 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
// 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.
func seedActorOneShots(st *ActorStatuses, seat CombatSeatSetup) bool {
playerMods := seat.Mods
func seedActorOneShots(st *ActorStatuses, playerMods CombatModifiers) bool {
st.WardCharges = playerMods.WardCharges
st.SporeRounds = playerMods.SporeCloud
st.ReflectFrac = playerMods.ReflectNext
st.AutoCritFirst = playerMods.AutoCritFirst
st.ArcaneWardHP = playerMods.ArcaneWardHP
st.HealChargesLeft = playerMods.HealItemCharges
st.ArmedAbility = seat.ArmedAbility
return st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0 ||
st.ArmedAbility != ""
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0
}
// 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 {
return nil, fmt.Errorf("start combat session: empty roster")
}
// Party-only enemy HP bump (solo roster scales by 1.0). The per-turn rebuild
// 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))
enemyHP := enemy.Stats.MaxHP
owner := seats[0]
sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID,
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
// sess.Statuses that the save below flushes along with the participants.
dirty := seedCombatSessionOneShots(sess, owner)
dirty := seedCombatSessionOneShots(sess, owner.Mods)
if len(seats) > 1 {
ps := make([]CombatParticipant, 0, len(seats)-1)
for i, s := range seats[1:] {
var st ActorStatuses
seedActorOneShots(&st, s)
seedActorOneShots(&st, s.Mods)
ps = append(ps, CombatParticipant{
Seat: i + 1, UserID: string(s.UserID), HP: s.HP, HPMax: s.HPMax, Statuses: st,
})
@@ -549,10 +525,6 @@ type CombatSeatSetup struct {
HPMax int
Mods CombatModifiers
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).

View File

@@ -33,18 +33,11 @@ import (
// + 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
// 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(
userID id.UserID,
monster DnDMonsterTemplate,
tier int,
dmMood int,
armed string,
) (player Combatant, enemy Combatant, dndChar *DnDCharacter, err error) {
tilt := dmMoodCombatTilt(dmMood)
char, err := loadAdvCharacter(userID)
@@ -71,7 +64,10 @@ func (p *AdventurePlugin) buildZoneCombatants(
applyRacePassives(&playerStats, &playerMods, dndChar)
applySubclassPassives(&playerStats, &playerMods, dndChar)
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()
// 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))
var enemy Combatant
for seat, uid := range seats {
// The ability this seat armed was consumed once, at fight start, and its
// 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)
player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood)
if err != nil {
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
// through the turn engine's resume/commit cycle, so only the persistent
// stat deltas are applied here — and only that seat's own.
applySessionBuffs(&player, st)
applySessionBuffs(&player, sess.actorStatusesForSeat(seat))
players[seat] = &player
if seat == 0 {
// The enemy build reads only (monster, tier, dmMood): every seat
// rebuilds the identical stat block, so seat 0's copy is the fight's.
// Only the *player* half of the build varies by seat.
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
}
// 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
// buff onto one rebuilt character. The buffs are stored as accumulated deltas
// (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) {
// Arcane Ward is the one resource normally live at fight start.
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")
}
if s.Statuses.ArcaneWardHP != 15 {
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.
empty := &CombatSession{}
if seedCombatSessionOneShots(empty, CombatSeatSetup{}) {
if seedCombatSessionOneShots(empty, CombatModifiers{}) {
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
// 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)
// Pet modifiers
if char.HasPet() {
pets = append(pets, struct{ level, armor int }{char.PetLevel, char.PetArmorTier})
}
if char.HasPet2() {
pets = append(pets, struct{ level, armor int }{char.Pet2Level, char.Pet2ArmorTier})
}
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
mods.PetAttackProc = petAttackChance(char.PetLevel)
mods.PetAttackDmg = 3 + char.PetLevel // per-attack variance added in engine
mods.PetDeflectProc = petDeflectChance(char.PetLevel, char.PetArmorTier)
mods.PetWhiffProc = 0.01 + float64(char.PetLevel)*0.005
}
if char.PetMorningDefense {
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.stampSeat(0, acting)
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()
// stepEnemyTurn seats its target before anything resolves, and the whole
// phase lands on that one character.
te.stampSeat(0, te.st.seatIdx)
case CombatPhaseRoundEnd:
te.stepRoundEnd()
case CombatPhaseOver:
@@ -616,39 +615,34 @@ func (te *turnEngine) stepEnemyTurn() {
return
}
te.st.seat(target)
// A control spell cast last phase forfeits the enemy's whole turn (every
// attack-action) this round — unless the enemy is fear_immune, in which case
// the control fizzled and it acts as normal.
// A control spell cast last phase forfeits the enemy's attack this round —
// unless the enemy is fear_immune, in which case the control fizzled and it
// acts as normal.
if te.st.enemySkipFirst {
te.st.enemySkipFirst = false
mark := len(te.st.events)
if enemyImmuneToControl(te.enemy, te.st) {
te.st.events = append(te.st.events, CombatEvent{
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "fear_resist",
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
})
te.stampSeat(mark, target)
} else {
te.st.events = append(te.st.events, CombatEvent{
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held",
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
})
te.stampSeat(mark, target)
te.advance()
return
}
}
// Monster ability fires once, at the top of the enemy turn, against the
// initial target. cleave / lifesteal resolve their own damage and stand in
// for the enemy's first attack-action this round; every other effect (poison
// / stun / enrage / armor_break) is a rider that leaves the multiattack below
// intact. applyAbility returns true when the player went down without a save.
// Monster ability fires at the top of the enemy turn. cleave / lifesteal
// resolve their own damage and stand in for the attack action this round;
// every other effect (poison / stun / enrage / armor_break) is a rider that
// leaves the multiattack below intact. applyAbility returns true when the
// player went down without a save.
abilityDealtDamage := false
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) {
te.stampSeat(mark, target)
// 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
// turn on that kill.
@@ -659,90 +653,57 @@ func (te *turnEngine) stepEnemyTurn() {
}
return
}
te.stampSeat(mark, target)
switch te.enemy.Ability.Effect {
case "cleave", "lifesteal":
abilityDealtDamage = true
}
}
// The enemy takes enemyActionsThisRound() attack-actions, each its full
// SRD multiattack, re-targeting a fresh standing seat per action so a party's
// damage is spread across the roster rather than pinning the first target.
// A solo roster is exactly one action against its one seat — no re-target is
// drawn — so its event stream and RNG draws are unchanged. When the ability
// already dealt damage it was the first action, so one fewer follows; for solo
// that collapses to the old "the ability stood in for the attack" skip.
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)
if !abilityDealtDamage {
// Pet defensive procs are a single proc per enemy turn: roll once, then
// spend it 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 round. (This deliberately diverges from the
// auto-resolve engine's apply-to-all model.)
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
if petDeflect {
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) {
swing := *te.enemy
swing.Stats.Attack = atk.Damage
swing.Stats.AttackBonus = atk.AttackBonus
// Spend the proc on the first swing only; later swings see false.
swingWhiff := petWhiff && i == 0
swingDeflect := petDeflect && i == 0
decided := resolveEnemyAttack(te.st, te.st.c, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false)
if te.st.playerHP <= 0 {
// The target is down. The enemy stops swinging at a corpse; the
// fight ends only if the roster is empty.
if !te.st.anyAlive() {
te.finish(CombatStatusLost)
return
}
break
}
if decided || te.st.enemyHP <= 0 {
te.finish(CombatStatusWon)
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
petDeflect := te.st.c.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.st.c.Mods.PetDeflectProc
if petDeflect {
te.result.PetDeflected = true
}
for i, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) {
swing := *te.enemy
swing.Stats.Attack = atk.Damage
swing.Stats.AttackBonus = atk.AttackBonus
swingWhiff := petWhiff && i == 0
swingDeflect := petDeflect && i == 0
decided := resolveEnemyAttack(te.st, te.st.c, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false)
if te.st.playerHP <= 0 {
te.stampSeat(mark, seat)
// The target is down. The enemy stops swinging at a corpse; the fight
// ends only if the roster is empty, otherwise this action is over and
// the caller re-targets.
if !te.st.anyAlive() {
te.finish(CombatStatusLost)
return true
}
return false
}
if decided || te.st.enemyHP <= 0 {
te.stampSeat(mark, seat)
te.finish(CombatStatusWon)
return true
}
}
te.stampSeat(mark, seat)
return false
}
// turnAbilityFires decides whether a monster ability triggers this enemy turn.
// The auto-resolve engine gates abilities on its named phase clock (Opening /
// Clash / Decisive); the turn-based duel has no phase clock, so the phase tag
@@ -795,9 +756,7 @@ func (te *turnEngine) stepRoundEnd() {
// Concentration aura (Spirit Guardians et al.): the lingering spell bites
// 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
// settles the fight before the enemy knits its wounds back — and before
// 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.
// settles the fight before the enemy knits its wounds back.
for i := range st.actors {
st.seat(i)
if st.concentrationDmg <= 0 || st.enemyHP <= 0 || st.playerHP <= 0 {
@@ -813,24 +772,6 @@ func (te *turnEngine) stepRoundEnd() {
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)
// Regenerate (monster ability): the enemy knits its wounds at round end.
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
}
// consumeArmedAbility disarms the character, returning the ability id that was
// armed. It is the *mutating* half of arming: the flag is cleared and saved, so
// it must run exactly once per fight, at fight start.
//
// 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 {
// applyArmedAbility checks for a pre-armed ability on the character and
// applies its effect to the player's CombatModifiers, then clears the armed
// flag. Called from combat_bridge.go before SimulateCombat.
func applyArmedAbility(c *DnDCharacter, mods *CombatModifiers) (string, bool) {
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
}
ab, ok := dndActiveAbilities[abilityID]
ab, ok := dndActiveAbilities[c.ArmedAbility]
if !ok {
c.ArmedAbility = ""
_ = SaveDnDCharacter(c)
return "", false
}
ab.Apply(c, mods)
return ab.Name, true
}
// armAbilityForFight consumes whatever the character armed and applies it in one
// 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)
firedName := ab.Name
c.ArmedAbility = ""
_ = SaveDnDCharacter(c)
return firedName, true
}

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)
uid := id.UserID("@arm_fighter:example")
c := &DnDCharacter{
@@ -162,7 +162,7 @@ func TestArmAbilityForFight_FighterSecondWind(t *testing.T) {
}
mods := CombatModifiers{}
name, fired := armAbilityForFight(c, &mods)
name, fired := applyArmedAbility(c, &mods)
if !fired {
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)
uid := id.UserID("@arm_mage:example")
c := &DnDCharacter{
@@ -192,7 +192,7 @@ func TestArmAbilityForFight_MageMagicMissile(t *testing.T) {
t.Fatal(err)
}
mods := CombatModifiers{}
_, fired := armAbilityForFight(c, &mods)
_, fired := applyArmedAbility(c, &mods)
if !fired {
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: ""}
mods := CombatModifiers{}
_, fired := armAbilityForFight(c, &mods)
_, fired := applyArmedAbility(c, &mods)
if fired {
t.Error("fired with no armed ability")
}

View File

@@ -25,7 +25,7 @@ import (
//
// 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
// Go; tests run without a sqlite instance.
// - 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
// conversion; persistDnDHPAfterCombat is the inverse direct copy.
func applyDnDHPScaling(stats *CombatStats, c *DnDCharacter) {
if c == nil || c.HPMax <= 0 {
if c == nil || c.HPMax <= 0 || c.HPCurrent >= c.HPMax {
return
}
// Well-rested temporary HP (from a long rest at a T2+ home) is a cushion
// layered above the fight's MaxHP. It is dormant (0) for anyone not
// currently rested — including every scenario in the balance corpus and
// the class-balance harness, which never sets it — so the full-HP fast
// path below stays byte-identical for them and the golden does not move.
if c.TempHP > 0 {
stats.MaxHP += c.TempHP
}
if c.HPCurrent >= c.HPMax {
// Full HP: StartHP=0 sentinel already means "enter at MaxHP", which
// now includes the +TempHP cushion.
return
}
startHP := c.HPCurrent + stats.HPBonus + c.TempHP
startHP := c.HPCurrent + stats.HPBonus
if startHP < 1 {
startHP = 1
}

View File

@@ -224,28 +224,6 @@ func getActiveExpedition(userID id.UserID) (*Expedition, error) {
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.
func getExpedition(id string) (*Expedition, error) {
row := db.Get().QueryRow(`
@@ -343,42 +321,6 @@ func updateSupplies(expID string, s ExpeditionSupplies) error {
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.
func updateCamp(expID string, c *CampState) error {
var arg any
@@ -407,18 +349,9 @@ func setExpeditionRunID(expID, runID string) error {
return err
}
// abandonExpedition flags the player's live 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.
// abandonExpedition flags the active expedition as abandoned. Idempotent.
func abandonExpedition(userID id.UserID) error {
e, err := ownedLiveExpedition(userID)
e, err := getActiveExpedition(userID)
if err != nil {
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
// nightRolloverBurn may have moved the pool since. Deduct against the pool
// 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 {
exp.Supplies.Current -= cost
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
return p.SendDM(ctx.Sender, "Couldn't deduct supplies: "+err.Error())
}
exp.Supplies = pooled
camp := &CampState{
Active: true,
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.",
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())
if p.euro == nil {
@@ -641,15 +610,6 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
if err != nil {
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 {
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.")
}
zone, _ := getZone(exp.ZoneID)
extracted := exp.Status == ExpeditionStatusExtracting
audience := expeditionAudience(exp) // read before abandonExpedition disbands the roster
if err := abandonExpedition(ctx.Sender); err != nil {
return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error())
}
markActedToday(ctx.Sender)
_ = retireAllRegionRuns(exp)
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "")
// An extracted party is standing in town, not in the dungeon: their supplies
// are already spent and their loot is already banked. Say the true thing.
body := fmt.Sprintf(
if err := p.SendDM(ctx.Sender, fmt.Sprintf(
"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 {
slog.Warn("expedition: abandon DM failed", "user", uid, "expedition", exp.ID, "err", err)
}
}
if err := p.SendDM(ctx.Sender, body); err != nil {
zone.Display, exp.CurrentDay)); err != nil {
return err
}
// Emergence seam: see maybeRollPetArrivalOnEmerge.

View File

@@ -82,39 +82,34 @@ type nightRolloverResult struct {
// to finish the rollover; legacy deliverBriefing interleaves processOvernightCamp
// between the two so a fortified camp's 5 lands before drift's +3.
func (p *AdventurePlugin) nightRolloverBurn(e *Expedition) (float32, error) {
// 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
// can surface the gain; pure no-op for non-Ranger characters.
if c, err := LoadDnDCharacter(id.UserID(e.UserID)); err == nil && c != nil {
if gain := applyRangerForage(e, c, nil); gain > 0 {
_ = appendExpeditionLog(e.ID, e.CurrentDay, "forage",
fmt.Sprintf("ranger forage +%g SU", gain),
flavor.Pick(flavor.HarvestForageSuccess))
}
}
burnOverride := applyZoneTemporalPreBurn(e, e.CurrentDay+1)
var newSupplies ExpeditionSupplies
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
// today's supplies, not tomorrow's. Logged so the end-of-day digest
// can surface the gain; pure no-op for non-Ranger characters.
if c, cerr := LoadDnDCharacter(id.UserID(fresh.UserID)); cerr == nil && c != nil {
if gain := applyRangerForage(fresh, c, nil); gain > 0 {
_ = appendExpeditionLog(fresh.ID, fresh.CurrentDay, "forage",
fmt.Sprintf("ranger forage +%g SU", gain),
flavor.Pick(flavor.HarvestForageSuccess))
}
if burnOverride.Multiplier > 0 {
// 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.
burn = e.Supplies.DailyBurn * burnOverride.Multiplier * float32(expeditionBurnRatePct(e.ID)) / 100
newSupplies = e.Supplies
newSupplies.Current -= burn
if newSupplies.Current < 0 {
newSupplies.Current = 0
}
burnOverride := applyZoneTemporalPreBurn(fresh, fresh.CurrentDay+1)
if burnOverride.Multiplier > 0 {
// 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.
burn = fresh.Supplies.DailyBurn * burnOverride.Multiplier * float32(expeditionBurnRatePct(fresh.ID)) / 100
next := fresh.Supplies
next.Current -= burn
if next.Current < 0 {
next.Current = 0
}
next.ForagedToday = false
return next, nil
}
harsh := fresh.ThreatLevel > 60 || zoneTemporalHarsh(fresh)
next, b := applyExpeditionDailyBurn(fresh, harsh, fresh.SiegeMode)
burn = b
return next, nil
})
if err != nil {
newSupplies.ForagedToday = false
} else {
harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e)
newSupplies, burn = applyExpeditionDailyBurn(e, harsh, e.SiegeMode)
}
if err := updateSupplies(e.ID, newSupplies); err != nil {
return 0, err
}
if err := advanceExpeditionDay(e.ID); err != nil {

View File

@@ -30,7 +30,6 @@ import (
const (
extractResumeWindow = 7 * 24 * time.Hour
forcedExtractCoinTaxFrac = 0.20
extractionSweepInterval = time.Hour
)
var (
@@ -219,94 +218,6 @@ func getResumableExpedition(userID id.UserID) (*Expedition, error) {
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
// supplies. Threat/temporal/region state are preserved as-is.
func resumeExpedition(expID string, supplies ExpeditionSupplies) error {
@@ -412,9 +323,8 @@ func (p *AdventurePlugin) handleResumeCmd(ctx MessageContext, args string) error
if exp == nil {
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()) {
// Expire it so it doesn't keep resurfacing. The hourly sweeper would get
// here on its own; this keeps the refusal and the reap in one breath.
if exp.CompletedAt == nil || time.Since(*exp.CompletedAt) > extractResumeWindow {
// Expire it so it doesn't keep resurfacing.
_ = completeExpedition(exp.ID, ExpeditionStatusFailed)
return p.SendDM(ctx.Sender,
"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 {
var lines []string
if e.Supplies.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
if s := e.Supplies; s.DailyBurn > 0 {
s.Current += twoWeeksRestockDays * s.DailyBurn
if s.Max > 0 && s.Current > s.Max {
s.Current = s.Max
}
if s.Current > e.Supplies.Current {
if err := updateSupplies(e.ID, s); err != nil {
slog.Error("milestone: two weeks restock failed",
"expedition", e.ID, "err", err)
} else {
lines = append(lines, fmt.Sprintf(
"📦 Supplies restocked — %.1f of %.1f.", s.Current, s.Max))
e.Supplies = s
}
s.Current += twoWeeksRestockDays * s.DailyBurn
if s.Max > 0 && s.Current > s.Max {
s.Current = s.Max
}
restocked = s.Current > fresh.Supplies.Current
return s, nil
})
switch {
case err != nil:
slog.Error("milestone: two weeks restock failed",
"expedition", e.ID, "err", err)
case restocked:
lines = append(lines, fmt.Sprintf(
"📦 Supplies restocked — %.1f of %.1f.", pooled.Current, pooled.Max))
e.Supplies = pooled
default:
e.Supplies = pooled
}
}

View File

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

View File

@@ -222,15 +222,8 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error {
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.TempHP = wellRestedTempHP(c.HPMax, restTier)
c.TempHP = 0
c.ShortRestCharges = c.Level
// Phase 10 SUB2a — long rest clears one level of exhaustion (5e: a
// 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)
_ = refreshAllResources(ctx.Sender)
// Phase 9: spell slots refresh on long rest. A home rest also folds in the
// well-rested bonus slots (no-op for non-casters and inn/tier-1 rests).
slotBonus, _ := applyLongRestSpellSlots(c, restTier)
// Phase 9: spell slots refresh on long rest.
_ = refreshSpellSlots(ctx.Sender)
// 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.
// 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 != "" {
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)
}

View File

@@ -212,9 +212,6 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta,
if adv.PetName != "" {
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 {
b.WriteString(fmt.Sprintf(" Housing: tier %d\n", house.Tier))
}

View File

@@ -310,13 +310,6 @@ type turnSpellOutcome struct {
PlayerHeal int
EnemySkip bool
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
@@ -351,8 +344,6 @@ func resolveTurnSpell(c *DnDCharacter, spell SpellDefinition, slotLevel int, ene
out.EnemyDamage = mods.SpellPreDamage
out.EnemySkip = mods.SpellEnemySkipFirst
out.Desc = mods.SpellPreDamageDesc
out.GrimHarvestSlot = mods.GrimHarvestSlot
out.GrimHarvestNecrotic = mods.GrimHarvestNecrotic
if out.Desc == "" {
out.Desc = spell.Name
}

View File

@@ -564,48 +564,6 @@ func refreshSpellSlots(userID id.UserID) error {
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
// come back, plus floor(charLevel/4) additional slots distributed
// 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 {
return 0
}
// The spell killed the enemy iff the spell_cast event itself dropped EnemyHP
// to 0. If a later round-event (a weapon swing, the pet, a concentration
// aura re-tick) finished the kill, no heal.
//
// It is the *last* spell_cast that has to be lethal, not the first. The
// 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 {
// The pre-combat spell killed the enemy iff the spell_cast event itself
// dropped EnemyHP to 0. If a later round-event finished the kill, no heal.
for _, ev := range result.Events {
if ev.Action == "spell_cast" {
if ev.EnemyHP > 0 {
return 0
}
break

View File

@@ -471,12 +471,6 @@ type advanceResult struct {
// harvest ran (Entry/Trap/Elite/Boss rooms, forks, stops).
harvest autoHarvestResult
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,
@@ -501,16 +495,7 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
if err != nil {
return p.SendDM(ctx.Sender, err.Error())
}
sendErr := 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
return p.streamOrSend(ctx.Sender, res.preStream, res.intro, res.phases, res.final)
}
// 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
// wrong right before the auto-advance transit block (and is shared with
// manual `!region travel`, which advances next).
region, next, midZone := midZoneRegionClear(ctx.Sender, run.RunID)
if midZone {
if region, next, midZone := midZoneRegionClear(ctx.Sender, run.RunID); midZone {
b.WriteString(fmt.Sprintf("🏁 **Cleared %s.** The way to %s opens ahead.\n\n", region.Name, next.Name))
} else {
b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Run complete.\n\n", zone.Display))
@@ -779,12 +763,11 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact, inlin
}
}
return advanceResult{
preStream: preStream,
intro: intro,
phases: phases,
final: b.String(),
reason: stopComplete,
zoneCleared: !midZone,
preStream: preStream,
intro: intro,
phases: phases,
final: b.String(),
reason: stopComplete,
}, nil
}
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."
case "pack_rat":
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 := fresh.Supplies
ns.Current -= drain
if ns.Current < 0 {
ns.Current = 0
}
return ns, nil
}); err != nil {
ns := e.Supplies
ns.Current -= drain
if ns.Current < 0 {
ns.Current = 0
}
if err := updateSupplies(e.ID, ns); err != nil {
slog.Warn("expedition: ambient supply drain", "expedition", e.ID, "err", err)
return ""
}

View File

@@ -250,15 +250,10 @@ func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision
if 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) {
next := fresh.Supplies
next.Current -= cost
return next, nil
})
if err != nil {
exp.Supplies.Current -= cost
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
return "", err
}
exp.Supplies = pooled
camp := &CampState{
Active: true,
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
}
// 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
// who already leads or rides an expedition of their own — the "one active
// 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()
}
// seatLeader converts a solo expedition row into a party of one, inside a
// caller's transaction. It reads the owner off the expedition row rather than
// trusting a passed-in id, so the roster's leader can never disagree with
// dnd_expedition.user_id. Idempotent (ON CONFLICT DO NOTHING), so the invite
// path can call it unconditionally before adding a member.
// seatLeader is openParty inside a caller's transaction: it reads the owner off
// the expedition row rather than trusting a passed-in id, so the roster's leader
// can never disagree with dnd_expedition.user_id.
func seatLeader(tx *sql.Tx, expeditionID string) error {
var owner string
err := tx.QueryRow(

View File

@@ -178,13 +178,21 @@ func (p *AdventurePlugin) expeditionCmdAccept(ctx MessageContext, c *DnDCharacte
if isHol, _ := isHolidayToday(); isHol {
suppliesPurchase.StandardPacks++
}
// `exp` was fetched before the coin debit, and a second invitee accepting —
// or the leader's day-burn tick — may have rewritten the row since. Folding
// onto that stale snapshot would silently drop their packs or resurrect
// spent SU, so the fold happens against a fresh read under the pool's lock.
pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
return addSupplyPurchase(fresh.Supplies, suppliesPurchase), nil
})
// updateSupplies overwrites supplies_json wholesale, so the pool has to be
// re-read under the expedition's own lock: `exp` was fetched before the coin
// debit, and a second invitee accepting — or the leader's day-burn tick —
// may have rewritten the row since. Folding onto that stale snapshot would
// silently drop their packs or resurrect spent SU.
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 {
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) {
setupEmptyTestDB(t)
owner := id.UserID("@solo:example.org")
@@ -71,9 +53,10 @@ func TestParty_OpenIsIdempotent(t *testing.T) {
owner := id.UserID("@lead:example.org")
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++ {
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")
if err != nil {
@@ -88,7 +71,9 @@ func TestParty_JoinSeatsMembersLeaderFirst(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead2:example.org")
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"} {
if err := joinParty("exp-2", u); err != nil {
t.Fatalf("joinParty %s: %v", u, err)
@@ -142,7 +127,9 @@ func TestParty_JoinRefusesFullRoster(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead3:example.org")
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"} {
if err := joinParty("exp-3", u); err != nil {
t.Fatal(err)
@@ -158,7 +145,9 @@ func TestParty_JoinRefusesDuplicate(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead4:example.org")
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 {
t.Fatal(err)
}
@@ -178,8 +167,12 @@ func TestParty_JoinRefusesPlayerBusyElsewhere(t *testing.T) {
setupEmptyTestDB(t)
seedExpedition(t, "exp-5", "@lead5:example.org", "active")
seedExpedition(t, "exp-6", "@lead6:example.org", "active")
seatLeaderFixture(t, "exp-5")
seatLeaderFixture(t, "exp-6")
if err := openParty("exp-5", "@lead5:example.org"); err != nil {
t.Fatal(err)
}
if err := openParty("exp-6", "@lead6:example.org"); err != nil {
t.Fatal(err)
}
// Owns their own active expedition.
seedExpedition(t, "exp-own", "@busy:example.org", "active")
@@ -205,7 +198,9 @@ func TestParty_LeaveRemovesMemberButNotLeader(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead7:example.org")
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 {
t.Fatal(err)
}
@@ -222,7 +217,9 @@ func TestParty_LeaveRemovesMemberButNotLeader(t *testing.T) {
}
// Leaving frees the member for their own next run.
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 {
t.Errorf("departed member could not rejoin elsewhere: %v", err)
}
@@ -232,7 +229,9 @@ func TestParty_DisbandFreesEveryone(t *testing.T) {
setupEmptyTestDB(t)
owner := id.UserID("@lead8:example.org")
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 {
t.Fatal(err)
}
@@ -243,7 +242,9 @@ func TestParty_DisbandFreesEveryone(t *testing.T) {
t.Errorf("after disband, partySize = %d, want 1 (no rows)", n)
}
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 {
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")
member := id.UserID("@member9:example.org")
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 {
t.Fatal(err)
}

View File

@@ -579,8 +579,18 @@ func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction
return p.SendDM(ctx.Sender, "Equip cancelled.")
}
idx, ok := parseMenuIndex(reply, len(data.Items))
if !ok {
idx := 0
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)
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
// L4e ports housing/mortgage off AdvCharacter (gogobee_legacy_migration.md
// §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.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 {
c.HouseTier = s.Tier
c.HouseLoanBalance = s.LoanBalance
@@ -1335,9 +1248,6 @@ func upsertAllPlayerMetaFromAdvChar(c *AdventureCharacter) error {
if err := upsertPlayerMetaPetState(uid, petStateFromAdvChar(c)); err != nil {
return err
}
if err := upsertPlayerMetaPet2State(uid, pet2StateFromAdvChar(c)); err != nil {
return err
}
if err := upsertPlayerMetaHouseState(uid, houseStateFromAdvChar(c)); err != nil {
return err
}

View File

@@ -92,30 +92,6 @@ var (
type Base struct {
Client *mautrix.Client
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 "!".
@@ -584,10 +560,6 @@ func textContent(text string) *event.MessageEventContent {
// SendMessage sends a message to a room, auto-formatting Markdown as HTML.
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)
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
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.
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)
resp, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
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
// construct plugins without a real client).
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 {
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).
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)
if err != nil {
return "", err

View File

@@ -86,24 +86,7 @@ func (p *AdventurePlugin) runZoneCombatRoster(
continue
}
// A downed member sits it out. They own no close-out claim — and the
// 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)
player, e, dndChar, err := p.buildZoneCombatants(uid, monster, tier, dmMood)
if err != nil {
if leader {
return bail(err)
@@ -113,6 +96,9 @@ func (p *AdventurePlugin) runZoneCombatRoster(
}
if leader {
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
@@ -150,8 +136,11 @@ func (p *AdventurePlugin) runZoneCombatRoster(
}
}
p.grantCombatAchievements(b.uid, seatRes)
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 _, err := p.grantDnDXP(b.uid, xp); err != nil {