mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
B1 — tempering. `!adventure temper` pushes an owned magic item one rung up the rarity ladder at the blacksmith, capped at Legendary. Rarity lives on the registry definition, so an upgraded instance records its progress as a `temper` step count on its own row (adventure_inventory, magic_item_equipped) and effective rarity is derived on read. The stored base rarity is never written back, so an item cannot double-bump across a load/save round-trip. Effects flow through the existing rarity scalars — no new combat math, and the Legendary cap means this accelerates reaching the current ceiling rather than raising it. Costs mirror the crafting ladder (10k/25k/60k/150k, Foraging 15/20/25/30). Only the Legendary rung consumes a T5 material; gating the lower rungs on one would make tempering unreachable until a player already clears T5. Two plan anchors were wrong: thyraks_core and portal_fragment are not loot-note strings needing promotion — they are UniqueAlways slate entries that already materialize as inventory rows on every T5 clear. B4 — expedition achievement wing: first-clear and all-zones-cleared per tier (10), a quiet-clear for keeping peak threat under 50, and temper_legendary. The quiet-clear requires max_threat_seen to be *present*, not merely low: recordMaxThreat samples only on day rollover and never stores a zero, so an absent key means no threat history, not low threat. The plan's no-death-T4 achievement is not shipped — no per-expedition death counter exists — and pet-saved-my-life already ships as combat_pet_save. C4 — arena seasons. Standings are derived from arena_history.created_at per calendar quarter rather than wiping arena_stats: same visible reset, but lifetime totals survive for `!arena stats` and no destructive job can fire twice. Crowns archive to arena_season_titles rather than player_meta.title, which already carries the Survivalist milestone. Rollover rides the existing midnight ticker and self-dedups on the season key, so a bot that was down on the boundary catches up on its next wake.
356 lines
11 KiB
Go
356 lines
11 KiB
Go
package plugin
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"gogobee/internal/db"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// TestTemperLadderWalk pins the rung order and the Legendary clamp. VeryRare
|
|
// must fold onto Epic — it shares Epic's tier and power scalar, so treating it
|
|
// as its own rung would sell a step that changes no number.
|
|
func TestTemperLadderWalk(t *testing.T) {
|
|
tests := []struct {
|
|
base DnDRarity
|
|
steps int
|
|
want DnDRarity
|
|
}{
|
|
{RarityCommon, 0, RarityCommon},
|
|
{RarityCommon, 1, RarityUncommon},
|
|
{RarityCommon, 4, RarityLegendary},
|
|
{RarityCommon, 99, RarityLegendary}, // clamp, never past the ceiling
|
|
{RarityRare, 1, RarityEpic},
|
|
{RarityEpic, 1, RarityLegendary},
|
|
{RarityLegendary, 1, RarityLegendary},
|
|
{RarityVeryRare, 0, RarityVeryRare}, // untempered items keep their label
|
|
{RarityVeryRare, 1, RarityLegendary},
|
|
{RarityUncommon, -1, RarityUncommon}, // negative steps are inert
|
|
}
|
|
for _, tc := range tests {
|
|
if got := temperedRarity(tc.base, tc.steps); got != tc.want {
|
|
t.Errorf("temperedRarity(%s, %d) = %s, want %s", tc.base, tc.steps, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTemperStepsToLegendary(t *testing.T) {
|
|
tests := []struct {
|
|
base DnDRarity
|
|
steps int
|
|
want int
|
|
}{
|
|
{RarityCommon, 0, 4},
|
|
{RarityCommon, 2, 2},
|
|
{RarityCommon, 4, 0},
|
|
{RarityCommon, 10, 0},
|
|
{RarityVeryRare, 0, 1}, // folds onto Epic, one rung short
|
|
{RarityLegendary, 0, 0},
|
|
}
|
|
for _, tc := range tests {
|
|
if got := temperStepsToLegendary(tc.base, tc.steps); got != tc.want {
|
|
t.Errorf("temperStepsToLegendary(%s, %d) = %d, want %d", tc.base, tc.steps, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTemperCostsCoverEveryRung guards against a ladder edit that leaves a rung
|
|
// priced at the zero value — a free temper.
|
|
func TestTemperCostsCoverEveryRung(t *testing.T) {
|
|
for rung := 1; rung < len(temperLadder); rung++ {
|
|
target := temperLadder[rung]
|
|
cost, ok := temperCosts[rarityLootTierNum(target)]
|
|
if !ok {
|
|
t.Fatalf("no temper cost for target rarity %s", target)
|
|
}
|
|
if cost.Euros <= 0 || cost.MinForaging <= 0 {
|
|
t.Errorf("temper to %s is free: %+v", target, cost)
|
|
}
|
|
}
|
|
// Only the Legendary rung consumes a material.
|
|
for tier, cost := range temperCosts {
|
|
if want := tier == 5; cost.NeedsMaterial != want {
|
|
t.Errorf("tier %d NeedsMaterial = %v, want %v", tier, cost.NeedsMaterial, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTemperedItemScalesPowerAndValue asserts a tempered item is exactly an
|
|
// item of its effective rarity — the whole point of B1 is that rarity flows
|
|
// through the existing scalar formulas with no new combat math.
|
|
func TestTemperedItemScalesPowerAndValue(t *testing.T) {
|
|
base := MagicItem{ID: "x", Name: "Thing", Kind: MagicItemWeapon, Rarity: RarityRare, Value: 300}
|
|
|
|
if got := temperedItem(base, 0); got != base {
|
|
t.Errorf("temper 0 mutated the item: %+v", got)
|
|
}
|
|
|
|
up := temperedItem(base, 1)
|
|
if up.Rarity != RarityEpic {
|
|
t.Fatalf("rarity = %s, want Epic", up.Rarity)
|
|
}
|
|
// Value tracks the power axis: Rare=3 → Epic=4.
|
|
if wantVal := int(300 * (rarityPowerScalar(RarityEpic) / rarityPowerScalar(RarityRare))); up.Value != wantVal {
|
|
t.Errorf("value = %d, want %d", up.Value, wantVal)
|
|
}
|
|
// A tempered Epic must fight exactly like a native Epic. (Its Value is
|
|
// deliberately higher than a native Epic definition's — value tracks the
|
|
// power axis, and the player paid to move it there.)
|
|
native := base
|
|
native.Rarity = RarityEpic
|
|
if magicItemEffectFor(up) != magicItemEffectFor(native) {
|
|
t.Errorf("tempered effect diverges from native Epic effect")
|
|
}
|
|
// Base must be untouched — temperedItem takes a copy.
|
|
if base.Rarity != RarityRare || base.Value != 300 {
|
|
t.Errorf("temperedItem mutated its argument: %+v", base)
|
|
}
|
|
}
|
|
|
|
// TestTemperNeverDoubleBumps is the invariant the whole design hangs on: the
|
|
// stored row keeps BASE rarity plus a step count, so loading and re-saving an
|
|
// item any number of times must not compound the upgrade.
|
|
func TestTemperNeverDoubleBumps(t *testing.T) {
|
|
base := MagicItem{ID: "x", Name: "Thing", Kind: MagicItemWeapon, Rarity: RarityCommon, Value: 100}
|
|
e := EquippedMagicItem{Item: base, Temper: 2}
|
|
|
|
first := e.Effective()
|
|
// Simulate a load→save→load cycle: Item stays base, Temper stays put.
|
|
again := EquippedMagicItem{Item: e.Item, Temper: e.Temper}.Effective()
|
|
if first != again {
|
|
t.Errorf("round-trip changed the item: %+v vs %+v", first, again)
|
|
}
|
|
if e.Item.Rarity != RarityCommon {
|
|
t.Errorf("Effective() mutated the stored base rarity to %s", e.Item.Rarity)
|
|
}
|
|
if first.Rarity != RarityRare {
|
|
t.Errorf("Common + 2 tempers = %s, want Rare", first.Rarity)
|
|
}
|
|
}
|
|
|
|
// TestTemperSurvivesEquipRoundTrip: a tempered item that gets swapped back to
|
|
// inventory must carry its temper with it, priced at its effective rarity.
|
|
// This is the path resolveMagicEquipReply takes on a slot swap.
|
|
func TestTemperSurvivesEquipRoundTrip(t *testing.T) {
|
|
base := MagicItem{ID: "x", Name: "Thing", Kind: MagicItemWeapon, Rarity: RarityCommon, Value: 100}
|
|
|
|
back := magicItemSellAt(base, 3)
|
|
if back.Temper != 3 {
|
|
t.Errorf("swap-back lost temper: %d", back.Temper)
|
|
}
|
|
if back.Tier != rarityLootTierNum(RarityEpic) {
|
|
t.Errorf("swap-back tier = %d, want Epic tier", back.Tier)
|
|
}
|
|
if back.Value <= int64(base.Value) {
|
|
t.Errorf("swap-back value %d not scaled up from %d", back.Value, base.Value)
|
|
}
|
|
// An untempered sell must be byte-identical to the old behaviour.
|
|
if magicItemSellAt(base, 0) != magicItemSell(base) {
|
|
t.Errorf("magicItemSellAt(_, 0) diverges from magicItemSell")
|
|
}
|
|
}
|
|
|
|
// TestTemperPersistence exercises the two DB writes: an equipped item's temper
|
|
// and a stowed item's temper both survive a reload, and loadEquippedMagicItems
|
|
// hands back a base-rarity Item with the step count beside it.
|
|
func TestTemperPersistence(t *testing.T) {
|
|
dir := t.TempDir()
|
|
db.Close()
|
|
if err := db.Init(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(db.Close)
|
|
|
|
user := id.UserID("@temper:test.invalid")
|
|
|
|
var item MagicItem
|
|
for _, mi := range magicItemRegistry {
|
|
if mi.Slot != "" && mi.Rarity == RarityCommon {
|
|
item = mi
|
|
break
|
|
}
|
|
}
|
|
if item.ID == "" {
|
|
for _, mi := range magicItemRegistry {
|
|
if mi.Slot != "" && temperStepsToLegendary(mi.Rarity, 0) > 0 {
|
|
item = mi
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if item.ID == "" {
|
|
t.Skip("no slotted sub-Legendary item in registry")
|
|
}
|
|
|
|
if err := equipMagicItem(user, item.Slot, item.ID, false, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := temperEquippedItem(user, item.Slot, 1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
equipped, err := loadEquippedMagicItems(user)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e := equipped[item.Slot]
|
|
if e.Temper != 1 {
|
|
t.Fatalf("equipped temper = %d, want 1", e.Temper)
|
|
}
|
|
if e.Item.Rarity != item.Rarity {
|
|
t.Errorf("stored Item.Rarity = %s, want the untouched base %s", e.Item.Rarity, item.Rarity)
|
|
}
|
|
if want := temperedRarity(item.Rarity, 1); e.Effective().Rarity != want {
|
|
t.Errorf("Effective().Rarity = %s, want %s", e.Effective().Rarity, want)
|
|
}
|
|
|
|
// Stowed side: a fresh inventory row defaults to temper 0, then bumps.
|
|
inv := AdvItem{Name: item.Name, Type: "magic_item", Tier: 1, Value: 100,
|
|
SkillSource: "magic_item:" + item.ID}
|
|
if err := addAdvInventoryItem(user, inv); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
items, err := loadAdvInventory(user)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(items) != 1 || items[0].Temper != 0 {
|
|
t.Fatalf("fresh inventory row should default to temper 0, got %+v", items)
|
|
}
|
|
bumped := temperedItem(item, 2)
|
|
if err := temperInventoryItem(items[0].ID, 2, rarityLootTierNum(bumped.Rarity), int64(bumped.Value)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
items, _ = loadAdvInventory(user)
|
|
if items[0].Temper != 2 {
|
|
t.Errorf("inventory temper = %d, want 2", items[0].Temper)
|
|
}
|
|
if items[0].Tier != rarityLootTierNum(bumped.Rarity) {
|
|
t.Errorf("inventory tier = %d, want %d", items[0].Tier, rarityLootTierNum(bumped.Rarity))
|
|
}
|
|
}
|
|
|
|
// TestCollectTemperTargetsFiltering: Legendary items and consumable-kind magic
|
|
// items (potions/scrolls, whose combat effect is zero) are not offerable.
|
|
func TestCollectTemperTargetsFiltering(t *testing.T) {
|
|
dir := t.TempDir()
|
|
db.Close()
|
|
if err := db.Init(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(db.Close)
|
|
|
|
user := id.UserID("@filter:test.invalid")
|
|
|
|
var legendary, potion, upgradable MagicItem
|
|
for _, mi := range magicItemRegistry {
|
|
if legendary.ID == "" && mi.Rarity == RarityLegendary && mi.Slot != "" {
|
|
legendary = mi
|
|
}
|
|
if potion.ID == "" && (mi.Kind == MagicItemPotion || mi.Kind == MagicItemScroll) {
|
|
potion = mi
|
|
}
|
|
if upgradable.ID == "" && mi.Slot != "" && mi.Kind == MagicItemWeapon &&
|
|
temperStepsToLegendary(mi.Rarity, 0) > 0 {
|
|
upgradable = mi
|
|
}
|
|
}
|
|
|
|
add := func(mi MagicItem) {
|
|
if mi.ID == "" {
|
|
return
|
|
}
|
|
if err := addAdvInventoryItem(user, AdvItem{
|
|
Name: mi.Name, Type: "magic_item", Tier: rarityLootTierNum(mi.Rarity),
|
|
Value: int64(mi.Value), SkillSource: "magic_item:" + mi.ID,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
add(legendary)
|
|
add(potion)
|
|
add(upgradable)
|
|
|
|
targets, err := collectTemperTargets(user)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, tg := range targets {
|
|
if legendary.ID != "" && tg.Base.ID == legendary.ID {
|
|
t.Errorf("Legendary %s offered for tempering", legendary.Name)
|
|
}
|
|
if potion.ID != "" && tg.Base.ID == potion.ID {
|
|
t.Errorf("consumable %s offered for tempering", potion.Name)
|
|
}
|
|
}
|
|
if upgradable.ID != "" {
|
|
found := false
|
|
for _, tg := range targets {
|
|
if tg.Base.ID == upgradable.ID {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("upgradable %s (%s) was not offered", upgradable.Name, upgradable.Rarity)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTemperMaterialsAreObtainable guards the economy: every material name the
|
|
// final rung demands must actually drop from some zone's loot slate. If a slate
|
|
// is renamed, tempering to Legendary silently becomes impossible.
|
|
func TestTemperMaterialsAreObtainable(t *testing.T) {
|
|
live := map[string]bool{}
|
|
for _, zone := range allZones() {
|
|
for _, entry := range zone.Loot {
|
|
live[titleCaseUnderscored(entry.ItemID)] = true
|
|
}
|
|
}
|
|
for _, name := range temperMaterialNames {
|
|
if !live[name] {
|
|
t.Errorf("temper material %q drops from no zone loot slate", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFindTemperMaterial picks the first material regardless of which one.
|
|
func TestFindTemperMaterial(t *testing.T) {
|
|
if _, ok := findTemperMaterial(nil); ok {
|
|
t.Error("found a material in an empty inventory")
|
|
}
|
|
if _, ok := findTemperMaterial([]AdvItem{{Name: "Iron Ore"}}); ok {
|
|
t.Error("Iron Ore accepted as a legendary material")
|
|
}
|
|
for _, name := range temperMaterialNames {
|
|
got, ok := findTemperMaterial([]AdvItem{{Name: "Iron Ore"}, {ID: 7, Name: name}})
|
|
if !ok || got.ID != 7 {
|
|
t.Errorf("did not find material %q", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseTemperIndex(t *testing.T) {
|
|
tests := []struct {
|
|
in string
|
|
n int
|
|
want int
|
|
wantOK bool
|
|
}{
|
|
{"1", 3, 0, true},
|
|
{"3", 3, 2, true},
|
|
{" 2 ", 3, 1, true},
|
|
{"2. Thing", 3, 1, true},
|
|
{"0", 3, 0, false},
|
|
{"4", 3, 0, false},
|
|
{"", 3, 0, false},
|
|
{"cancel", 3, 0, false},
|
|
}
|
|
for _, tc := range tests {
|
|
got, ok := parseTemperIndex(tc.in, tc.n)
|
|
if ok != tc.wantOK || (ok && got != tc.want) {
|
|
t.Errorf("parseTemperIndex(%q, %d) = (%d, %v), want (%d, %v)",
|
|
tc.in, tc.n, got, ok, tc.want, tc.wantOK)
|
|
}
|
|
}
|
|
}
|