mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Compare commits
1 Commits
57a0ea90f2
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b4e6833b0 |
4
go.mod
4
go.mod
@@ -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
20
go.sum
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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.8–1.5)", perWeek)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimDailyEventSlot_OnePerDay guards the cap the rate test assumes.
|
||||
func TestClaimDailyEventSlot_OnePerDay(t *testing.T) {
|
||||
uid := id.UserID("@events-slot:example")
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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\".")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 2N−1, 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 2N−1 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
|
||||
|
||||
@@ -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), 2N−1 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, ""
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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\".")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user