mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
L: openParty had no production callers (invite path uses joinParty -> seatLeader, which seats the leader the same way but reads the owner off the expedition row). Removed it; the tests now seat the leader through a seatLeaderFixture that wraps seatLeader in a transaction. I: promoted parseTemperIndex to parseMenuIndex and routed resolveMagicEquipReply and handleMasterworkEquipReply through it, replacing two hand-inlined copies of the same digit parser. Behaviour-preserving (the parsed flag was redundant with the idx<0 guard); the retry-on-bad-parse disagreement is left as-is since this is a pure dedup. Full plugin suite green. Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
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 := parseMenuIndex(tc.in, tc.n)
|
|
if ok != tc.wantOK || (ok && got != tc.want) {
|
|
t.Errorf("parseMenuIndex(%q, %d) = (%d, %v), want (%d, %v)",
|
|
tc.in, tc.n, got, ok, tc.want, tc.wantOK)
|
|
}
|
|
}
|
|
}
|