mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Wires the §3 zone-note interactions that were called out across the resource doc but left for the polish pass. Harvest gates (dnd_expedition_harvest.go): - zoneConditionHarvestBlock — Underforge Heat 10 blocks !mine, Dragon's Lair infernax_awake blocks all harvesting, Abyss Instability 81+ blocks all harvesting. - zoneConditionHarvestDCMod — Underforge Heat 7-9 → +3 !mine DC, Dragon's Lair Awareness Pulse 3+ (day 9+) → +4 all DCs, Abyss 61-80 → +5 all DCs, Feywild Double-Day → -3 !forage DC. Delta + reason shown inline on the dice line. - restoreHarvestNodesInRoom — Time Loop hook (in-memory only; cycle flow persists downstream). - Manor !scavenge: 10% Cursed Trinket secondary drop with one-time TwinBee warning gated by RegionState["manor_cursed_warned"]. Time Loop wiring (dnd_expedition_temporal.go): - feywildTemporalPostRollover Loop branch now restores nodes in the current room and appends a re-emergence narration line. Drow Poison Blade recipe (dnd_economy.go): - ThomCraftRecipe gains BladeWeaponCount; pickBladeWeapons matches Type=="weapon" + blade keyword (sword/dagger/blade/scimitar/etc.). - !craft list shows the blade slot with current inventory count. - Recipe consumes 1× Drow Poison + 1× any blade weapon → Drow Poison Blade (sleep-on-crit for 3 combats, effect TODO). Tests (dnd_zone_conditions_test.go, all DB-free): block branches per zone, DC delta tables, manor cursed-trinket gate at 10% boundary, restoreHarvestNodesInRoom in-memory roundtrip, isBladeWeapon / pickBladeWeapons coverage + shortfall + exclude-already-consumed, drow_poison_blade recipe shape. Flavor reuse only — no new flavor file. Reasons-strings on the dice line and the cursed-trinket warning are inline (Thom/TwinBee voices in existing pools didn't fit harvest-blocking or warding contexts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
258 lines
7.6 KiB
Go
258 lines
7.6 KiB
Go
package plugin
|
|
|
|
import "testing"
|
|
|
|
// Phase R6 — zone-condition harvest interactions (§3 zone notes).
|
|
|
|
func TestZoneConditionHarvestBlock_Underforge(t *testing.T) {
|
|
cases := []struct {
|
|
stacks int
|
|
action HarvestAction
|
|
want bool
|
|
}{
|
|
{9, HarvestMine, false},
|
|
{10, HarvestMine, true},
|
|
{10, HarvestScavenge, false},
|
|
{0, HarvestMine, false},
|
|
}
|
|
for _, c := range cases {
|
|
exp := &Expedition{ZoneID: ZoneUnderforge, TemporalStack: c.stacks}
|
|
got := zoneConditionHarvestBlock(exp, c.action) != ""
|
|
if got != c.want {
|
|
t.Errorf("Underforge stacks=%d action=%s: got block=%v want=%v", c.stacks, c.action, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestZoneConditionHarvestBlock_DragonsLair(t *testing.T) {
|
|
exp := &Expedition{ZoneID: ZoneDragonsLair, RegionState: map[string]any{}}
|
|
if zoneConditionHarvestBlock(exp, HarvestMine) != "" {
|
|
t.Errorf("Dragon's Lair pre-awake should not block")
|
|
}
|
|
exp.RegionState["infernax_awake"] = true
|
|
if zoneConditionHarvestBlock(exp, HarvestMine) == "" {
|
|
t.Errorf("Dragon's Lair post-awake should block")
|
|
}
|
|
}
|
|
|
|
func TestZoneConditionHarvestBlock_Abyss(t *testing.T) {
|
|
cases := []struct {
|
|
stack int
|
|
want bool
|
|
}{
|
|
{60, false},
|
|
{80, false},
|
|
{81, true},
|
|
{99, true},
|
|
}
|
|
for _, c := range cases {
|
|
exp := &Expedition{ZoneID: ZoneAbyssPortal, TemporalStack: c.stack}
|
|
got := zoneConditionHarvestBlock(exp, HarvestEssence) != ""
|
|
if got != c.want {
|
|
t.Errorf("Abyss stack=%d: got block=%v want=%v", c.stack, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestZoneConditionHarvestDCMod_Underforge(t *testing.T) {
|
|
cases := []struct {
|
|
stacks int
|
|
action HarvestAction
|
|
want int
|
|
}{
|
|
{6, HarvestMine, 0},
|
|
{7, HarvestMine, 3},
|
|
{9, HarvestMine, 3},
|
|
{7, HarvestScavenge, 0},
|
|
}
|
|
for _, c := range cases {
|
|
exp := &Expedition{ZoneID: ZoneUnderforge, TemporalStack: c.stacks}
|
|
got, _ := zoneConditionHarvestDCMod(exp, c.action)
|
|
if got != c.want {
|
|
t.Errorf("Underforge stacks=%d action=%s: got delta=%d want=%d", c.stacks, c.action, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestZoneConditionHarvestDCMod_DragonsLair_Awareness(t *testing.T) {
|
|
cases := []struct {
|
|
day int
|
|
want int
|
|
}{
|
|
{8, 0},
|
|
{9, 4},
|
|
{12, 4},
|
|
}
|
|
for _, c := range cases {
|
|
exp := &Expedition{ZoneID: ZoneDragonsLair, CurrentDay: c.day, RegionState: map[string]any{}}
|
|
got, _ := zoneConditionHarvestDCMod(exp, HarvestMine)
|
|
if got != c.want {
|
|
t.Errorf("Dragon's Lair day=%d: got delta=%d want=%d", c.day, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestZoneConditionHarvestDCMod_Abyss(t *testing.T) {
|
|
cases := []struct {
|
|
stack int
|
|
want int
|
|
}{
|
|
{60, 0},
|
|
{61, 5},
|
|
{80, 5},
|
|
{81, 0}, // 81+ is the block branch; DC mod stops contributing.
|
|
}
|
|
for _, c := range cases {
|
|
exp := &Expedition{ZoneID: ZoneAbyssPortal, TemporalStack: c.stack}
|
|
got, _ := zoneConditionHarvestDCMod(exp, HarvestMine)
|
|
if got != c.want {
|
|
t.Errorf("Abyss stack=%d: got delta=%d want=%d", c.stack, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestZoneConditionHarvestDCMod_FeywildDoubleDay(t *testing.T) {
|
|
exp := &Expedition{ZoneID: ZoneFeywildCrossing, RegionState: map[string]any{
|
|
"feywild_today": string(FeywildDistortionDouble),
|
|
}}
|
|
if got, _ := zoneConditionHarvestDCMod(exp, HarvestForage); got != -3 {
|
|
t.Errorf("Feywild double-day forage: got delta=%d want=-3", got)
|
|
}
|
|
if got, _ := zoneConditionHarvestDCMod(exp, HarvestMine); got != 0 {
|
|
t.Errorf("Feywild double-day mine: should not bonus, got delta=%d", got)
|
|
}
|
|
exp.RegionState["feywild_today"] = string(FeywildDistortionLoop)
|
|
if got, _ := zoneConditionHarvestDCMod(exp, HarvestForage); got != 0 {
|
|
t.Errorf("Feywild loop should not bonus forage DC, got delta=%d", got)
|
|
}
|
|
}
|
|
|
|
func TestRollManorCursedTrinket(t *testing.T) {
|
|
// Wrong zone or wrong action: never fires.
|
|
if rollManorCursedTrinket(ZoneCryptValdris, HarvestScavenge, func(int) int { return 0 }) {
|
|
t.Errorf("non-Manor zone should never fire")
|
|
}
|
|
if rollManorCursedTrinket(ZoneManorBlackspire, HarvestForage, func(int) int { return 0 }) {
|
|
t.Errorf("non-scavenge action should never fire")
|
|
}
|
|
// 10% threshold: roll 9 fires, roll 10 does not.
|
|
if !rollManorCursedTrinket(ZoneManorBlackspire, HarvestScavenge, func(int) int { return 9 }) {
|
|
t.Errorf("roll=9 should fire (under 10%%)")
|
|
}
|
|
if rollManorCursedTrinket(ZoneManorBlackspire, HarvestScavenge, func(int) int { return 10 }) {
|
|
t.Errorf("roll=10 should not fire (boundary)")
|
|
}
|
|
}
|
|
|
|
func TestRestoreHarvestNodesInRoom(t *testing.T) {
|
|
exp := &Expedition{
|
|
ZoneID: ZoneFeywildCrossing,
|
|
CurrentRegion: "",
|
|
RegionState: map[string]any{
|
|
regionStateHarvestKey: map[string]map[string][]HarvestNode{
|
|
"": {
|
|
"0": {
|
|
{ResourceID: "fey_dust", CurrentCharges: 0, MaxCharges: 2},
|
|
{ResourceID: "enchanted_petal", CurrentCharges: 1, MaxCharges: 2},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
if !restoreHarvestNodesInRoom(exp, 0) {
|
|
t.Fatalf("restore should report changes")
|
|
}
|
|
table := loadHarvestTable(exp)
|
|
got := table[""]["0"]
|
|
for _, n := range got {
|
|
if n.CurrentCharges != n.MaxCharges {
|
|
t.Errorf("node %s: charges=%d max=%d (expected restored)", n.ResourceID, n.CurrentCharges, n.MaxCharges)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRestoreHarvestNodesInRoom_NoOpEmpty(t *testing.T) {
|
|
exp := &Expedition{ZoneID: ZoneFeywildCrossing, RegionState: map[string]any{}}
|
|
if got := restoreHarvestNodesInRoom(exp, -1); got {
|
|
t.Errorf("negative roomIdx should no-op")
|
|
}
|
|
}
|
|
|
|
func TestIsBladeWeapon(t *testing.T) {
|
|
cases := []struct {
|
|
it AdvItem
|
|
want bool
|
|
}{
|
|
{AdvItem{Name: "Mithral Dagger", Type: "weapon"}, true},
|
|
{AdvItem{Name: "Iron Longsword", Type: "weapon"}, true},
|
|
{AdvItem{Name: "Battle Scimitar", Type: "weapon"}, true},
|
|
{AdvItem{Name: "Heavy Mace", Type: "weapon"}, false},
|
|
{AdvItem{Name: "Iron Dagger", Type: "consumable"}, false},
|
|
{AdvItem{Name: "Drow Poison (diluted)", Type: "material"}, false},
|
|
}
|
|
for _, c := range cases {
|
|
if got := isBladeWeapon(c.it); got != c.want {
|
|
t.Errorf("%s (type=%s): got %v want %v", c.it.Name, c.it.Type, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPickBladeWeapons_Coverage(t *testing.T) {
|
|
items := []AdvItem{
|
|
{ID: 1, Name: "Mithral Dagger", Type: "weapon"},
|
|
{ID: 2, Name: "Iron Mace", Type: "weapon"},
|
|
{ID: 3, Name: "Iron Longsword", Type: "weapon"},
|
|
{ID: 4, Name: "Drow Poison (diluted)", Type: "material"},
|
|
}
|
|
picked, short := pickBladeWeapons(items, 2, nil)
|
|
if short != 0 {
|
|
t.Errorf("expected fully covered, got short=%d", short)
|
|
}
|
|
if len(picked) != 2 {
|
|
t.Errorf("expected 2 picks, got %d", len(picked))
|
|
}
|
|
for _, id := range picked {
|
|
if id == 2 || id == 4 {
|
|
t.Errorf("picked non-blade id=%d", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPickBladeWeapons_Short(t *testing.T) {
|
|
items := []AdvItem{{ID: 1, Name: "Mithral Dagger", Type: "weapon"}}
|
|
picked, short := pickBladeWeapons(items, 2, nil)
|
|
if short != 1 {
|
|
t.Errorf("expected short=1, got %d", short)
|
|
}
|
|
if len(picked) != 1 {
|
|
t.Errorf("expected 1 pick, got %d", len(picked))
|
|
}
|
|
}
|
|
|
|
func TestPickBladeWeapons_ExcludesAlreadyConsumed(t *testing.T) {
|
|
items := []AdvItem{
|
|
{ID: 1, Name: "Iron Dagger", Type: "weapon"},
|
|
{ID: 2, Name: "Iron Sword", Type: "weapon"},
|
|
}
|
|
picked, short := pickBladeWeapons(items, 1, []int64{1})
|
|
if short != 0 || len(picked) != 1 || picked[0] != 2 {
|
|
t.Errorf("expected pick=[2] short=0; got pick=%v short=%d", picked, short)
|
|
}
|
|
}
|
|
|
|
func TestThomCraftRecipes_DrowPoisonBlade_Defined(t *testing.T) {
|
|
r, ok := findRecipeByID("drow_poison_blade")
|
|
if !ok {
|
|
t.Fatalf("drow_poison_blade recipe not registered")
|
|
}
|
|
if r.BladeWeaponCount != 1 {
|
|
t.Errorf("expected BladeWeaponCount=1, got %d", r.BladeWeaponCount)
|
|
}
|
|
if _, ok := r.Ingredients["Drow Poison (diluted)"]; !ok {
|
|
t.Errorf("expected Drow Poison ingredient")
|
|
}
|
|
if r.OutputType != "weapon" {
|
|
t.Errorf("expected weapon output type, got %s", r.OutputType)
|
|
}
|
|
}
|