mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
R3 — Combat Event Integration: - dnd_expedition_combat.go: Combat Interrupt rolls (§4.2) with threat-clock and Ranger-wilderness modifiers; Patrol Encounters scaled by threat level; recordZoneKill writer with monsterKillTags. - Interrupt gate at head of handleHarvestCmd; patrol gate before resolveRoom in zoneCmdAdvance; kill writer wired into combat-win paths. R4a — Zone Loot Tables: - dnd_zone_loot.go: §5 loot drop tables for all 10 zones × 5 tiers with §8.1 sell-value bands. dropTierFromCR brackets + boss floor. - Hooks on combat-win in resolveCombatRoom, resolveBossRoom, runHarvestInterrupt, tryPatrolEncounter. R4b — Fishing Integration: - dnd_zone_fish.go: fishingZones allow-list, fishingSkillBonus, rangerRareCatchUpgrade (§6.2), feywildFishDistortion narration. - §6.1 fish entries added to resource registry for Forest, Sunken Temple, Underdark, Feywild zones. - !fish wired through handleHarvestCmd; zoneItemFlavor matrix populated for all 10 zones × all loot items. R5 — Economy Integration: - dnd_economy.go: !sell (post-expedition gate, single CHA Persuasion DC 17 → +15% bump), !craft (§8.2 4 exemplar recipes), !lore (INT/Arcana DC 15 recipe discovery). - dnd_known_recipe table for persistent recipe discovery. Flavor reuse: HarvestInterrupt, PatrolEncounter, CombatVictory, PlayerDeath, LootDrop*, FeywildTimeDistortion* — all existing pools. No new flavor file. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
197 lines
6.6 KiB
Go
197 lines
6.6 KiB
Go
package plugin
|
|
|
|
import (
|
|
"math/rand/v2"
|
|
"testing"
|
|
)
|
|
|
|
// ── §6.1 Fishing zone gate ─────────────────────────────────────────────────
|
|
|
|
func TestIsFishingZone_AllowList(t *testing.T) {
|
|
allowed := map[ZoneID]bool{
|
|
ZoneForestShadows: true,
|
|
ZoneSunkenTemple: true,
|
|
ZoneUnderdark: true,
|
|
ZoneFeywildCrossing: true,
|
|
}
|
|
all := []ZoneID{
|
|
ZoneGoblinWarrens, ZoneCryptValdris, ZoneForestShadows,
|
|
ZoneSunkenTemple, ZoneManorBlackspire, ZoneUnderforge,
|
|
ZoneUnderdark, ZoneFeywildCrossing, ZoneDragonsLair, ZoneAbyssPortal,
|
|
}
|
|
for _, z := range all {
|
|
if got, want := isFishingZone(z), allowed[z]; got != want {
|
|
t.Errorf("isFishingZone(%s) = %v, want %v", z, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── §6.2 fishing skill bonus folds in legacy rank ──────────────────────────
|
|
|
|
func TestFishingSkillBonus_Bands(t *testing.T) {
|
|
cases := []struct {
|
|
skill int
|
|
want int
|
|
}{
|
|
{0, 0}, {1, 0}, {4, 0}, {5, 1}, {9, 1}, {10, 2}, {15, 2}, {99, 2},
|
|
}
|
|
for _, c := range cases {
|
|
if got := fishingSkillBonus(c.skill); got != c.want {
|
|
t.Errorf("fishingSkillBonus(%d) = %d, want %d", c.skill, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── §6.2 Ranger +20% rare-catch upgrade ────────────────────────────────────
|
|
|
|
func TestRangerRareCatchUpgrade_OnlyAppliesToFish(t *testing.T) {
|
|
// Non-fish action: never upgrade, even for rangers.
|
|
rng := rand.New(rand.NewPCG(1, 1))
|
|
for i := 0; i < 100; i++ {
|
|
got := rangerRareCatchUpgrade(ClassRanger, HarvestForage, OutcomeStandard, rng)
|
|
if got != OutcomeStandard {
|
|
t.Fatalf("non-fish action upgraded: %s", got)
|
|
}
|
|
}
|
|
// Non-ranger fish: never upgrade.
|
|
rng = rand.New(rand.NewPCG(2, 2))
|
|
for i := 0; i < 100; i++ {
|
|
got := rangerRareCatchUpgrade(ClassFighter, HarvestFish, OutcomeStandard, rng)
|
|
if got != OutcomeStandard {
|
|
t.Fatalf("non-ranger upgraded: %s", got)
|
|
}
|
|
}
|
|
// Ranger fish + Rich/Nothing: passes through unchanged.
|
|
rng = rand.New(rand.NewPCG(3, 3))
|
|
for i := 0; i < 100; i++ {
|
|
if got := rangerRareCatchUpgrade(ClassRanger, HarvestFish, OutcomeRich, rng); got != OutcomeRich {
|
|
t.Fatalf("Rich changed: %s", got)
|
|
}
|
|
if got := rangerRareCatchUpgrade(ClassRanger, HarvestFish, OutcomeNothing, rng); got != OutcomeNothing {
|
|
t.Fatalf("Nothing changed: %s", got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRangerRareCatchUpgrade_RoughRate(t *testing.T) {
|
|
rng := rand.New(rand.NewPCG(7, 7))
|
|
const trials = 4000
|
|
upgrades := 0
|
|
for i := 0; i < trials; i++ {
|
|
out := rangerRareCatchUpgrade(ClassRanger, HarvestFish, OutcomeStandard, rng)
|
|
if out == OutcomeRich {
|
|
upgrades++
|
|
}
|
|
}
|
|
rate := float64(upgrades) / float64(trials)
|
|
if rate < 0.15 || rate > 0.25 {
|
|
t.Errorf("upgrade rate %.3f outside expected ~0.20 ±0.05", rate)
|
|
}
|
|
}
|
|
|
|
// ── §6.1 Feywild Time Distortion narration hook ─────────────────────────────
|
|
|
|
func TestFeywildFishDistortion_OnlyFiresInFeywildOnFish(t *testing.T) {
|
|
rng := rand.New(rand.NewPCG(11, 11))
|
|
for i := 0; i < 200; i++ {
|
|
if got := feywildFishDistortion(ZoneForestShadows, HarvestFish, rng); got != "" {
|
|
t.Fatalf("non-feywild fired: %q", got)
|
|
}
|
|
if got := feywildFishDistortion(ZoneFeywildCrossing, HarvestForage, rng); got != "" {
|
|
t.Fatalf("non-fish action fired: %q", got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── §3 fish entries present in registry per §6.1 zones ─────────────────────
|
|
|
|
func TestZoneFishRegistry_Coverage(t *testing.T) {
|
|
want := map[ZoneID][]string{
|
|
ZoneForestShadows: {"shadow_trout", "gloomfin", "phantom_carp"},
|
|
ZoneSunkenTemple: {"black_pearl", "blind_cave_fish", "merrow_eel", "dareth_lanternfish", "aboleth_spawn"},
|
|
ZoneUnderdark: {"blind_albino_bass", "glowing_cave_eel", "underdark_shark", "eyeless_king"},
|
|
ZoneFeywildCrossing: {"moonlit_minnow", "dreaming_pike", "timeworn_koi"},
|
|
}
|
|
for zid, ids := range want {
|
|
seen := map[string]bool{}
|
|
for _, r := range ZoneResourcesByAction(zid, HarvestFish) {
|
|
seen[r.ID] = true
|
|
}
|
|
for _, id := range ids {
|
|
if !seen[id] {
|
|
t.Errorf("zone %s missing fish entry %s", zid, id)
|
|
}
|
|
}
|
|
}
|
|
// And: zones not on the §6.1 list should have no fish entries.
|
|
noFish := []ZoneID{
|
|
ZoneGoblinWarrens, ZoneCryptValdris, ZoneManorBlackspire,
|
|
ZoneUnderforge, ZoneDragonsLair, ZoneAbyssPortal,
|
|
}
|
|
for _, z := range noFish {
|
|
if got := ZoneResourcesByAction(z, HarvestFish); len(got) != 0 {
|
|
t.Errorf("zone %s has %d fish entries, want 0", z, len(got))
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── §5 zone-contextual item flavor matrix ──────────────────────────────────
|
|
|
|
func TestZoneItemDescription_LootMatrixCoverage(t *testing.T) {
|
|
// Every zone's loot table entry name should resolve to either a
|
|
// flavor line or the Potion of Healing exemplar — but the specific
|
|
// invariant we want is: every zone has *some* per-item flavor populated
|
|
// beyond just Potion of Healing.
|
|
zones := []ZoneID{
|
|
ZoneGoblinWarrens, ZoneCryptValdris, ZoneForestShadows,
|
|
ZoneSunkenTemple, ZoneManorBlackspire, ZoneUnderforge,
|
|
ZoneUnderdark, ZoneFeywildCrossing, ZoneDragonsLair, ZoneAbyssPortal,
|
|
}
|
|
for _, z := range zones {
|
|
zm, ok := zoneItemFlavor[z]
|
|
if !ok || len(zm) == 0 {
|
|
t.Errorf("zone %s: no item flavor matrix populated", z)
|
|
continue
|
|
}
|
|
// Every flavor entry should resolve via the public path.
|
|
for name, want := range zm {
|
|
if got := zoneItemDescription(z, name); got != want {
|
|
t.Errorf("zone %s item %q: zoneItemDescription mismatch", z, name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestZoneItemDescription_MissingItemReturnsEmpty(t *testing.T) {
|
|
if got := zoneItemDescription(ZoneGoblinWarrens, "Nonexistent Item"); got != "" {
|
|
t.Errorf("got %q, want empty for unknown item", got)
|
|
}
|
|
}
|
|
|
|
// ── Fish entries respect §8.1 sell-value bands ──────────────────────────────
|
|
|
|
func TestZoneFish_SellValueBands(t *testing.T) {
|
|
bands := map[DnDRarity][2]int{
|
|
RarityCommon: {3, 20},
|
|
RarityUncommon: {25, 70},
|
|
RarityRare: {100, 350},
|
|
RarityVeryRare: {500, 1500},
|
|
RarityLegendary: {3000, 10000},
|
|
}
|
|
zones := []ZoneID{
|
|
ZoneForestShadows, ZoneSunkenTemple, ZoneUnderdark, ZoneFeywildCrossing,
|
|
}
|
|
for _, z := range zones {
|
|
for _, r := range ZoneResourcesByAction(z, HarvestFish) {
|
|
band, ok := bands[r.Rarity]
|
|
if !ok {
|
|
t.Errorf("fish %s has unhandled rarity %s", r.ID, r.Rarity)
|
|
continue
|
|
}
|
|
if r.SellValue < band[0] || r.SellValue > band[1] {
|
|
t.Errorf("fish %s sell %d outside band %v for %s", r.ID, r.SellValue, band, r.Rarity)
|
|
}
|
|
}
|
|
}
|
|
}
|