diff --git a/backend/internal/game/rewards_apply_test.go b/backend/internal/game/rewards_apply_test.go new file mode 100644 index 0000000..81e256a --- /dev/null +++ b/backend/internal/game/rewards_apply_test.go @@ -0,0 +1,48 @@ +package game + +import ( + "testing" + "time" + + "github.com/denisovdennis/autohero/internal/model" + "github.com/denisovdennis/autohero/internal/tuning" +) + +// ApplyVictoryRewards + GenerateLoot: gold only if gold roll succeeds; force chance 1.0 so the test is deterministic. +func TestApplyVictoryRewards_awardsGoldFromLoot(t *testing.T) { + v := tuning.DefaultValues() + v.GoldDropChance = 1.0 + tuning.Set(v) + t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) }) + + hero := &model.Hero{ + ID: 1, Level: 1, Gold: 0, XP: 0, + State: model.StateFighting, + } + enemy := &model.Enemy{ + Type: model.EnemyWolf, Name: "Wolf", + MinLevel: 1, MaxLevel: 5, + XPReward: 10, + } + beforeGold := hero.Gold + drops := ApplyVictoryRewards(hero, enemy, time.Now(), VictoryRewardDeps{}) + if len(drops) < 1 { + t.Fatal("expected at least one loot drop") + } + var hasGold bool + for _, d := range drops { + if d.ItemType == "gold" && d.GoldAmount > 0 { + hasGold = true + break + } + } + if !hasGold { + t.Fatalf("expected a gold entry in drops: %#v", drops) + } + if hero.Gold <= beforeGold { + t.Fatalf("hero gold should increase (loot gold); before=%d after=%d", beforeGold, hero.Gold) + } + if model.SumGoldFromLootDrops(drops) <= 0 { + t.Fatal("SumGoldFromLootDrops should be positive for victory drops") + } +} diff --git a/backend/internal/model/loot_chances_test.go b/backend/internal/model/loot_chances_test.go new file mode 100644 index 0000000..fccfe75 --- /dev/null +++ b/backend/internal/model/loot_chances_test.go @@ -0,0 +1,108 @@ +package model + +import ( + "math" + "math/rand" + "testing" + + "github.com/denisovdennis/autohero/internal/tuning" +) + +// equipmentLootSlots must give every slot a positive share so that, with EquipmentDropBase > 0, +// P(drop slot s | luck=1) = EquipmentDropBase * weight[s] > 0. +func TestEquipmentLootSlotWeights_positiveAndSumToOne(t *testing.T) { + var sum float64 + for _, row := range equipmentLootSlots { + if row.weight <= 0 { + t.Fatalf("slot %q: weight must be > 0", row.itemType) + } + sum += row.weight + } + if math.Abs(sum-1.0) > 1e-9 { + t.Fatalf("equipmentLootSlots weights sum to %g, want 1.0", sum) + } +} + +// User-facing slots: weapon, armor, necklace, ring, boots, pants, bracers, gloves +// map to main_hand, chest, neck, finger, feet, legs, wrist, hands — all must appear with positive weight. +func TestEquipmentLootSlotWeights_coversCoreSlots(t *testing.T) { + want := []EquipmentSlot{ + SlotMainHand, SlotChest, SlotNeck, SlotFinger, SlotFeet, SlotLegs, SlotWrist, SlotHands, + } + seen := make(map[string]bool, len(equipmentLootSlots)) + for _, row := range equipmentLootSlots { + seen[row.itemType] = true + } + for _, s := range want { + if !seen[string(s)] { + t.Fatalf("missing slot %q in equipmentLootSlots", s) + } + } +} + +// With default tuning and luck 1.0, marginal probability of rolling a specific equipment slot +// (equip roll succeeds, then slot roll) is EquipmentDropBase * weight > 0. +func TestMarginalEquipmentDropChancePerSlot_nonZeroWithDefaults(t *testing.T) { + cfg := tuning.DefaultValues() + if cfg.EquipmentDropBase <= 0 { + t.Fatal("default EquipmentDropBase must be > 0") + } + for _, row := range equipmentLootSlots { + marginal := cfg.EquipmentDropBase * row.weight + if marginal <= 0 { + t.Fatalf("marginal chance for %q is %g", row.itemType, marginal) + } + } +} + +func TestGenerateLoot_goldLineWhenChanceSucceeds(t *testing.T) { + v := tuning.DefaultValues() + v.GoldDropChance = 1.0 + tuning.Set(v) + t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) }) + + drops := GenerateLootWithRNG(EnemyWolf, 1.0, nil) + var gold *LootDrop + for i := range drops { + if drops[i].ItemType == "gold" { + gold = &drops[i] + break + } + } + if gold == nil { + t.Fatal("expected a gold LootDrop line when GoldDropChance is 1") + } + if gold.GoldAmount < 1 { + t.Fatalf("gold amount should be >= 1, got %d", gold.GoldAmount) + } +} + +func TestGenerateLoot_noGoldWhenChanceZero(t *testing.T) { + v := tuning.DefaultValues() + v.GoldDropChance = 0 + tuning.Set(v) + t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) }) + + drops := GenerateLootWithRNG(EnemyWolf, 1.0, nil) + for _, d := range drops { + if d.ItemType == "gold" { + t.Fatalf("unexpected gold line: %#v", drops) + } + } +} + +func TestGenerateLoot_goldOmittedWhenFirstRollFails(t *testing.T) { + v := tuning.DefaultValues() + v.GoldDropChance = 0.5 + tuning.Set(v) + t.Cleanup(func() { tuning.Set(tuning.DefaultValues()) }) + + // rng returns 0.99, 0.1, ... — first roll fails gold (< 0.5), second is potion check, etc. + r := rand.New(rand.NewSource(1)) + drops := GenerateLootWithRNG(EnemyWolf, 1.0, r) + for _, d := range drops { + if d.ItemType == "gold" { + t.Fatal("expected no gold when first float is high and chance is 0.5") + } + } +} diff --git a/backend/internal/model/loot_payload_test.go b/backend/internal/model/loot_payload_test.go new file mode 100644 index 0000000..9c7edb5 --- /dev/null +++ b/backend/internal/model/loot_payload_test.go @@ -0,0 +1,30 @@ +package model + +import "testing" + +func TestSumGoldFromLootDrops(t *testing.T) { + drops := []LootDrop{ + {ItemType: "gold", GoldAmount: 30, Rarity: RarityCommon}, + {ItemType: "gold", GoldAmount: 12, Rarity: RarityCommon}, + {ItemType: "main_hand", ItemName: "Blade", Rarity: RarityRare}, + } + if g := SumGoldFromLootDrops(drops); g != 42 { + t.Fatalf("SumGoldFromLootDrops: want 42, got %d", g) + } +} + +func TestLootDropsToLootItems_skipsGold_includesGearAndPotion(t *testing.T) { + drops := []LootDrop{ + {ItemType: "gold", GoldAmount: 99, Rarity: RarityCommon}, + {ItemType: "potion", Rarity: RarityCommon}, + {ItemType: "chest", ItemName: "Chainmail", Rarity: RarityUncommon}, + {ItemType: "head", ItemName: "", Rarity: RarityCommon}, // no name → omitted + } + items := LootDropsToLootItems(drops) + if len(items) != 2 { + t.Fatalf("want 2 loot lines (potion + chest), got %d: %+v", len(items), items) + } + if items[0].ItemType != "potion" || items[1].ItemType != "chest" || items[1].Name != "Chainmail" { + t.Fatalf("unexpected items: %+v", items) + } +} diff --git a/backend/internal/tuning/loot_defaults_test.go b/backend/internal/tuning/loot_defaults_test.go new file mode 100644 index 0000000..288db69 --- /dev/null +++ b/backend/internal/tuning/loot_defaults_test.go @@ -0,0 +1,25 @@ +package tuning + +import "testing" + +// Defaults must keep equipment drop and gold scaling positive so marginal per-slot +// equipment chances (EquipmentDropBase × slot weight) stay non-zero. +func TestDefaultLootTuning_nonZeroEquipmentAndGoldScale(t *testing.T) { + d := DefaultValues() + if d.EquipmentDropBase <= 0 { + t.Fatal("default EquipmentDropBase must be > 0 so equipment can drop") + } + if d.EquipmentDropBase > 1 { + t.Fatal("default EquipmentDropBase should be a probability in (0,1]") + } + if d.GoldLootScale <= 0 { + t.Fatal("default GoldLootScale must be > 0") + } + if d.GoldDropChance <= 0 || d.GoldDropChance > 1 { + t.Fatalf("default GoldDropChance must be in (0,1], got %v", d.GoldDropChance) + } + // Potion is independent; allow 0 only if explicitly intended (currently 0.05). + if d.PotionDropChance < 0 || d.PotionDropChance > 1 { + t.Fatalf("PotionDropChance out of range: %v", d.PotionDropChance) + } +} diff --git a/backend/migrations/000036_runtime_config_merchant_encounter_weights.sql b/backend/migrations/000036_runtime_config_merchant_encounter_weights.sql new file mode 100644 index 0000000..2dfa4c9 --- /dev/null +++ b/backend/migrations/000036_runtime_config_merchant_encounter_weights.sql @@ -0,0 +1,9 @@ +-- Lower wandering merchant encounter weights (relative to monster weight). +UPDATE runtime_config +SET + payload = payload || '{ + "merchantEncounterWeightBase": 0.02, + "merchantEncounterWeightRoadBonus": 0.05 + }'::jsonb, + updated_at = now() +WHERE id = TRUE; diff --git a/frontend/src/game/types.ts b/frontend/src/game/types.ts index 7312ac6..97e6e75 100644 --- a/frontend/src/game/types.ts +++ b/frontend/src/game/types.ts @@ -92,6 +92,21 @@ export enum ArmorType { // ---- Loot ---- +/** Server `EquipmentSlot` strings + legacy weapon/armor labels for popup copy. */ +export type LootBonusItemSlot = + | 'main_hand' + | 'chest' + | 'head' + | 'feet' + | 'neck' + | 'hands' + | 'legs' + | 'cloak' + | 'finger' + | 'wrist' + | 'weapon' + | 'armor'; + export interface LootDrop { itemType: 'weapon' | 'armor' | 'gold'; itemName?: string; @@ -99,7 +114,7 @@ export interface LootDrop { goldAmount: number; /** Optional equipment drop shown together with gold (backend may return gold + item). */ bonusItem?: { - itemType: 'weapon' | 'armor'; + itemType: LootBonusItemSlot; rarity: Rarity; itemName?: string; }; diff --git a/frontend/src/game/ws-handler.ts b/frontend/src/game/ws-handler.ts index 34da80d..db340b9 100644 --- a/frontend/src/game/ws-handler.ts +++ b/frontend/src/game/ws-handler.ts @@ -25,6 +25,7 @@ import type { ServerErrorPayload, EnemyState, LootDrop, + LootBonusItemSlot, MerchantLootPayload, DebuffAppliedPayload, } from './types'; @@ -307,7 +308,7 @@ export function buildLootFromCombatEnd(p: CombatEndPayload): LootDrop | null { itemName: equip?.name, bonusItem: equip ? { - itemType: equip.itemType as 'weapon' | 'armor', + itemType: equip.itemType as LootBonusItemSlot, rarity: (equip.rarity?.toLowerCase() ?? 'common') as Rarity, itemName: equip.name, }