mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 17:02:42 +00:00
Adv 2.0 D&D Phase R R6: Zone-condition harvest polish
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>
This commit is contained in:
@@ -30,15 +30,18 @@ import (
|
|||||||
// ThomCraftRecipe is one §8.2 recipe. Ingredient names match the registry
|
// ThomCraftRecipe is one §8.2 recipe. Ingredient names match the registry
|
||||||
// Name field exactly so inventory lookups succeed without fuzzy matching.
|
// Name field exactly so inventory lookups succeed without fuzzy matching.
|
||||||
type ThomCraftRecipe struct {
|
type ThomCraftRecipe struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Ingredients map[string]int // Name → count
|
Ingredients map[string]int // Name → count (exact match)
|
||||||
OutputName string
|
// BladeWeaponCount is the count of generic "any blade weapon"
|
||||||
OutputType string // material | item | weapon | armor | accessory | consumable
|
// inventory items consumed (§8.2 Drow Poison Blade). 0 = none.
|
||||||
OutputTier int
|
BladeWeaponCount int
|
||||||
OutputValue int64
|
OutputName string
|
||||||
Description string
|
OutputType string // material | item | weapon | armor | accessory | consumable
|
||||||
DiscoveryHint string // shown when newly discovered via !lore
|
OutputTier int
|
||||||
|
OutputValue int64
|
||||||
|
Description string
|
||||||
|
DiscoveryHint string // shown when newly discovered via !lore
|
||||||
}
|
}
|
||||||
|
|
||||||
var thomCraftRecipes = []ThomCraftRecipe{
|
var thomCraftRecipes = []ThomCraftRecipe{
|
||||||
@@ -98,6 +101,43 @@ var thomCraftRecipes = []ThomCraftRecipe{
|
|||||||
Description: "Light armor. Stealth advantage 1/day. (Effect wired in R6 polish.)",
|
Description: "Light armor. Stealth advantage 1/day. (Effect wired in R6 polish.)",
|
||||||
DiscoveryHint: "Thom: \"Three lengths of ghost silk woven against one drop of shadow essence. The silk fights you while you stitch. That's how you know it's working.\"",
|
DiscoveryHint: "Thom: \"Three lengths of ghost silk woven against one drop of shadow essence. The silk fights you while you stitch. That's how you know it's working.\"",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: "drow_poison_blade",
|
||||||
|
Name: "Drow Poison Blade",
|
||||||
|
Ingredients: map[string]int{
|
||||||
|
"Drow Poison (diluted)": 1,
|
||||||
|
},
|
||||||
|
BladeWeaponCount: 1,
|
||||||
|
OutputName: "Drow Poison Blade",
|
||||||
|
OutputType: "weapon",
|
||||||
|
OutputTier: 4,
|
||||||
|
OutputValue: 540,
|
||||||
|
Description: "Coated blade — sleep-on-crit (DC 14 CON save) for 3 combats. (Effect wired in R6 polish.)",
|
||||||
|
DiscoveryHint: "Thom turns a glass vial in the lamplight. \"Drow brew, drawn down to a thin coat. Bring me any blade you trust — sword, dagger, scimitar, makes no odds. The poison picks the edge.\"",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// bladeWeaponKeywords are the name-substrings that qualify an AdvItem
|
||||||
|
// (Type=="weapon") as a blade for the §8.2 "any blade weapon" matcher.
|
||||||
|
var bladeWeaponKeywords = []string{
|
||||||
|
"sword", "dagger", "blade", "scimitar", "rapier",
|
||||||
|
"knife", "cutlass", "saber", "katana", "falchion",
|
||||||
|
"shortsword", "longsword", "greatsword",
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBladeWeapon reports whether an inventory item qualifies as a blade
|
||||||
|
// for crafting purposes — type "weapon" plus a blade keyword in the name.
|
||||||
|
func isBladeWeapon(it AdvItem) bool {
|
||||||
|
if it.Type != "weapon" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
low := strings.ToLower(it.Name)
|
||||||
|
for _, kw := range bladeWeaponKeywords {
|
||||||
|
if strings.Contains(low, kw) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// findRecipeByID returns the recipe with the given ID.
|
// findRecipeByID returns the recipe with the given ID.
|
||||||
@@ -402,6 +442,18 @@ func renderRecipeBook(userID id.UserID, known map[string]bool) string {
|
|||||||
ready = false
|
ready = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if r.BladeWeaponCount > 0 {
|
||||||
|
haveBlades := 0
|
||||||
|
for _, it := range items {
|
||||||
|
if isBladeWeapon(it) {
|
||||||
|
haveBlades++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("any blade weapon ×%d (%d)", r.BladeWeaponCount, haveBlades))
|
||||||
|
if haveBlades < r.BladeWeaponCount {
|
||||||
|
ready = false
|
||||||
|
}
|
||||||
|
}
|
||||||
sb.WriteString(" Ingredients: " + strings.Join(parts, " + ") + "\n")
|
sb.WriteString(" Ingredients: " + strings.Join(parts, " + ") + "\n")
|
||||||
sb.WriteString(" " + r.Description + "\n")
|
sb.WriteString(" " + r.Description + "\n")
|
||||||
if ready {
|
if ready {
|
||||||
@@ -439,13 +491,19 @@ func (p *AdventurePlugin) executeCraft(userID id.UserID, r ThomCraftRecipe) erro
|
|||||||
return p.SendDM(userID, "Couldn't load inventory.")
|
return p.SendDM(userID, "Couldn't load inventory.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick item IDs to consume — first-fit per ingredient name.
|
// Pick item IDs to consume — first-fit per ingredient name plus any
|
||||||
|
// generic "blade weapon" slots required by the recipe.
|
||||||
consume, missing := pickIngredients(items, r.Ingredients)
|
consume, missing := pickIngredients(items, r.Ingredients)
|
||||||
if len(missing) > 0 {
|
bladeIDs, bladeShort := pickBladeWeapons(items, r.BladeWeaponCount, consume)
|
||||||
|
consume = append(consume, bladeIDs...)
|
||||||
|
if len(missing) > 0 || bladeShort > 0 {
|
||||||
var lines []string
|
var lines []string
|
||||||
for ing, short := range missing {
|
for ing, short := range missing {
|
||||||
lines = append(lines, fmt.Sprintf(" • %s — short %d", ing, short))
|
lines = append(lines, fmt.Sprintf(" • %s — short %d", ing, short))
|
||||||
}
|
}
|
||||||
|
if bladeShort > 0 {
|
||||||
|
lines = append(lines, fmt.Sprintf(" • any blade weapon — short %d", bladeShort))
|
||||||
|
}
|
||||||
return p.SendDM(userID, fmt.Sprintf(
|
return p.SendDM(userID, fmt.Sprintf(
|
||||||
"_Thom: \"Not enough material. Come back when you've got the rest.\"_\n\n%s",
|
"_Thom: \"Not enough material. Come back when you've got the rest.\"_\n\n%s",
|
||||||
strings.Join(lines, "\n")))
|
strings.Join(lines, "\n")))
|
||||||
@@ -473,6 +531,33 @@ func (p *AdventurePlugin) executeCraft(userID id.UserID, r ThomCraftRecipe) erro
|
|||||||
r.OutputName, r.OutputTier, r.OutputValue, r.Description))
|
r.OutputName, r.OutputTier, r.OutputValue, r.Description))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pickBladeWeapons returns inventory IDs covering the requested generic
|
||||||
|
// blade-weapon count, skipping any IDs already in `exclude`. Returns the
|
||||||
|
// chosen IDs plus a shortfall count when inventory can't cover.
|
||||||
|
func pickBladeWeapons(items []AdvItem, count int, exclude []int64) ([]int64, int) {
|
||||||
|
if count <= 0 {
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
used := map[int64]bool{}
|
||||||
|
for _, id := range exclude {
|
||||||
|
used[id] = true
|
||||||
|
}
|
||||||
|
var picked []int64
|
||||||
|
for _, it := range items {
|
||||||
|
if len(picked) >= count {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if used[it.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isBladeWeapon(it) {
|
||||||
|
picked = append(picked, it.ID)
|
||||||
|
used[it.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return picked, count - len(picked)
|
||||||
|
}
|
||||||
|
|
||||||
// pickIngredients walks the inventory and selects item IDs to consume,
|
// pickIngredients walks the inventory and selects item IDs to consume,
|
||||||
// covering all required (Name → count). Returns (idsToRemove, missingByName).
|
// covering all required (Name → count). Returns (idsToRemove, missingByName).
|
||||||
func pickIngredients(items []AdvItem, need map[string]int) ([]int64, map[string]int) {
|
func pickIngredients(items []AdvItem, need map[string]int) ([]int64, map[string]int) {
|
||||||
|
|||||||
@@ -366,6 +366,10 @@ func (p *AdventurePlugin) handleHarvestCmd(ctx MessageContext, action HarvestAct
|
|||||||
prettyRoomType(run.CurrentRoomType())))
|
prettyRoomType(run.CurrentRoomType())))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if blockReason := zoneConditionHarvestBlock(exp, action); blockReason != "" {
|
||||||
|
return p.SendDM(ctx.Sender, blockReason)
|
||||||
|
}
|
||||||
|
|
||||||
roomIdx := run.CurrentRoom
|
roomIdx := run.CurrentRoom
|
||||||
nodes := loadHarvestNodes(exp, roomIdx)
|
nodes := loadHarvestNodes(exp, roomIdx)
|
||||||
idx := pickAvailableNode(nodes, exp, char, action)
|
idx := pickAvailableNode(nodes, exp, char, action)
|
||||||
@@ -415,7 +419,9 @@ func (p *AdventurePlugin) handleHarvestCmd(ctx MessageContext, action HarvestAct
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
total := roll + mod
|
total := roll + mod
|
||||||
outcome := resolveHarvestOutcome(total, res.DC)
|
dcDelta, dcReason := zoneConditionHarvestDCMod(exp, action)
|
||||||
|
effectiveDC := res.DC + dcDelta
|
||||||
|
outcome := resolveHarvestOutcome(total, effectiveDC)
|
||||||
outcome = rangerRareCatchUpgrade(char.Class, action, outcome, nil)
|
outcome = rangerRareCatchUpgrade(char.Class, action, outcome, nil)
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -426,8 +432,17 @@ func (p *AdventurePlugin) handleHarvestCmd(ctx MessageContext, action HarvestAct
|
|||||||
b.WriteString(dist)
|
b.WriteString(dist)
|
||||||
}
|
}
|
||||||
verb := strings.ToTitle(string(action)[:1]) + string(action)[1:]
|
verb := strings.ToTitle(string(action)[:1]) + string(action)[1:]
|
||||||
b.WriteString(fmt.Sprintf("**%s · %s** — d20=%d + %d = **%d** vs DC %d\n\n",
|
if dcDelta != 0 {
|
||||||
verb, res.Name, roll, mod, total, res.DC))
|
sign := "+"
|
||||||
|
if dcDelta < 0 {
|
||||||
|
sign = ""
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("**%s · %s** — d20=%d + %d = **%d** vs DC %d (%s%d %s)\n\n",
|
||||||
|
verb, res.Name, roll, mod, total, effectiveDC, sign, dcDelta, dcReason))
|
||||||
|
} else {
|
||||||
|
b.WriteString(fmt.Sprintf("**%s · %s** — d20=%d + %d = **%d** vs DC %d\n\n",
|
||||||
|
verb, res.Name, roll, mod, total, effectiveDC))
|
||||||
|
}
|
||||||
|
|
||||||
switch outcome {
|
switch outcome {
|
||||||
case OutcomeNothing:
|
case OutcomeNothing:
|
||||||
@@ -463,6 +478,19 @@ func (p *AdventurePlugin) handleHarvestCmd(ctx MessageContext, action HarvestAct
|
|||||||
b.WriteString(flavor.Pick(flavor.NodeDepleted))
|
b.WriteString(flavor.Pick(flavor.NodeDepleted))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §3 Zone 05 — Manor secondary drop on !scavenge (10% per attempt).
|
||||||
|
if rollManorCursedTrinket(exp.ZoneID, action, nil) {
|
||||||
|
if trinket, ok := FindZoneResource(exp.ZoneID, "cursed_trinket"); ok {
|
||||||
|
_ = grantHarvestYield(ctx.Sender, trinket, 1)
|
||||||
|
b.WriteString("\n\n_Something cold turns up in your kit — a small trinket you didn't pocket. **+1 Cursed Trinket.**_")
|
||||||
|
if warned, _ := exp.RegionState["manor_cursed_warned"].(bool); !warned {
|
||||||
|
b.WriteString("\n\n_TwinBee, low: \"Cursed items in this house bind to anyone who equips them. Sell them. Don't wear them. I'll only say this once.\"_")
|
||||||
|
exp.RegionState["manor_cursed_warned"] = true
|
||||||
|
_ = persistRegionState(exp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := saveHarvestNodes(exp, roomIdx, nodes); err != nil {
|
if err := saveHarvestNodes(exp, roomIdx, nodes); err != nil {
|
||||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
|
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
|
||||||
fmt.Sprintf("persistence error: %v", err), "")
|
fmt.Sprintf("persistence error: %v", err), "")
|
||||||
@@ -777,3 +805,130 @@ func retireAllRegionRuns(exp *Expedition) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── R6: Zone-condition harvest interactions (§3 zone notes) ──────────────────
|
||||||
|
|
||||||
|
// zoneConditionHarvestBlock returns a human-facing block message when the
|
||||||
|
// current zone state forbids harvesting entirely (Heat 10 in Underforge,
|
||||||
|
// Infernax awake in Dragon's Lair, Instability 81+ in Abyss). Empty
|
||||||
|
// string = harvesting allowed.
|
||||||
|
func zoneConditionHarvestBlock(exp *Expedition, action HarvestAction) string {
|
||||||
|
switch exp.ZoneID {
|
||||||
|
case ZoneUnderforge:
|
||||||
|
if action == HarvestMine && exp.TemporalStack >= UnderforgeMaxHeat {
|
||||||
|
return "_The forge stones are white-hot. The hammers shake out of your hands before they bite. Mining is impossible until the heat drops._"
|
||||||
|
}
|
||||||
|
case ZoneDragonsLair:
|
||||||
|
if awake, _ := exp.RegionState["infernax_awake"].(bool); awake {
|
||||||
|
return "_Infernax is hunting these halls now. There's no time to harvest — keep moving or extract._"
|
||||||
|
}
|
||||||
|
case ZoneAbyssPortal:
|
||||||
|
if exp.TemporalStack >= 81 {
|
||||||
|
return "_The Abyss is unraveling. Reality is too loose to anchor a harvest — what you reach for slides between your fingers._"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoneConditionHarvestDCMod returns a (deltaDC, reason) pair applied to
|
||||||
|
// the per-zone harvest roll. Reason is shown alongside the dice line.
|
||||||
|
func zoneConditionHarvestDCMod(exp *Expedition, action HarvestAction) (int, string) {
|
||||||
|
switch exp.ZoneID {
|
||||||
|
case ZoneUnderforge:
|
||||||
|
if action == HarvestMine && exp.TemporalStack >= 7 {
|
||||||
|
return 3, "heat-baked stone"
|
||||||
|
}
|
||||||
|
case ZoneDragonsLair:
|
||||||
|
// Awareness pulses fire on every DragonsLairAwarenessCycleDays.
|
||||||
|
// Pulse 3+ = day 9+. Use day count as the proxy (matches §7.5
|
||||||
|
// pulse cadence and the spec's "Awareness Pulse 3+" reading).
|
||||||
|
pulses := 0
|
||||||
|
if d := exp.CurrentDay; d > 0 {
|
||||||
|
pulses = d / DragonsLairAwarenessCycleDays
|
||||||
|
}
|
||||||
|
if pulses >= 3 {
|
||||||
|
return 4, "the lair is alert"
|
||||||
|
}
|
||||||
|
case ZoneAbyssPortal:
|
||||||
|
if exp.TemporalStack >= 61 && exp.TemporalStack < 81 {
|
||||||
|
return 5, "reality surges"
|
||||||
|
}
|
||||||
|
case ZoneFeywildCrossing:
|
||||||
|
if action == HarvestForage {
|
||||||
|
raw, _ := exp.RegionState["feywild_today"].(string)
|
||||||
|
if FeywildDistortion(raw) == FeywildDistortionDouble {
|
||||||
|
return -3, "double-day bloom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// manorCursedTrinketChance is §3 Zone 05 — every !scavenge in the Manor
|
||||||
|
// has a 10% secondary-drop chance regardless of node outcome.
|
||||||
|
const manorCursedTrinketChance = 10
|
||||||
|
|
||||||
|
// rollManorCursedTrinket returns true when a !scavenge in the Manor
|
||||||
|
// should also yield a Cursed Trinket. rng==nil falls back to math/rand.
|
||||||
|
func rollManorCursedTrinket(zoneID ZoneID, action HarvestAction, rng func(int) int) bool {
|
||||||
|
if zoneID != ZoneManorBlackspire || action != HarvestScavenge {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
roll := 0
|
||||||
|
if rng != nil {
|
||||||
|
roll = rng(100)
|
||||||
|
} else {
|
||||||
|
roll = rand.IntN(100)
|
||||||
|
}
|
||||||
|
return roll < manorCursedTrinketChance
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreHarvestNodesInRoom resets every node in (region, room) to its
|
||||||
|
// MaxCharges. Used by the Feywild Time Loop event (§7.4) — "previous
|
||||||
|
// harvest results in that room are nullified."
|
||||||
|
func restoreHarvestNodesInRoom(exp *Expedition, roomIdx int) bool {
|
||||||
|
if roomIdx < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
nodes := loadHarvestNodes(exp, roomIdx)
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
for i := range nodes {
|
||||||
|
if nodes[i].CurrentCharges < nodes[i].MaxCharges {
|
||||||
|
nodes[i].CurrentCharges = nodes[i].MaxCharges
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Write the mutated nodes back into the in-memory table; the
|
||||||
|
// briefing/cycle flow persists RegionState downstream. Avoiding the
|
||||||
|
// persist call here keeps the helper test-friendly without a DB.
|
||||||
|
regionKey := regionHarvestKey(exp)
|
||||||
|
roomKey := roomHarvestKey(roomIdx)
|
||||||
|
table := loadHarvestTable(exp)
|
||||||
|
regionMap := table[regionKey]
|
||||||
|
if regionMap == nil {
|
||||||
|
regionMap = map[string][]HarvestNode{}
|
||||||
|
}
|
||||||
|
regionMap[roomKey] = nodes
|
||||||
|
table[regionKey] = regionMap
|
||||||
|
exp.RegionState[regionStateHarvestKey] = table
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentRoomIndexFor returns the room index of the active region run
|
||||||
|
// for an expedition, or -1 if no run is linked or the run can't load.
|
||||||
|
func currentRoomIndexFor(e *Expedition) int {
|
||||||
|
if e == nil || e.RunID == "" {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
run, err := getZoneRun(e.RunID)
|
||||||
|
if err != nil || run == nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return run.CurrentRoom
|
||||||
|
}
|
||||||
|
|||||||
@@ -372,7 +372,14 @@ func feywildTemporalPostRollover(e *Expedition) []string {
|
|||||||
_ = appendExpeditionLog(e.ID, e.CurrentDay, "temporal",
|
_ = appendExpeditionLog(e.ID, e.CurrentDay, "temporal",
|
||||||
"feywild distortion: time loop (room re-enters with new enemies; loot already taken)",
|
"feywild distortion: time loop (room re-enters with new enemies; loot already taken)",
|
||||||
line)
|
line)
|
||||||
return []string{line}
|
// §3 Zone 08 — Time Loop returns previously-harvested resources
|
||||||
|
// to the current room's nodes. Combat loot stays gone; only the
|
||||||
|
// renewable harvest nodes loop back.
|
||||||
|
out := []string{line}
|
||||||
|
if restored := restoreHarvestNodesInRoom(e, currentRoomIndexFor(e)); restored {
|
||||||
|
out = append(out, "_Harvest nodes in this room have re-emerged — the timeline isn't sure they were ever taken._")
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
257
internal/plugin/dnd_zone_conditions_test.go
Normal file
257
internal/plugin/dnd_zone_conditions_test.go
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user