6 Commits

Author SHA1 Message Date
prosolis
57a0ea90f2 N4/E1: second pet slot for the Tier-4 Estate
An Established (Tier-4) home can draw a second companion. Both pets show up on
the sheet and the town showcase, level via babysitting, and wear their own
barding (!thom pet2buy <tier>).

Combat: both pets contribute their attack/deflect/whiff procs at half weight —
DerivePlayerStats now averages over the active pets, so a pair reads as roughly
one full pet (flavor-forward, not a stat spike; difficulty-neutral). The average
reduces to identity over a single pet (x/1==x, 0.0+x==x, 3+(2L+1)/2==3+L), and
the engines still roll one proc per channel per round, so the single-pet path
and TestCombatCharacterization golden stay byte-identical. Pinned by
TestDerivePlayerStats_OnePetIsByteIdentical + the golden.

Scope kept deliberately flavor-forward: pet 2 carries identity/level/barding and
the three combat procs only. The morning-defense buff, death/ditch-recovery
save, and the level-10 supply-shop unlock stay pet-1 mechanics — no second
defensive multiplier stacks, and the one-pet path is untouched by construction.

Storage: parallel pet2_* columns on player_meta + Pet2* mirror on
AdventureCharacter (absent == no second pet, DEFAULT '' correct for every
pre-existing row, no backfill — the N2-temper / P4-party precedent). Pet-1 code
paths are untouched. Arrival reuses the chase/feed/type/name DM flow, slot-aware,
gated HouseTier>=4 with an established first pet.

Review fixes folded in (high-effort /code-review, 3 angles): pet-2 barding block
no longer hidden when pet-1 is chased away; showcase tie-break restored
(level desc, then name); petGrantXP collapsed onto the shared level-up helper;
dead-param armor-buy wrappers inlined; pet-2 barding tagged with its own euro
ledger reason.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 15:03:57 -07:00
prosolis
27d6bfd361 N4/E3: town registries — !town, !graveyard, !rivals board
Surface social data the game already stores as three read-only boards:

- !town — civic pride (top tax_ledger contributors), housing street
  (name + tier), pet showcase (name/type/level/barding).
- !graveyard — recent deaths across the guild (death_source/location,
  still-resting vs recovered), St. Guildmore's caretaker voice.
- !rivals board — room-wide duel standings aggregated from the directed
  adventure_rival_records ledger; bare !rivals keeps the per-user view.

All read-only: no schema, no combat, golden byte-identical. Enumerate off
player_meta / tax_ledger / adventure_rival_records with COALESCE'd display
names. Render layer is client-free and unit-tested; a DB-backed loader test
exercises the real schema (caught a MAX(datetime) affinity issue — parsed by
hand via parseSQLiteTime).

Leak-check (engagement plan §E3): the civic board ranks tax_ledger.total_paid,
which is gambling/shop/arena rake only — Misty/Arina donations go through
euro.Debit with their own reason strings and their counters live in separate
player_meta columns, so no board can correlate donations with the hidden buffs.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 14:31:59 -07:00
prosolis
476384206e N4/E2 review fixes: close a gift-enabled equip dupe; harden vault writes
A high-effort code review of the gifting/vault work turned up three issues:

1. Item duplication (real exploit). The masterwork/arena equip confirmation
   captures an item at prompt time and equips it on "yes" without re-checking
   ownership. Since MasterworkGear/ArenaGear are now giftable, a player could
   !give the item away in the window, then confirm — minting a copy onto
   themselves while the recipient kept theirs. Fixed by taking the user lock
   (making gift-vs-confirm atomic) and re-loading the inventory to refuse an
   item that is no longer ours. Magic items are exempt (not giftable); other
   delete paths load-and-remove within one locked invocation.

2. clearAdvInventory read the vault-filtered list but its DELETE wiped every
   row including vaulted ones — no live caller today, but it would break the
   vault's "safe from !sell all" promise the instant sell-all routed through
   it. Delete now matches the read (in_vault = 0).

3. vault store/take took no per-user lock, so two near-simultaneous stores
   could both pass the capacity check and overfill the 10-slot vault. Now
   serialized on the same user lock the gift path uses.

go test ./... green; golden byte-identical.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 14:05:24 -07:00
prosolis
272f59aa32 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 3 gifts/day (a persisted log doubling
as an anti-twink-funnel guard and audit trail). Recipient must have run !setup.
Magic items are deliberately non-giftable: attunement and the BiS economy stay
personal.

Built on the vault's inventory plumbing: a gift is transferInventoryItem
flipping the row's owner (owner-scoped, forces in_vault=0 so it lands in the
recipient's active pack). pickGiftableItem prefers a giftable match so a
non-giftable item can't shadow a giftable one and block the command.

go test ./... green; golden byte-identical.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 14:05:11 -07:00
prosolis
1de2004f26 N4/E1: T4 Estate vault — 10-slot protected item storage
The Tier-4 "Established" home now unlocks a vault: `!adventure vault
[store|take] <item>` moves items in and out of a 10-slot pool that shelters
them from `!sell all`, crafting, and combat consumption. It is also E2
gifting's prerequisite plumbing.

Implemented as a single `in_vault` flag on adventure_inventory rather than a
parallel table: a stowed item keeps its identity (id, temper, everything) and
loadAdvInventory filters it out, so a vaulted item drops out of every play
surface with one flag change and comes back untouched. DEFAULT 0 = "in play",
correct for every pre-existing row, so no bootstrap backfill.

Golden byte-identical; go test ./... green.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 13:44:32 -07:00
prosolis
d01d3e277a N4/E1: T3 housing payoffs + a home-rest "well-rested" buff
Turn the dead top housing tiers into something worth buying. All three
land without a schema bump, and TestCombatCharacterization stays
byte-identical (the balance corpus never sets the new fields).

T3 trophy room: treasure cap 3->4 at HouseTier>=3 (maxTreasuresForTier).
Enforced at the save gate only -- HouseTier is never written downward,
so a held 4th treasure below tier 3 is unreachable and a load-time cap in
computeAdvBonuses would just add a query for an impossible state. The
all-irreplaceable manual discard prompt now lists the 4th slot too.

T3 workshop: +5% craft success at HouseTier>=3, lifting the cap 0.95->0.98
so a maxed forager still gains. craftingSuccessRate takes a workshopBonus,
threaded through autoCraftConsumables and renderRecipesKnown.

Well-rested buff (replaces the plan's T4 "inn-quality rest", a verified
no-op -- home rest already equals inn rest). Home-only (the inn and a
tier-1 shack grant nothing), starts at T2 and grows through T4, expires at
the next long rest:
  - Temp HP cushion (8/12/16% of MaxHP). TempHP was a dormant field; wired
    into applyDnDHPScaling as MaxHP headroom -- additive and golden-safe.
  - Bonus spell slots (+1/+2/+3 at the caster's highest slot level), the
    real reward, lifting casters who trail on spell-pool richness.
    applyLongRestSpellSlots resets the pool to base then folds in the
    bonus; expiry is stateless (next rest's reset drops it).

Magnitudes are tunable defaults; revisit against the post-parties
re-baseline.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 13:36:32 -07:00
28 changed files with 2424 additions and 116 deletions

View File

@@ -357,6 +357,27 @@ 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 {
@@ -1376,6 +1397,21 @@ CREATE TABLE IF NOT EXISTS adventure_activity_log (
);
CREATE INDEX IF NOT EXISTS idx_adv_log_user ON adventure_activity_log(user_id, logged_at);
-- N4/E2 item gifting. One row per gift; the sender's row count for the current
-- UTC day enforces the daily cap (twink-funnel guard), and the full log is an
-- audit trail. gift_day is the UTC date string the cap counts against, kept
-- alongside given_at so the count is a plain equality match, not a range scan.
CREATE TABLE IF NOT EXISTS adventure_gift_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender TEXT NOT NULL,
recipient TEXT NOT NULL,
item_name TEXT NOT NULL,
value INTEGER NOT NULL DEFAULT 0,
gift_day TEXT NOT NULL,
given_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_adv_gift_sender_day ON adventure_gift_log(sender, gift_day);
CREATE TABLE IF NOT EXISTS adventure_treasures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,

View File

@@ -179,6 +179,10 @@ 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"},
}
}
@@ -416,6 +420,20 @@ 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") {
@@ -513,6 +531,8 @@ 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.")
@@ -530,6 +550,7 @@ 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
@@ -894,9 +915,9 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
case "npc_encounter":
return p.resolveNPCEncounter(ctx, interaction)
case "pet_arrival":
return p.resolvePetArrival(ctx)
return p.resolvePetArrival(ctx, interaction)
case "pet_type":
return p.resolvePetType(ctx)
return p.resolvePetType(ctx, interaction)
case "pet_name":
return p.resolvePetName(ctx, interaction)
}
@@ -1126,7 +1147,7 @@ func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCha
return
}
if count < advMaxTreasures {
if count < maxTreasuresForTier(char.HouseTier) {
// Directly save
if err := advSaveTreasure(userID, drop.Def); err != nil {
slog.Error("adventure: failed to save treasure", "user", userID, "err", err)
@@ -1511,7 +1532,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))
return p.SendDM(ctx.Sender, renderRecipesKnown(char.ForagingSkill, homeWorkshopCraftBonus(char.HouseTier)))
}
func (p *AdventurePlugin) handleTreasuresCmd(ctx MessageContext, arg string) error {

View File

@@ -36,22 +36,15 @@ 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() && 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")
}
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
}
outcome := "pet_care"
if leveled {

View File

@@ -92,13 +92,25 @@ type AdventureCharacter struct {
PetSupplyShopUnlocked bool
PetLevel10Date string
PetMorningDefense bool
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
// 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
}
type AdvEquipment struct {
@@ -270,6 +282,11 @@ 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
@@ -562,7 +579,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 = ?
FROM adventure_inventory WHERE user_id = ? AND in_vault = 0
ORDER BY tier DESC, value DESC`, string(userID))
if err != nil {
return nil, err
@@ -613,7 +630,10 @@ func clearAdvInventory(userID id.UserID) ([]AdvItem, error) {
return nil, err
}
d := db.Get()
_, err = d.Exec(`DELETE FROM adventure_inventory WHERE user_id = ?`, string(userID))
// 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))
if err != nil {
return nil, err
}
@@ -623,10 +643,62 @@ 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 = ?`, string(userID)).Scan(&count)
_ = d.QueryRow(`SELECT COUNT(*) FROM adventure_inventory WHERE user_id = ? AND in_vault = 0`, string(userID)).Scan(&count)
return count
}
// loadAdvVault returns the items the player has stowed in their housing vault
// (N4/E1). These are excluded from loadAdvInventory — a vaulted item is out of
// play (unsellable, uncraftable, unusable in combat) until it is taken back.
func loadAdvVault(userID id.UserID) ([]AdvItem, error) {
d := db.Get()
rows, err := d.Query(`
SELECT id, name, item_type, tier, value, slot, skill_source, temper
FROM adventure_inventory WHERE user_id = ? AND in_vault = 1
ORDER BY tier DESC, value DESC`, string(userID))
if err != nil {
return nil, err
}
defer rows.Close()
var items []AdvItem
for rows.Next() {
var it AdvItem
var slot string
if err := rows.Scan(&it.ID, &it.Name, &it.Type, &it.Tier, &it.Value, &slot, &it.SkillSource, &it.Temper); err != nil {
return nil, err
}
it.Slot = EquipmentSlot(slot)
items = append(items, it)
}
return items, rows.Err()
}
// advVaultCount returns how many of a user's items are stowed in the vault.
func advVaultCount(userID id.UserID) int {
d := db.Get()
var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM adventure_inventory WHERE user_id = ? AND in_vault = 1`, string(userID)).Scan(&count)
return count
}
// setItemVaulted flips a single inventory row's vault flag. Scoped to the
// owning user so a mistyped id can never move another player's item.
func setItemVaulted(userID id.UserID, itemID int64, vaulted bool) (bool, error) {
v := 0
if vaulted {
v = 1
}
res, err := db.Get().Exec(
`UPDATE adventure_inventory SET in_vault = ? WHERE id = ? AND user_id = ?`,
v, itemID, string(userID))
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
func loadAllAdvCharacters() ([]AdventureCharacter, error) {
d := db.Get()
rows, err := d.Query(`SELECT user_id FROM player_meta`)

View File

@@ -346,15 +346,34 @@ var craftingRecipes = []CraftingRecipe{
{Result: "Voidstone Shard", Ingredients: []string{"Voidstone", "Mythril Ore"}, MinForaging: 30, Tier: 5},
}
// 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 {
// 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 {
base := 0.50
levelsAbove := foragingLevel - minForaging
bonus := float64(levelsAbove) / 5.0 * 0.03
rate := base + bonus
if rate > 0.95 {
return 0.95
rate := base + bonus + workshopBonus
cap := 0.95
if workshopBonus > 0 {
cap = 0.98
}
if rate > cap {
return cap
}
return rate
}
@@ -369,7 +388,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) string {
func renderRecipesKnown(foragingLevel int, workshopBonus float64) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🧪 **Crafting Recipes** — Foraging Lv.%d\n\n", foragingLevel))
@@ -377,6 +396,9 @@ func renderRecipesKnown(foragingLevel int) 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{}
@@ -402,7 +424,7 @@ func renderRecipesKnown(foragingLevel int) string {
}
sb.WriteString(fmt.Sprintf("**Tier %d** (Foraging %d+)\n", tier, recipes[0].MinForaging))
for _, r := range recipes {
rate := craftingSuccessRate(foragingLevel, r.MinForaging) * 100
rate := craftingSuccessRate(foragingLevel, r.MinForaging, workshopBonus) * 100
sb.WriteString(fmt.Sprintf(" • %s — %s (%.0f%% success)\n",
r.Result, strings.Join(r.Ingredients, " + "), rate))
}
@@ -433,7 +455,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) ([]CraftResult, []AdvItem) {
func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int, workshopBonus float64) ([]CraftResult, []AdvItem) {
if foragingLevel < 10 {
return nil, items
}
@@ -452,7 +474,7 @@ func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int)
break
}
rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging)
rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging, workshopBonus)
success := rand.Float64() < rate
if success {

View File

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

View File

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

View File

@@ -99,12 +99,16 @@ type advPendingHouseDownPayment struct {
type advPendingHouseAutopay struct{}
type advPendingPetArrival 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 advPendingPetType struct{}
type advPendingPetType struct{ Slot int }
type advPendingPetName struct {
PetType string
Slot int
}
// ── Thom Krooke Greeting ───────────────────────────────────────────────────
@@ -221,11 +225,19 @@ func thomShopView(char *AdventureCharacter, balance float64) string {
}
}
// Pet supply shop (unlocks 1 week after pet level 10)
if char.PetSupplyShopUnlocked && char.HasPet() {
// 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()) {
sb.WriteString("\n---\n")
sb.WriteString(fmt.Sprintf("🐾 **Pet Supplies** — for %s\n\n", char.PetName))
sb.WriteString(petArmorShopView(char))
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"))
}
}
return sb.String()
@@ -269,8 +281,11 @@ 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.handlePetArmorBuy(ctx, char, strings.TrimSpace(args[7:]))
return p.petArmorBuyForSlot(ctx, strings.TrimSpace(args[7:]), 1)
default:
return p.SendDM(ctx.Sender, "Unknown command. Type `!thom` to visit Krooke Realty.")
@@ -673,18 +688,18 @@ func petArmorDefs(petType string) []PetArmorDef {
return petCatArmor
}
func petArmorShopView(char *AdventureCharacter) string {
func petArmorShopView(petType string, armorTier int, buyCmd string) string {
var sb strings.Builder
defs := petArmorDefs(char.PetType)
defs := petArmorDefs(petType)
if char.PetArmorTier >= 5 {
if armorTier >= 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 <= char.PetArmorTier {
if def.Tier == char.PetArmorTier {
if def.Tier <= armorTier {
if def.Tier == armorTier {
sb.WriteString(fmt.Sprintf("🟢 %s (T%d) — Currently equipped\n", def.Name, def.Tier))
}
continue
@@ -692,11 +707,14 @@ func petArmorShopView(char *AdventureCharacter) 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 petbuy <tier>` (e.g., `!thom petbuy %d`)", char.PetArmorTier+1))
sb.WriteString(fmt.Sprintf("\nTo buy: `!thom %s <tier>` (e.g., `!thom %s %d`)", buyCmd, buyCmd, armorTier+1))
return sb.String()
}
func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureCharacter, tierStr string) error {
// 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 {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
@@ -706,8 +724,16 @@ func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureC
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
if !char.HasPet() {
return p.SendDM(ctx.Sender, "You don't have a pet.")
// 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.PetSupplyShopUnlocked {
return p.SendDM(ctx.Sender, "Pet supplies aren't available yet.")
@@ -716,19 +742,19 @@ func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureC
tier := 0
for _, c := range tierStr {
if c < '0' || c > '9' {
return p.SendDM(ctx.Sender, "Usage: `!thom petbuy <tier>`")
return p.SendDM(ctx.Sender, fmt.Sprintf("Usage: `!thom %s <tier>`", buyCmd))
}
tier = tier*10 + int(c-'0')
}
if tier != char.PetArmorTier+1 {
if tier <= char.PetArmorTier {
if tier != curTier+1 {
if tier <= curTier {
return p.SendDM(ctx.Sender, "That's not an upgrade.")
}
return p.SendDM(ctx.Sender, fmt.Sprintf("You need to buy tier %d first.", char.PetArmorTier+1))
return p.SendDM(ctx.Sender, fmt.Sprintf("You need to buy tier %d first.", curTier+1))
}
defs := petArmorDefs(char.PetType)
defs := petArmorDefs(petType)
var def *PetArmorDef
for i := range defs {
if defs[i].Tier == tier {
@@ -742,18 +768,26 @@ func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureC
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.", char.PetName, def.Price, balance))
return p.SendDM(ctx.Sender, fmt.Sprintf("%s deserves better, but you can't afford €%d. Balance: €%.0f.", petName, def.Price, balance))
}
if !p.euro.Debit(ctx.Sender, float64(def.Price), "pet_armor_"+tierStr) {
reason := "pet_armor_" + tierStr
if slot == 2 {
reason = "pet2_armor_" + tierStr
}
if !p.euro.Debit(ctx.Sender, float64(def.Price), reason) {
return p.SendDM(ctx.Sender, "Transaction failed.")
}
char.PetArmorTier = tier
if slot == 2 {
char.Pet2ArmorTier = tier
} else {
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, char.PetName, def.DeflectBonus*100, char.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, petName, def.DeflectBonus*100, petName))
}

View File

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

View File

@@ -492,8 +492,33 @@ func (p *AdventurePlugin) handleMasterworkEquipConfirm(ctx MessageContext, inter
return p.SendDM(ctx.Sender, "Equip cancelled.")
}
// Serialize against the sender's own !give: MasterworkGear/ArenaGear are
// giftable (N4/E2), so the item captured at prompt time may have changed
// hands before this confirmation. The lock makes gift-vs-confirm atomic,
// and the re-load below refuses an item that is no longer ours — otherwise
// equipping the captured copy would mint a duplicate the gift already
// handed to the recipient.
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
selected := data.Item
inv, err := loadAdvInventory(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your inventory.")
}
stillOwned := false
for _, it := range inv {
if it.ID == selected.ID {
stillOwned = true
break
}
}
if !stillOwned {
return p.SendDM(ctx.Sender, fmt.Sprintf("**%s** isn't in your inventory anymore. Equip cancelled.", selected.Name))
}
equip, err := loadAdvEquipment(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load equipment.")

View File

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

View File

@@ -31,28 +31,37 @@ func petXPToNextLevel(level int) int {
}
}
// petGrantXP adds XP to the pet and handles level-ups. Returns true if leveled up.
// 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.
func petGrantXP(pet *PetState) bool {
if !pet.HasPet() || pet.Level >= 10 {
if !pet.HasPet() {
return false
}
return advancePetLevelsFromXP(&pet.XP, &pet.Level, &pet.Level10Date, int(petXPPerAction*100))
}
pet.XP += int(petXPPerAction * 100) // store as centixp for precision
// 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
leveled := false
for pet.Level < 10 {
needed := petXPToNextLevel(pet.Level) * 100
if pet.XP < needed {
for *level < 10 {
needed := petXPToNextLevel(*level) * 100
if *xp < needed {
break
}
pet.XP -= needed
pet.Level++
*xp -= needed
*level++
leveled = true
}
if pet.Level >= 10 && pet.Level10Date == "" {
pet.Level10Date = time.Now().UTC().Format("2006-01-02")
if *level >= 10 && *level10Date == "" {
*level10Date = time.Now().UTC().Format("2006-01-02")
}
return leveled
}
@@ -206,6 +215,23 @@ 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
@@ -221,32 +247,52 @@ func (p *AdventurePlugin) maybeRollPetArrivalOnEmerge(userID id.UserID) {
pet, _ := loadPetState(userID)
house, _ := loadHouseState(userID)
if petShouldArrive(pet, house) {
p.petArrivalDM(userID)
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)
}
}
// petArrivalDM sends the initial "there's an animal in your house" DM.
func (p *AdventurePlugin) petArrivalDM(userID id.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) {
// Don't overwrite an existing pending interaction
if _, exists := p.pending.Load(string(userID)); exists {
return
}
text := "There's an animal in your house. It looks like a...\n\n" +
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" +
"🚪 Chase it away\n" +
"🍖 Feed it\n\n" +
"Reply with `chase` or `feed`."
p.pending.Store(string(userID), &advPendingInteraction{
Type: "pet_arrival",
Data: &advPendingPetArrival{},
Data: &advPendingPetArrival{Slot: slot},
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) error {
func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext, interaction *advPendingInteraction) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
@@ -256,13 +302,18 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
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 == "🚪" {
char.PetChasedAway = true
char.PetReactivated = false
if slot == 2 {
char.Pet2ChasedAway = true
char.Pet2Reactivated = false
} else {
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.")
}
@@ -276,7 +327,7 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "pet_type",
Data: &advPendingPetType{},
Data: &advPendingPetType{Slot: slot},
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
})
return p.SendDM(ctx.Sender, text)
@@ -285,18 +336,19 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
// Invalid response — re-prompt
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "pet_arrival",
Data: &advPendingPetArrival{},
Data: &advPendingPetArrival{Slot: slot},
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) error {
func (p *AdventurePlugin) resolvePetType(ctx MessageContext, interaction *advPendingInteraction) 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 := ""
@@ -309,7 +361,7 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error {
if petType == "" {
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "pet_type",
Data: &advPendingPetType{},
Data: &advPendingPetType{Slot: slot},
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
})
return p.SendDM(ctx.Sender, "Reply with `dog` or `cat`.")
@@ -317,7 +369,7 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error {
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "pet_name",
Data: &advPendingPetName{PetType: petType},
Data: &advPendingPetName{PetType: petType, Slot: slot},
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
})
@@ -355,24 +407,36 @@ 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.")
}
char.PetType = data.PetType
char.PetName = name
char.PetArrived = true
char.PetChasedAway = false
char.PetLevel = 1
char.PetXP = 0
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
}
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 = "🐱"
}
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))
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))
}
// ── Morning Pet Events ─────────────────────────────────────────────────────

View File

@@ -1037,8 +1037,17 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
// ── Treasure Discard Prompt ──────────────────────────────────────────────────
func renderAdvTreasureDiscardPrompt(newTreasure *AdvTreasureDef, existing []AdvTreasureDef) string {
if len(TreasureInventoryCap) == 0 {
return "You found a treasure but your inventory is full. Reply 1, 2, or 3 to discard, or `keep`."
// 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()
}
// Build substitution map with existing treasure info

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -185,7 +185,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)
craftResults, updatedItems := autoCraftConsumables(userID, items, char.ForagingSkill, homeWorkshopCraftBonus(char.HouseTier))
if len(craftResults) > 0 {
items = updatedItems
for _, cr := range craftResults {

View File

@@ -157,12 +157,33 @@ func DerivePlayerStats(
}
}
// Pet modifiers
// 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)
if char.HasPet() {
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
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
}
if char.PetMorningDefense {
mods.DamageReduct *= 0.95 // 5% less damage

View File

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

View File

@@ -222,8 +222,15 @@ 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 = 0
c.TempHP = wellRestedTempHP(c.HPMax, restTier)
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
@@ -241,8 +248,9 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error {
}
markActedToday(ctx.Sender)
_ = refreshAllResources(ctx.Sender)
// Phase 9: spell slots refresh on long rest.
_ = refreshSpellSlots(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: 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
@@ -263,5 +271,13 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error {
if line := dndRestLongFlavorLine(hasHousing); line != "" {
msg += "\n\n_" + line + "_"
}
if bits := wellRestedSummary(c.TempHP, slotBonus); bits != "" {
homeName := "home"
if def := houseTierByTier(restTier); def != nil {
homeName = def.Name
}
msg += fmt.Sprintf("\n\n🛏 _Well-rested at your %s — %s until your next long rest._",
strings.ToLower(homeName), bits)
}
return p.SendDM(ctx.Sender, msg)
}

View File

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

View File

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

View File

@@ -310,6 +310,82 @@ 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,
@@ -1194,6 +1270,17 @@ 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
@@ -1248,6 +1335,9 @@ 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
}